diff --git a/scripts/fleet_monitor.sh b/scripts/fleet_monitor.sh index 0b1f35886..1b3666e79 100755 --- a/scripts/fleet_monitor.sh +++ b/scripts/fleet_monitor.sh @@ -723,7 +723,9 @@ fi # Produces fleet_stub_drift.json — the DRIFTED enrolled repos across every # processed stub, each row tagged with its `stub`/`stub_file` so the workflow's # "Track stub drift" step can group/route per stub kind. HAS_STUB_DRIFT wakes -# dev-lead. A combined empty array is emitted when no stub drifted. +# dev-lead. A combined empty array is emitted when no stub drifted. Role rows +# classified from a legacy per-role file (repo has no agent-ingress.yml) are left +# out by stub_drift_alert_json and appear only in the report, marked legacy (#2289). # --------------------------------------------------------------------------- STUB_DRIFT_JSON="fleet_stub_drift.json" if [ ${#stub_drift_files[@]} -gt 0 ]; then diff --git a/scripts/fleet_stub_drift.sh b/scripts/fleet_stub_drift.sh index 1ae964d25..29ff67cbe 100755 --- a/scripts/fleet_stub_drift.sh +++ b/scripts/fleet_stub_drift.sh @@ -14,8 +14,10 @@ # GitHub's contents API returns in `.sha` (git blob object IDs), so equal SHAs # mean byte-identical files. Network fetching lives in fleet_monitor.sh. # -# Drift TSV format (4 fields, tab-separated): -# 1:repo 2:status 3:repo_sha 4:canonical_sha +# Drift TSV format (4 fields, tab-separated, plus an optional 5th): +# 1:repo 2:status 3:repo_sha 4:canonical_sha [5:source] +# source is `legacy` on a collapsed-role row classified from the legacy per-role +# file because the repo has no agent-ingress.yml (#2289); absent otherwise. # status ∈ { ALIGNED, DRIFTED, MISSING } # ALIGNED — stub present and byte-identical to the canonical template # DRIFTED — stub present but its blob SHA differs (the alertable set) @@ -151,19 +153,21 @@ classify_role_drift() { # through the identical report/alert path as whole-file (#822/#886) rows. The # emitted repo_sha/canonical_sha are the pair the classification was actually made # on: the block SHAs when an agent-ingress.yml is present, else the legacy -# whole-file SHAs — so the rendered table stays meaningful either way. +# whole-file SHAs — so the rendered table stays meaningful either way. A +# legacy-fallback row carries a 5th field `legacy` (#2289): it is not +# agent-ingress.yml drift, so stub_drift_alert_json keeps it out of the alert / +# remediation set while generate_stub_drift_report still lists it. stub_drift_row_role() { local repo="${1:-}" ingress_present="${2:-}" canon_block="${3:-}" local ingress_block="${4:-}" legacy_canon="${5:-}" legacy_repo="${6:-}" - local status repo_sha canonical + local status status="$(classify_role_drift "$ingress_present" "$canon_block" \ "$ingress_block" "$legacy_canon" "$legacy_repo")" if [ "$ingress_present" = "yes" ]; then - repo_sha="$ingress_block"; canonical="$canon_block" + printf '%s\t%s\t%s\t%s\n' "$repo" "$status" "$ingress_block" "$canon_block" else - repo_sha="$legacy_repo"; canonical="$legacy_canon" + printf '%s\t%s\t%s\t%s\tlegacy\n' "$repo" "$status" "$legacy_repo" "$legacy_canon" fi - printf '%s\t%s\t%s\t%s\n' "$repo" "$status" "$repo_sha" "$canonical" } # count_stub_drift @@ -182,6 +186,11 @@ count_stub_drift() { # is given (a collapsed agent-ingress.yml job, #1726), each row is also # tagged with `role` so remediation patches the RIGHT job block — the stub_file # alone is ambiguous once several roles share one agent-ingress.yml. +# Rows tagged `legacy` (classified from a legacy per-role file because the repo +# has no agent-ingress.yml, #2289) are EXCLUDED: stamping them with the +# agent-ingress.yml stub_file would tell dev-lead / remediation to write an +# ingress into a repo that has none — the ADR-0007 double-dispatch trap. The +# compliance audit's stub-surface checks govern legacy per-role stubs. stub_drift_alert_json() { local f="${1:-}" stub_label="${2:-}" stub_file="${3:-}" role="${4:-}" if [ -z "$f" ] || [ ! -s "$f" ]; then @@ -192,7 +201,7 @@ stub_drift_alert_json() { [ inputs | select(length > 0) | split("\t") - | select(length >= 4 and .[1] == "DRIFTED") + | select(length >= 4 and .[1] == "DRIFTED" and .[4] != "legacy") | { repo: .[0], status: .[1], repo_sha: .[2], canonical_sha: .[3] } | if $stub != "" then . + { stub: $stub } else . end | if $stub_file != "" then . + { stub_file: $stub_file } else . end @@ -224,10 +233,22 @@ generate_stub_drift_report() { return 0 fi - printf 'These enrolled repos have drifted from the canonical stub — re-sync them:\n\n' - printf '| Repo | Status | Stub SHA | Canonical SHA |\n' - printf '|---|---|---|---|\n' - awk -F'\t' '$2 == "DRIFTED" { - printf "| `%s` | 🔴 %s | `%s` | `%s` |\n", $1, $2, substr($3,1,7), substr($4,1,7) - }' "$f" + if awk -F'\t' '$2 == "DRIFTED" && $5 != "legacy" { found = 1; exit } END { exit !found }' "$f"; then + printf 'These enrolled repos have drifted from the canonical stub — re-sync them:\n\n' + printf '| Repo | Status | Stub SHA | Canonical SHA |\n' + printf '|---|---|---|---|\n' + awk -F'\t' '$2 == "DRIFTED" && $5 != "legacy" { + printf "| `%s` | 🔴 %s | `%s` | `%s` |\n", $1, $2, substr($3,1,7), substr($4,1,7) + }' "$f" + fi + + if awk -F'\t' '$2 == "DRIFTED" && $5 == "legacy" { found = 1; exit } END { exit !found }' "$f"; then + printf '\n### Legacy per-role stubs (repos without agent-ingress.yml)\n\n' + printf 'These repos use per-role stubs and are not enrolled in agent-ingress.yml. They are not alerted or remediated in the above; the compliance audit stub-surface checks cover them.\n\n' + printf '| Repo | Status | Stub SHA | Canonical SHA |\n' + printf '|---|---|---|---|\n' + awk -F'\t' '$2 == "DRIFTED" && $5 == "legacy" { + printf "| `%s` | 🔴 %s | `%s` | `%s` |\n", $1, $2, substr($3,1,7), substr($4,1,7) + }' "$f" + fi } diff --git a/tests/fleet_stub_drift.bats b/tests/fleet_stub_drift.bats index a605c2fab..1d7a0e428 100644 --- a/tests/fleet_stub_drift.bats +++ b/tests/fleet_stub_drift.bats @@ -289,9 +289,54 @@ INGRESS_NO_DEVLEAD="${BATS_TEST_DIRNAME}/fixtures/agent-ingress/collapsed-no-dev [ "$output" = "$(printf 'petry-projects/charlie\tMISSING\t\t%s' "$canon_dl")" ] } -@test "stub_drift_row_role: no ingress (legacy repo) emits the legacy whole-file SHAs (AC #4)" { +@test "stub_drift_row_role: no ingress (legacy repo) emits the legacy whole-file SHAs, tagged legacy (AC #4, #2289)" { local canon="cafecafecafecafecafecafecafecafecafecafe" run stub_drift_row_role "petry-projects/delta" "no" "block-sha-ignored" "" "$canon" "$canon" [ "$status" -eq 0 ] - [ "$output" = "$(printf 'petry-projects/delta\tALIGNED\t%s\t%s' "$canon" "$canon")" ] + [ "$output" = "$(printf 'petry-projects/delta\tALIGNED\t%s\t%s\tlegacy' "$canon" "$canon")" ] +} + +# --------------------------------------------------------------------------- +# Legacy-fallback rows (#2289): a role row classified from the legacy per-role +# file (no agent-ingress.yml) is reported but never alerted or remediated as +# agent-ingress.yml drift. +# --------------------------------------------------------------------------- + +@test "stub_drift_alert_json: a legacy-tagged DRIFTED row is excluded; an ingress row is kept (#2289)" { + tsv="$(mktemp)" + stub_drift_row_role "petry-projects/legacy" "no" "" "" "$CANON" "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" > "$tsv" + stub_drift_row_role "petry-projects/markets" "yes" "$CANON" "ffffffffffffffffffffffffffffffffffffffff" "" "" >> "$tsv" + run stub_drift_alert_json "$tsv" "Dev-lead" ".github/workflows/agent-ingress.yml" "dev-lead" + rm -f "$tsv" + [ "$status" -eq 0 ] + [ "$(printf '%s' "$output" | jq -c '[.[] | {repo, stub_file, role}]')" = \ + '[{"repo":"petry-projects/markets","stub_file":".github/workflows/agent-ingress.yml","role":"dev-lead"}]' ] +} + +@test "generate_stub_drift_report: a legacy DRIFTED row stays in the table, marked legacy and not alerted (#2289)" { + tsv="$(mktemp)" + stub_drift_row_role "petry-projects/legacy" "no" "" "" "$CANON" "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" > "$tsv" + stub_drift_row_role "petry-projects/markets" "yes" "$CANON" "ffffffffffffffffffffffffffffffffffffffff" "" "" >> "$tsv" + run generate_stub_drift_report "$tsv" "$CANON" "Dev-lead" + rm -f "$tsv" + [ "$status" -eq 0 ] + [[ "$output" == *"🔴 DRIFTED: 2"* ]] + local legacy_section before_legacy + legacy_section="$(printf '%s\n' "$output" | sed -n '/^### Legacy per-role stubs/,$p')" + before_legacy="$(printf '%s\n' "$output" | sed '/^### Legacy per-role stubs/,$d')" + [[ "$legacy_section" == *'`petry-projects/legacy`'* ]] + [[ "$legacy_section" != *'`petry-projects/markets`'* ]] + [[ "$before_legacy" == *'`petry-projects/markets`'* ]] + [[ "$before_legacy" != *'`petry-projects/legacy`'* ]] + [[ "$output" == *"not alerted"* ]] +} + +@test "generate_stub_drift_report: legacy-only drift omits the re-sync remediation table (#2289)" { + tsv="$(mktemp)" + stub_drift_row_role "petry-projects/legacy" "no" "" "" "$CANON" "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" > "$tsv" + run generate_stub_drift_report "$tsv" "$CANON" "Dev-lead" + rm -f "$tsv" + [ "$status" -eq 0 ] + [[ "$output" != *"re-sync them"* ]] + [[ "$output" == *"### Legacy per-role stubs"* ]] } diff --git a/tests/fleet_stub_registry.bats b/tests/fleet_stub_registry.bats index eee405ba0..9216e386e 100644 --- a/tests/fleet_stub_registry.bats +++ b/tests/fleet_stub_registry.bats @@ -286,3 +286,68 @@ registry_rows() { [ "$(role_counts Ci-failure-analyst)" = "$(counts_line 0 0 1)" ] grep -qF "repos/petry-projects/legacy/contents/.github/workflows/dev-lead.yml" "$CALLS" } + +# --------------------------------------------------------------------------- +# #2289 — a legacy repo (no agent-ingress.yml) is classified from its per-role +# file, but its drift is NOT agent-ingress.yml drift: it is reported (marked +# legacy) and kept out of fleet_stub_drift.json, the alert + remediation input. +# --------------------------------------------------------------------------- + +# drift_dev_lead_block — print the markets ingress with ONLY the dev-lead job +# block changed (one line inside it), so its block SHA differs from canon. +drift_dev_lead_block() { + awk ' + $0 ~ /^ dev-lead:/ { injob = 1 } + injob && /^ uses:/ && !done { sub(/@dev-lead\/v139-stable/, "@dev-lead/v1-stable"); done = 1 } + { print } + ' "$MARKETS_INGRESS" +} + +@test "#2289(a): a legacy repo whose per-role file drifts is reported, marked legacy, but not in fleet_stub_drift.json" { + publish_canonicals + printf 'name: drifted\n' | put_file "petry-projects/legacy" ".github/workflows/dev-lead.yml" + + run_monitor petry-projects/legacy + [ "$status" -eq 0 ] + [ "$(role_counts Dev-lead)" = "$(counts_line 0 1 0)" ] + sed -n '/^### Legacy per-role stubs/,$p' "${WORK}/fleet_monitor_report.md" | grep -qF '`petry-projects/legacy`' + [ "$(jq -c . "${WORK}/fleet_stub_drift.json")" = "[]" ] +} + +@test "#2289(b): a collapsed repo whose block drifts is in fleet_stub_drift.json against agent-ingress.yml" { + publish_canonicals + drift_dev_lead_block | put_file "petry-projects/markets" "$INGRESS_STUB" + # Sanity: only the dev-lead block differs from canon. + [ "$(job_block_sha dev-lead < "${FAKE_ROOT}/petry-projects/markets/${INGRESS_STUB}")" != \ + "$(job_block_sha dev-lead < "$MARKETS_INGRESS")" ] + + run_monitor petry-projects/markets + [ "$status" -eq 0 ] + [ "$(role_counts Dev-lead)" = "$(counts_line 0 1 0)" ] + [ "$(jq -c '[.[] | {repo, stub_file, role}]' "${WORK}/fleet_stub_drift.json")" = \ + "$(jq -nc --arg sf "$INGRESS_STUB" '[{repo: "petry-projects/markets", stub_file: $sf, role: "dev-lead"}]')" ] +} + +@test "#2289(c): the remediation input never lists agent-ingress.yml for a repo that has no ingress" { + publish_canonicals + drift_dev_lead_block | put_file "petry-projects/markets" "$INGRESS_STUB" + # The legacy repo drifts on every role that has a per-role file. + local role + for role in "${ROLES[@]}"; do + printf 'name: drifted %s\n' "$role" | put_file "petry-projects/legacy" ".github/workflows/${role}.yml" + done + + run_monitor petry-projects/legacy petry-projects/markets + [ "$status" -eq 0 ] + # The legacy repo is reported DRIFTED (dev-lead row) yet absent from the JSON. + [ "$(role_counts Dev-lead)" = "$(counts_line 0 2 0)" ] + [ "$(jq '[.[] | select(.repo == "petry-projects/legacy")] | length' "${WORK}/fleet_stub_drift.json")" -eq 0 ] + + # The remediation plan built from that JSON targets only markets' ingress. + source "$REMEDIATE" + plan="$(build_remediation_plan "${WORK}/fleet_stub_drift.json")" + [ "$(printf '%s' "$plan" | jq -r '[.[] | select(.stub_file == "'"$INGRESS_STUB"'") | .repo] | unique | join(",")')" = "petry-projects/markets" ] + run bash "$REMEDIATE" "${WORK}/fleet_stub_drift.json" + [ "$status" -eq 0 ] + [[ "$output" != *"petry-projects/legacy"* ]] +}