matrix.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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 neither covered by an
  6. area nor declared local_only, a suite directory is not compiled in at
  7. all, needs_secrets disagrees with secret_groups, or an area names a
  8. secret group that the reusable workflow does not wire up.
  9. json Print the GitHub Actions matrix (enabled areas only) as compact JSON
  10. for the workflow's strategy.matrix.
  11. plan Print, per enabled leg, exactly which credential env vars it will
  12. receive. Derived from each area's secret_groups and the group -> var
  13. mapping parsed out of e2e-reusable.yml. This reads NO secret values
  14. (it never touches the secrets context), so it proves the scoping
  15. without any risk of leaking a value, masked or not.
  16. Paths are resolved relative to this file, so the working directory does not
  17. matter. YAML is read with PyYAML when present, else via yq (mikefarah), so no
  18. new runtime dependency is required in CI.
  19. """
  20. import json
  21. import re
  22. import subprocess
  23. import sys
  24. from pathlib import Path
  25. HERE = Path(__file__).resolve().parent
  26. MATRIX = HERE / "matrix.yaml"
  27. IMPORT = HERE / "suites/provider/cases/import.go"
  28. WORKFLOW = HERE.parent / ".github/workflows/e2e-reusable.yml"
  29. def load_yaml(path: Path):
  30. """Load a YAML file as a dict. Prefer PyYAML; fall back to yq -> JSON."""
  31. try:
  32. import yaml # type: ignore
  33. return yaml.safe_load(path.read_text())
  34. except ModuleNotFoundError:
  35. out = subprocess.run(
  36. ["yq", "-o=json", str(path)],
  37. check=True, capture_output=True, text=True,
  38. ).stdout
  39. return json.loads(out)
  40. def imported_providers() -> list[str]:
  41. """Provider names blank imported into the suite binary.
  42. Taken as the first path segment of each import, not a character class, or a
  43. directory like cases/gitlab-ce would resolve to "gitlab" and silently
  44. inherit that provider's policy."""
  45. return sorted({p.split("/")[0] for p in imported_paths()})
  46. def imported_paths() -> set[str]:
  47. """Suite paths compiled into the suite binary, relative to cases/
  48. (cases/aws/secretsmanager -> aws/secretsmanager). Unlike
  49. imported_providers this keeps the sub-package, so it can be compared
  50. against the directories on disk."""
  51. text = IMPORT.read_text()
  52. return {m.group(1) for m in re.finditer(r"cases/([\w/-]+)\"", text)}
  53. # Package-level Ginkgo container nodes. Context/When are literal aliases of
  54. # Describe in the ginkgo DSL, and a bare It/Specify registers a spec too, so all
  55. # of them have to count or an unimported suite using one stays invisible here.
  56. SUITE_NODE = re.compile(
  57. r"^var _ = (?:F|P|X)?"
  58. r"(?:Describe|DescribeTable|DescribeTableSubtree|Context|When|It|Specify)\(",
  59. re.M,
  60. )
  61. def suite_dirs() -> set[str]:
  62. """Directories under cases/ that define a suite, relative to cases/.
  63. A directory is a suite when one of its own .go files registers a
  64. package-level Ginkgo node. That distinguishes real suites from the
  65. common/ helper package and from aws/, which only holds a shared
  66. common.go beside its three sub-suites."""
  67. root = IMPORT.parent
  68. found = set()
  69. for path in root.rglob("*.go"):
  70. if SUITE_NODE.search(path.read_text()):
  71. found.add(path.parent.relative_to(root).as_posix())
  72. return found
  73. def group_to_vars() -> dict[str, list[str]]:
  74. """Map each secret group to the env vars the reusable workflow gates on it,
  75. parsed from lines like:
  76. FOO: ${{ contains(matrix.secret_groups, 'aws') && secrets.BAR || '' }}
  77. Reads only the workflow text, never any secret value."""
  78. pat = re.compile(
  79. r"^\s*([A-Z0-9_]+):\s*\$\{\{\s*"
  80. r"contains\(matrix\.secret_groups,\s*'([a-z0-9]+)'\)",
  81. re.MULTILINE,
  82. )
  83. mapping: dict[str, list[str]] = {}
  84. for var, group in pat.findall(WORKFLOW.read_text()):
  85. mapping.setdefault(group, []).append(var)
  86. for group in mapping:
  87. mapping[group].sort()
  88. return mapping
  89. def cmd_check(matrix: dict) -> int:
  90. areas = matrix["areas"]
  91. errors: list[str] = []
  92. # 1. Every imported provider is either covered by an area or declared
  93. # local_only. local_only is for suites that need an account on an external
  94. # service; see the comment on the list in matrix.yaml.
  95. covered = {
  96. p for a in areas if a.get("enabled") for p in (a.get("providers") or [])
  97. }
  98. declared = {p for a in areas for p in (a.get("providers") or [])}
  99. local_only = set(matrix.get("local_only") or [])
  100. imported = imported_providers()
  101. missing = [p for p in imported if p not in covered and p not in local_only]
  102. if missing:
  103. errors.append(
  104. "providers imported into the e2e suite but neither covered by an "
  105. "enabled area nor listed in local_only, so they compile and run "
  106. "nowhere (give each a leg, or declare it local_only with a "
  107. "reason):\n - " + "\n - ".join(missing)
  108. )
  109. # 1a. local_only must stay honest: entries have to be imported, or the list
  110. # is stale, and must not also have a leg, or the intent is contradictory.
  111. stale = sorted(local_only - set(imported))
  112. if stale:
  113. errors.append(
  114. "local_only names providers that are not imported in import.go, so "
  115. "they cannot run even locally:\n - " + "\n - ".join(stale)
  116. )
  117. unknown = sorted(declared - set(imported))
  118. if unknown:
  119. errors.append(
  120. "areas name providers that are not imported in import.go, so the "
  121. "leg would select nothing:\n - " + "\n - ".join(unknown)
  122. )
  123. both = sorted(local_only & covered)
  124. if both:
  125. errors.append(
  126. "providers are both local_only and covered by an area, so it is "
  127. "unclear whether CI should run them:\n - " + "\n - ".join(both)
  128. )
  129. # 1b. Every suite on disk is compiled into the binary. Without this the
  130. # check only runs one way: a suite added under cases/ but never blank
  131. # imported is silently dead, which is how the akeyless, gitlab and oracle
  132. # suites went unrun for months while still passing this validation.
  133. unimported = sorted(suite_dirs() - imported_paths())
  134. if unimported:
  135. errors.append(
  136. "suite directories that are not blank imported in import.go, so "
  137. "they are never compiled into the suite binary and never run:\n - "
  138. + "\n - ".join(unimported)
  139. )
  140. # 2. needs_secrets must mirror "secret_groups is non-empty".
  141. for a in areas:
  142. has_groups = bool(a.get("secret_groups"))
  143. if bool(a.get("needs_secrets")) != has_groups:
  144. errors.append(
  145. f"area {a['name']!r}: needs_secrets={a.get('needs_secrets')} "
  146. f"disagrees with secret_groups={a.get('secret_groups')}"
  147. )
  148. # 3. Every secret group an area uses is actually wired in the workflow.
  149. wired = set(group_to_vars())
  150. for a in areas:
  151. for group in a.get("secret_groups") or []:
  152. if group not in wired:
  153. errors.append(
  154. f"area {a['name']!r}: secret group {group!r} is not wired "
  155. f"in {WORKFLOW.name} (no env var gates on it)"
  156. )
  157. if errors:
  158. print("ERROR: matrix.yaml is inconsistent:", file=sys.stderr)
  159. for e in errors:
  160. print(f"- {e}", file=sys.stderr)
  161. return 1
  162. enabled = sum(1 for a in areas if a.get("enabled"))
  163. print(
  164. f"matrix.yaml ok: {len(imported_providers())} providers imported, "
  165. f"{enabled} leg(s) enabled, {len(local_only)} local only "
  166. f"({', '.join(sorted(local_only)) or 'none'})"
  167. )
  168. return 0
  169. def cmd_json(matrix: dict) -> int:
  170. include = [
  171. {
  172. "name": a["name"],
  173. "suite": a["suite"],
  174. "labels": a["labels"],
  175. "secret_groups": a.get("secret_groups") or [],
  176. }
  177. for a in matrix["areas"]
  178. if a.get("enabled")
  179. ]
  180. print(json.dumps({"include": include}, separators=(",", ":")))
  181. return 0
  182. def cmd_plan(matrix: dict) -> int:
  183. """Show the credential env vars each enabled leg will receive. No secret
  184. values are read; the list comes from matrix.yaml + the workflow mapping."""
  185. mapping = group_to_vars()
  186. print("Per-leg credential scoping (from matrix.yaml + e2e-reusable.yml):")
  187. for a in matrix["areas"]:
  188. if not a.get("enabled"):
  189. continue
  190. groups = a.get("secret_groups") or []
  191. env_vars = sorted({v for g in groups for v in mapping.get(g, [])})
  192. shown = ", ".join(env_vars) if env_vars else "(none: in-cluster only)"
  193. print(f" {a['name']}: groups={groups or '[]'} -> {shown}")
  194. return 0
  195. def main() -> int:
  196. cmd = sys.argv[1] if len(sys.argv) > 1 else "check"
  197. if cmd not in ("check", "json", "plan"):
  198. print(f"usage: {sys.argv[0]} [check|json|plan]", file=sys.stderr)
  199. return 2
  200. matrix = load_yaml(MATRIX)
  201. return {"check": cmd_check, "json": cmd_json, "plan": cmd_plan}[cmd](matrix)
  202. if __name__ == "__main__":
  203. sys.exit(main())