matrix.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. #!/usr/bin/env python3
  2. """Validate and render the e2e fan-out matrix defined in e2e/matrix.yaml.
  3. Subcommands:
  4. check Fail early if the matrix is inconsistent: a provider compiled into
  5. the suite (suites/provider/cases/import.go) is not covered by any
  6. area, needs_secrets disagrees with secret_groups, or an area names a
  7. secret group that the reusable workflow does not wire up.
  8. json Print the GitHub Actions matrix as compact JSON for the workflow's
  9. strategy.matrix. With --changed <file|->, keep only the legs that
  10. change can affect. See "Affected-only selection" in e2e/README.md.
  11. selftest Check the affected-only resolver against a table of known cases.
  12. plan Print, per enabled leg, exactly which credential env vars it will
  13. receive. Derived from each area's secret_groups and the group -> var
  14. mapping parsed out of e2e-reusable.yml. This reads NO secret values
  15. (it never touches the secrets context), so it proves the scoping
  16. without any risk of leaking a value, masked or not.
  17. Paths are resolved relative to this file, so the working directory does not
  18. matter. YAML is read with PyYAML when present, else via yq (mikefarah), so no
  19. new runtime dependency is required in CI.
  20. """
  21. import io
  22. import json
  23. import re
  24. import subprocess
  25. import sys
  26. from contextlib import redirect_stderr
  27. from fnmatch import fnmatchcase
  28. from pathlib import Path
  29. USAGE = "usage: matrix.py [check|json [--changed <file|->]|plan|selftest]"
  30. Match = tuple[str, str] | None
  31. HERE = Path(__file__).resolve().parent
  32. MATRIX = HERE / "matrix.yaml"
  33. IMPORT = HERE / "suites/provider/cases/import.go"
  34. WORKFLOW = HERE.parent / ".github/workflows/e2e-reusable.yml"
  35. def load_yaml(path: Path):
  36. """Load a YAML file as a dict. Prefer PyYAML; fall back to yq -> JSON."""
  37. try:
  38. import yaml # type: ignore
  39. return yaml.safe_load(path.read_text())
  40. except ModuleNotFoundError:
  41. out = subprocess.run(
  42. ["yq", "-o=json", str(path)],
  43. check=True, capture_output=True, text=True,
  44. ).stdout
  45. return json.loads(out)
  46. def imported_providers() -> list[str]:
  47. """Provider names compiled into the suite: the segment after cases/ in
  48. each blank import of import.go (cases/aws/secretsmanager -> aws)."""
  49. text = IMPORT.read_text()
  50. return sorted({m.group(1) for m in re.finditer(r"cases/([a-z0-9]+)", text)})
  51. def group_to_vars() -> dict[str, list[str]]:
  52. """Map each secret group to the env vars the reusable workflow gates on it,
  53. parsed from lines like:
  54. FOO: ${{ contains(matrix.secret_groups, 'aws') && secrets.BAR || '' }}
  55. Reads only the workflow text, never any secret value."""
  56. pat = re.compile(
  57. r"^\s*([A-Z0-9_]+):\s*\$\{\{\s*"
  58. r"contains\(matrix\.secret_groups,\s*'([a-z0-9]+)'\)",
  59. re.MULTILINE,
  60. )
  61. mapping: dict[str, list[str]] = {}
  62. for var, group in pat.findall(WORKFLOW.read_text()):
  63. mapping.setdefault(group, []).append(var)
  64. for group in mapping:
  65. mapping[group].sort()
  66. return mapping
  67. def tracked_files() -> list[str]:
  68. """Repo-relative tracked paths. The glob rules in check need to know that a
  69. pattern corresponds to something real."""
  70. out = subprocess.run(
  71. ["git", "-C", str(HERE.parent), "ls-files"],
  72. check=True, capture_output=True, text=True,
  73. ).stdout
  74. return out.splitlines()
  75. def cmd_check(matrix: dict) -> int:
  76. areas = matrix["areas"]
  77. errors: list[str] = []
  78. # 1. Every imported provider is covered by some area.
  79. covered = {p for a in areas for p in (a.get("providers") or [])}
  80. missing = [p for p in imported_providers() if p not in covered]
  81. if missing:
  82. errors.append(
  83. "providers imported into the e2e suite but not covered by any "
  84. "area (add each to an area's providers list and a leg):\n - "
  85. + "\n - ".join(missing)
  86. )
  87. # 2. needs_secrets must mirror "secret_groups is non-empty".
  88. for a in areas:
  89. has_groups = bool(a.get("secret_groups"))
  90. if bool(a.get("needs_secrets")) != has_groups:
  91. errors.append(
  92. f"area {a['name']!r}: needs_secrets={a.get('needs_secrets')} "
  93. f"disagrees with secret_groups={a.get('secret_groups')}"
  94. )
  95. # 3. Every secret group an area uses is actually wired in the workflow.
  96. wired = set(group_to_vars())
  97. for a in areas:
  98. for group in a.get("secret_groups") or []:
  99. if group not in wired:
  100. errors.append(
  101. f"area {a['name']!r}: secret group {group!r} is not wired "
  102. f"in {WORKFLOW.name} (no env var gates on it)"
  103. )
  104. # 4. Selection depends on paths, so an enabled area without them would run
  105. # only when full_matrix_paths hits and silently sit out every other PR.
  106. for a in areas:
  107. if a.get("enabled") and not a.get("paths"):
  108. errors.append(
  109. f"area {a['name']!r}: enabled but declares no paths, so "
  110. "affected-only selection would almost never run it"
  111. )
  112. shared = matrix.get("full_matrix_paths") or []
  113. live = [a for a in areas if a.get("enabled")]
  114. files = tracked_files()
  115. # 5. Every glob must match something. A typo like providers/v1/Azure/**
  116. # stops selecting its leg while check and selftest stay green, and unlike
  117. # enabled: false it leaves no trace on later pull requests.
  118. labelled = [("full_matrix_paths", g) for g in shared]
  119. labelled += [(f"area {a['name']!r}", g) for a in live
  120. for g in (a.get("paths") or [])]
  121. for where, glob in labelled:
  122. if not any(fnmatchcase(f, glob) for f in files):
  123. errors.append(f"{where}: glob {glob!r} matches no tracked file")
  124. # 6. Every suite's own files must reach some leg. They sit one level above
  125. # the per-case globs, so the provider suite's bootstrap was selected by
  126. # nothing until it was listed explicitly.
  127. selectable = shared + [g for a in live for g in (a.get("paths") or [])]
  128. for f in files:
  129. parts = f.split("/")
  130. if len(parts) == 4 and parts[:2] == ["e2e", "suites"]:
  131. if not any(fnmatchcase(f, g) for g in selectable):
  132. errors.append(
  133. f"suite file {f} is selected by no enabled leg, so a "
  134. "change to it would skip the legs that run it"
  135. )
  136. if errors:
  137. print("ERROR: matrix.yaml is inconsistent:", file=sys.stderr)
  138. for e in errors:
  139. print(f"- {e}", file=sys.stderr)
  140. return 1
  141. enabled = sum(1 for a in areas if a.get("enabled"))
  142. print(
  143. f"matrix.yaml ok: {len(imported_providers())} providers covered, "
  144. f"{enabled} leg(s) enabled"
  145. )
  146. return 0
  147. def parse_changed(text: str, source: str) -> list[str] | None:
  148. """Non-blank lines of text, or None for "nothing usable, run everything".
  149. An empty list is indistinguishable from a diff step that produced
  150. nothing, so it must not narrow the matrix."""
  151. paths = [line.strip() for line in text.splitlines() if line.strip()]
  152. if not paths:
  153. print(f"WARNING: no changed paths in {source}; running the full matrix",
  154. file=sys.stderr)
  155. return None
  156. return paths
  157. def read_changed(source: str) -> list[str] | None:
  158. """Changed paths, one per line, from a file or stdin ("-"). None means
  159. "run everything": an unreadable file is a broken diff step, not an empty
  160. diff, so it may not narrow the matrix either."""
  161. try:
  162. text = sys.stdin.read() if source == "-" else Path(source).read_text()
  163. except OSError as err:
  164. print(f"WARNING: cannot read changed paths from {source!r} ({err}); "
  165. "running the full matrix", file=sys.stderr)
  166. return None
  167. return parse_changed(text, repr(source))
  168. def first_match(patterns: list[str], paths: list[str]) -> Match:
  169. """First (pattern, path) pair that matches, else None. fnmatchcase, not
  170. fnmatch: the latter normalises case per platform, so a laptop and a Linux
  171. runner would disagree."""
  172. for pattern in patterns:
  173. for path in paths:
  174. if fnmatchcase(path, pattern):
  175. return pattern, path
  176. return None
  177. def select_areas(matrix: dict, changed: list[str] | None) -> list[dict]:
  178. """Enabled areas a change can affect; changed=None runs all of them. An
  179. area is kept when it is always-on or one of its paths globs matches, and
  180. full_matrix_paths keeps every area. See matrix.yaml for why that is not
  181. merely defensive."""
  182. enabled = [a for a in matrix["areas"] if a.get("enabled")]
  183. if changed is None:
  184. return enabled
  185. if hit := first_match(matrix.get("full_matrix_paths") or [], changed):
  186. print(f"full matrix: {hit[1]} matches full_matrix_paths {hit[0]!r}",
  187. file=sys.stderr)
  188. return enabled
  189. selected, dropped = [], []
  190. for a in enabled:
  191. if a.get("always") or first_match(a.get("paths") or [], changed):
  192. selected.append(a)
  193. else:
  194. dropped.append(a["name"])
  195. if dropped:
  196. print(f"affected-only: {len(selected)} of {len(enabled)} leg(s) "
  197. f"selected from {len(changed)} changed file(s); skipping "
  198. + ", ".join(dropped), file=sys.stderr)
  199. return selected
  200. def cmd_json(matrix: dict, changed_from: str | None = None) -> int:
  201. changed = read_changed(changed_from) if changed_from else None
  202. include = [
  203. {
  204. "name": a["name"],
  205. "suite": a["suite"],
  206. "labels": a["labels"],
  207. "secret_groups": a.get("secret_groups") or [],
  208. }
  209. for a in select_areas(matrix, changed)
  210. ]
  211. print(json.dumps({"include": include}, separators=(",", ":")))
  212. return 0
  213. def cmd_plan(matrix: dict) -> int:
  214. """Show the credential env vars each enabled leg will receive. No secret
  215. values are read; the list comes from matrix.yaml + the workflow mapping."""
  216. mapping = group_to_vars()
  217. print("Per-leg credential scoping (from matrix.yaml + e2e-reusable.yml):")
  218. for a in matrix["areas"]:
  219. if not a.get("enabled"):
  220. continue
  221. groups = a.get("secret_groups") or []
  222. env_vars = sorted({v for g in groups for v in mapping.get(g, [])})
  223. shown = ", ".join(env_vars) if env_vars else "(none: in-cluster only)"
  224. print(f" {a['name']}: groups={groups or '[]'} -> {shown}")
  225. return 0
  226. # (changed paths, expected leg names or ALL) for the resolver. Guards the two
  227. # rules whose silent failure costs coverage: fail open, and a shared-machinery
  228. # change running every leg. See cmd_selftest.
  229. ALL = None # in the table below: expect every enabled leg
  230. SELFTEST_CASES: list[tuple[list[str], set[str] | None]] = [
  231. # A provider change runs that provider plus the always-on floor.
  232. (["providers/v1/vault/client.go"], {"core-smoke", "vault"}),
  233. (["e2e/suites/provider/cases/aws/secretsmanager.go"],
  234. {"core-smoke", "aws"}),
  235. # grafana.go selects the generator leg too: both legs run the same suite
  236. # binary from the same Go package, so a change here can break its compile.
  237. (["e2e/suites/generator/grafana.go"],
  238. {"core-smoke", "generator", "grafana"}),
  239. # Two providers at once select both.
  240. (["providers/v1/vault/x.go", "providers/v1/gcp/y.go"],
  241. {"core-smoke", "vault", "gcp"}),
  242. # Shared machinery runs everything, even though most areas do not list it.
  243. (["runtime/reconciler.go"], ALL),
  244. (["apis/externalsecrets/v1/types.go"], ALL),
  245. (["e2e/framework/util.go"], ALL),
  246. (["e2e/matrix.yaml"], ALL),
  247. ([".github/workflows/e2e-reusable.yml"], ALL),
  248. # Every leg runs the same image and cluster, so these are shared too. They
  249. # were missed once; a change here skipping the vault leg is the exact
  250. # coverage loss affected-only selection must never cause.
  251. # Every provider leg compiles this bootstrap, and it sits above their
  252. # cases/<name>/** globs, so nothing else would select it.
  253. (["e2e/suites/provider/suite_test.go"], ALL),
  254. (["e2e/Dockerfile"], ALL),
  255. (["e2e/entrypoint.sh"], ALL),
  256. (["e2e/k8s/vault.values.yaml"], ALL),
  257. (["e2e/kind.yaml"], ALL),
  258. # Found by auditing each leg's imports against its globs: these four
  259. # dependencies are real but not obvious from the leg's name.
  260. (["e2e/suites/generator/testcase.go"],
  261. {"core-smoke", "generator", "grafana"}),
  262. (["e2e/suites/provider/cases/fake/fake.go"],
  263. {"core-smoke", "flux", "argocd"}),
  264. (["providers/v1/kubernetes/client.go"], {"core-smoke"}),
  265. (["providers/v1/fake/fake.go"], {"core-smoke"}),
  266. # Unrelated changes still run the floor, never an empty matrix. e2e docs
  267. # are deliberately not shared machinery.
  268. (["docs/introduction/faq.md"], {"core-smoke"}),
  269. (["e2e/README.md"], {"core-smoke"}),
  270. # A near miss must not match: awsx is not aws.
  271. (["providers/v1/awsx/client.go"], {"core-smoke"}),
  272. # Fail open: nothing to go on means run everything.
  273. ([], ALL),
  274. ]
  275. def cmd_selftest(matrix: dict) -> int:
  276. """Exercise select_areas against SELFTEST_CASES. Runs in prepare-matrix
  277. beside check, so a regression in the resolver fails the build rather than
  278. quietly shrinking the fan-out."""
  279. every = {a["name"] for a in matrix["areas"] if a.get("enabled")}
  280. failures = 0
  281. def fail(what: str, detail: str) -> None:
  282. nonlocal failures
  283. failures += 1
  284. print(f"FAIL {what}\n {detail}", file=sys.stderr)
  285. for changed, expected in SELFTEST_CASES:
  286. want = every if expected is None else expected
  287. # select_areas narrates its decision on stderr; capture it so a real
  288. # failure is not buried, and so the narration can be asserted on.
  289. log = io.StringIO()
  290. with redirect_stderr(log):
  291. got = {a["name"] for a in select_areas(matrix, changed or None)}
  292. if got != want:
  293. fail(f"changed={changed}",
  294. f"want {sorted(want)}\n got {sorted(got)}")
  295. # A wrongly skipped leg is diagnosed from this log, so it has to name
  296. # the legs it dropped, or the reason a change ran everything.
  297. if changed and got != every and "skipping" not in log.getvalue():
  298. fail(f"changed={changed}", "narrowed the matrix, logged no why")
  299. if changed and got == every and "full matrix" not in log.getvalue():
  300. fail(f"changed={changed}", "ran every leg without logging why")
  301. # These two own the remaining fail-open rules and select_areas never
  302. # reaches them, so exercise them directly rather than trusting them.
  303. with redirect_stderr(io.StringIO()):
  304. cases = [
  305. ("unreadable file", read_changed(str(HERE / "no-such-file")), None),
  306. ("blank text", parse_changed(" \n\t\n", "<test>"), None),
  307. ("two paths", parse_changed("a/b.go\n c/d.go \n", "<test>"),
  308. ["a/b.go", "c/d.go"]),
  309. ]
  310. for what, got_paths, want_paths in cases:
  311. if got_paths != want_paths:
  312. fail(f"read_changed/parse_changed on {what}",
  313. f"want {want_paths}, got {got_paths}")
  314. total = len(SELFTEST_CASES) + len(cases)
  315. if failures:
  316. print(f"ERROR: {failures} of {total} selftest assertion(s) failed",
  317. file=sys.stderr)
  318. return 1
  319. print(f"matrix.py selftest ok: {total} cases")
  320. return 0
  321. def main() -> int:
  322. argv = sys.argv[1:]
  323. cmd = argv[0] if argv else "check"
  324. others = {"check": cmd_check, "plan": cmd_plan, "selftest": cmd_selftest}
  325. changed_from = None
  326. if cmd == "json":
  327. if len(argv) > 1:
  328. if argv[1] != "--changed" or len(argv) != 3:
  329. print(USAGE, file=sys.stderr)
  330. return 2
  331. changed_from = argv[2]
  332. elif len(argv) > 1 or cmd not in others:
  333. print(USAGE, file=sys.stderr)
  334. return 2
  335. matrix = load_yaml(MATRIX)
  336. if cmd == "json":
  337. return cmd_json(matrix, changed_from)
  338. return others[cmd](matrix)
  339. if __name__ == "__main__":
  340. sys.exit(main())