Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/fleet-reports.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Weekly fleet reports

# Regenerates fleet-reports/fleet-report.md (combined issue report + code census)
# Regenerates fleet-reports/fleet-report.md (issue report, site error budgets, code census)
# and fleet-reports/epics.md (tracking issues, their sub-issue progress, and the
# merged work attributed to each) from the fleet's public state, plus
# fleet-reports/incidents.md (the incident register, rebuilt from the local report
Expand All @@ -10,6 +10,34 @@ name: Weekly fleet reports
# fine-grained PAT stored as the repo secret FLEET_REPORTS_TOKEN (resource owner
# `lentago`, repo `.github`, permissions Contents + Pull requests: write). The
# generator's cross-repo reads work because every lentago repo is public.
#
# Site error budgets (lentago/drosera#199): fleet-report.md's error-budget table
# queries drosera's Grafana Cloud stack with a second, optional secret,
# FLEET_REPORTS_GRAFANA_TOKEN. It is passed to the regenerate step only. When it is
# unset the table renders a one-line "not configured" note and the run stays green;
# when it is set but fails (expired, revoked) the table says so and the run warns.
# Scope: a Grafana service-account token (`glsa_…`) under an account with the
# built-in Viewer role, which is enough to query the `grafanacloud-prom` data
# source through /api/datasources/proxy and can change nothing. Not a Cloud
# access-policy token and not the `terraform-iac` Admin account.
# Creating it (ADR-0011 rule 4: Terraform holds the slot, the value is set by hand):
# 1. In lentago/drosera, add to terraform/service_accounts.tf (and its row to
# terraform/README.md § Service accounts), and merge so CI applies it:
# resource "grafana_service_account" "fleet_reports_viewer" {
# name = "fleet-reports-viewer"
# role = "Viewer"
# is_disabled = false
# }
# 2. In https://lentago.grafana.net, Administration → Users and access →
# Service accounts → fleet-reports-viewer → Add service account token,
# named `lentago/.github fleet-reports`. Copy it (shown once).
# 3. Store it without echoing it:
# gh secret set FLEET_REPORTS_GRAFANA_TOKEN --repo lentago/.github
# (paste at the prompt). Record any expiry in the credential register (#243).
# 4. Run this workflow by hand (workflow_dispatch) and check that the refresh PR's
# fleet-report.md shows the table rather than the note (ADR-0011 rule 5).
# Rotate: mint a new token, `gh secret set` again, delete the old token.
# Revoke: delete the token in the stack; the table falls back to an error note.

on:
schedule:
Expand Down Expand Up @@ -61,6 +89,9 @@ jobs:
sudo install -m 0755 /tmp/cloc-2.06.pl /usr/local/bin/cloc

- name: Regenerate reports
env:
# Optional; empty when the secret is unset. See the header.
FLEET_REPORTS_GRAFANA_TOKEN: ${{ secrets.FLEET_REPORTS_GRAFANA_TOKEN }}
run: python3 metrics/generate-fleet-reports.py --out-dir .

- name: Open auto-merging PR if anything changed
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ CI does: `python3 ci/validate.py`):
| `fleet` | `repos.json`, `required-checks.json`, `labels.json` and `brand/fleet.json` agree on which repos exist (a public repo with no required checks can't arm auto-merge; one with no brand entry ships bannerless) |
| `brand` | `brand/generated/` is reproducible from `brand/fleet.json` — the banners are copied verbatim into 15 repos, so a hand-edit forks the identity |
| `census` | `metrics/generate-fleet-reports.py` imports; its data/code and markdown classifiers route known paths correctly |
| `budget` | the fleet report's site error-budget section still sends drosera's SLO PromQL verbatim, counts burn stretches right, and degrades to one line without its token |
| `register` | `fleet-reports/incidents.md` is reproducible from `fleet-reports/incidents/` — it is generated, never hand-edited |

**Relative markdown links are not checked here.** That check lived in `validate.py`
Expand Down Expand Up @@ -188,7 +189,7 @@ section. When refreshing, update **all three** together: the table, the

`metrics/generate-fleet-reports.py` regenerates a single GitHub-rendered report,
**`fleet-reports/fleet-report.md`**, linked from the org profile under **Fleet in
numbers**. It has two parts from the fleet's public state:
numbers**. It has three parts:

- **Issues** — open issues by repo + a 30-day merge/close activity snapshot, from
public GitHub metadata only. The public, auto-generated sibling of the LAN-only
Expand All @@ -198,6 +199,14 @@ numbers**. It has two parts from the fleet's public state:
instruction markdown (+ reference-checker prompt-programs) as natural-language
code, and reports documentation / content / data separately. Distinct from the
canonical `metrics/language-census.md`; the two intentionally differ.
- **Site error budgets** — per public site, the 30-day SLO attainment, budget used and
left, and burn-rule stretches, from drosera's blackbox probes (drosera ADR-0008;
lentago/drosera#199). The PromQL is drosera's SLO board and burn rules verbatim, sent
through Grafana's data-source proxy with the optional repo secret
**`FLEET_REPORTS_GRAFANA_TOKEN`** (a `glsa_` token under a Viewer service account
minted from drosera's `terraform/service_accounts.tf`; setup steps in the workflow
header). Unset, the section is a one-line "not configured" note and the run stays
green.

Scope is the **active** `lentago` repos (archived repos are frozen and excluded;
GitHub's listing endpoints don't surface archived repos anyway). Regenerate with
Expand Down
59 changes: 59 additions & 0 deletions ci/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
census metrics/generate-fleet-reports.py imports, and its data/code classifier
still routes known paths correctly (regression cover for #59, where a
JSON-only carve-out counted 15,595 lines of CSV exports as source).
budget the site error-budget section degrades to a one-line note without its
token, counts burn stretches correctly, and still sends drosera's own
PromQL (pinned verbatim from its SLO board and burn rules).
register fleet-reports/incidents.md is reproducible from fleet-reports/incidents/.
It is generated, not hand-authored; a hand-edit here is a change that the
next weekly refresh silently reverts.
Expand All @@ -40,6 +43,7 @@
import subprocess
import sys
import tempfile
from datetime import datetime, timezone

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FAILURES = []
Expand Down Expand Up @@ -428,6 +432,60 @@ def check_census_classifier():
fail("census", f"classify_md({repo!r}, {path!r}) returned {got!r}, expected {want!r}")


# ------------------------------------------------------------------ budget
# Verbatim from lentago/drosera dashboards/sites-slo-error-budget.json (stat panels) and
# terraform/alerts.tf (burn rules), for lentago.dev. If drosera changes them, update the
# generator and these together — the report must show the board's numbers.
BUDGET_EXPRS = {
"attainment": 'avg_over_time(probe_success{job="integrations/blackbox/lentago.dev"}[30d])',
"remaining": '1 - (1 - avg_over_time(probe_success{job="integrations/blackbox/lentago.dev"}[30d])) / 0.001',
"minutes": '(0.001 - (1 - avg_over_time(probe_success{job="integrations/blackbox/lentago.dev"}[30d]))) * 43200',
}
BURN_1H = '(1 - avg_over_time(probe_success{job="integrations/blackbox/lentago.dev"}[1h])) / 0.001'

# (long points, short points, threshold, step, expected (episodes, last ts)).
# The second case is the one that matters: the long window alone is hot for a
# stretch, but only steps where the short window agrees count, and a gap splits
# them into two episodes.
BURN_CASES = [
([(0, 0.0), (300, 0.0)], [(0, 0.0), (300, 0.0)], 14.4, 300, (0, None)),
([(0, 20), (300, 20), (600, 20), (900, 20), (1200, 20)],
[(0, 20), (300, 1), (600, 20), (900, 20), (1200, 1)], 14.4, 300, (2, 900)),
([(0, 14.4)], [(0, 99)], 14.4, 300, (0, None)), # gt, not ge — as the rules
]


def check_error_budget_section():
try:
gen = load_generator()
except Exception as exc: # noqa: BLE001
fail("budget", f"metrics/generate-fleet-reports.py failed to import — {exc}")
return

if gen.slo_exprs("lentago.dev") != BUDGET_EXPRS:
fail("budget", "slo_exprs() no longer matches drosera's SLO-board PromQL verbatim")
if gen.burn_expr("lentago.dev", "1h") != BURN_1H:
fail("budget", "burn_expr() no longer matches drosera's burn-rule PromQL verbatim")

for long_pts, short_pts, thr, step, want in BURN_CASES:
got = gen.burn_episodes(long_pts, short_pts, thr, step)
if got != want:
fail("budget", f"burn_episodes(…, {thr}, {step}) returned {got}, expected {want}")

saved = os.environ.pop(gen.GRAFANA_TOKEN_ENV, None)
try:
rows, reason = gen.collect_error_budgets(datetime.now(timezone.utc))
finally:
if saved is not None:
os.environ[gen.GRAFANA_TOKEN_ENV] = saved
if rows is not None or reason != "not configured":
fail("budget", f"with no token, collect_error_budgets() returned {(rows, reason)!r}")
return
body = [ln for ln in gen.error_budget_section(rows, reason) if ln and not ln.startswith("## ")]
if len(body) != 1 or "Not configured" not in body[0]:
fail("budget", f"with no token the section should be one 'Not configured' line, got {body!r}")


# ------------------------------------------------------------------ register
GENERATED_LINE = re.compile(r"^\*\*Generated:\*\*")

Expand Down Expand Up @@ -482,6 +540,7 @@ def check_incident_register_reproducible():
("fleet", check_fleet_coverage),
("brand", check_brand_assets),
("census", check_census_classifier),
("budget", check_error_budget_section),
("register", check_incident_register_reproducible),
]

Expand Down
Loading
Loading