matrix.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  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 (enabled areas only) as compact JSON
  9. for the workflow's strategy.matrix.
  10. plan Print, per enabled leg, exactly which credential env vars it will
  11. receive. Derived from each area's secret_groups and the group -> var
  12. mapping parsed out of e2e-reusable.yml. This reads NO secret values
  13. (it never touches the secrets context), so it proves the scoping
  14. without any risk of leaking a value, masked or not.
  15. Paths are resolved relative to this file, so the working directory does not
  16. matter. YAML is read with PyYAML when present, else via yq (mikefarah), so no
  17. new runtime dependency is required in CI.
  18. """
  19. import json
  20. import re
  21. import subprocess
  22. import sys
  23. from pathlib import Path
  24. HERE = Path(__file__).resolve().parent
  25. MATRIX = HERE / "matrix.yaml"
  26. IMPORT = HERE / "suites/provider/cases/import.go"
  27. WORKFLOW = HERE.parent / ".github/workflows/e2e-reusable.yml"
  28. def load_yaml(path: Path):
  29. """Load a YAML file as a dict. Prefer PyYAML; fall back to yq -> JSON."""
  30. try:
  31. import yaml # type: ignore
  32. return yaml.safe_load(path.read_text())
  33. except ModuleNotFoundError:
  34. out = subprocess.run(
  35. ["yq", "-o=json", str(path)],
  36. check=True, capture_output=True, text=True,
  37. ).stdout
  38. return json.loads(out)
  39. def imported_providers() -> list[str]:
  40. """Provider names compiled into the suite: the segment after cases/ in
  41. each blank import of import.go (cases/aws/secretsmanager -> aws)."""
  42. text = IMPORT.read_text()
  43. return sorted({m.group(1) for m in re.finditer(r"cases/([a-z0-9]+)", text)})
  44. def group_to_vars() -> dict[str, list[str]]:
  45. """Map each secret group to the env vars the reusable workflow gates on it,
  46. parsed from lines like:
  47. FOO: ${{ contains(matrix.secret_groups, 'aws') && secrets.BAR || '' }}
  48. Reads only the workflow text, never any secret value."""
  49. pat = re.compile(
  50. r"^\s*([A-Z0-9_]+):\s*\$\{\{\s*"
  51. r"contains\(matrix\.secret_groups,\s*'([a-z0-9]+)'\)",
  52. re.MULTILINE,
  53. )
  54. mapping: dict[str, list[str]] = {}
  55. for var, group in pat.findall(WORKFLOW.read_text()):
  56. mapping.setdefault(group, []).append(var)
  57. for group in mapping:
  58. mapping[group].sort()
  59. return mapping
  60. def cmd_check(matrix: dict) -> int:
  61. areas = matrix["areas"]
  62. errors: list[str] = []
  63. # 1. Every imported provider is covered by some area.
  64. covered = {p for a in areas for p in (a.get("providers") or [])}
  65. missing = [p for p in imported_providers() if p not in covered]
  66. if missing:
  67. errors.append(
  68. "providers imported into the e2e suite but not covered by any "
  69. "area (add each to an area's providers list and a leg):\n - "
  70. + "\n - ".join(missing)
  71. )
  72. # 2. needs_secrets must mirror "secret_groups is non-empty".
  73. for a in areas:
  74. has_groups = bool(a.get("secret_groups"))
  75. if bool(a.get("needs_secrets")) != has_groups:
  76. errors.append(
  77. f"area {a['name']!r}: needs_secrets={a.get('needs_secrets')} "
  78. f"disagrees with secret_groups={a.get('secret_groups')}"
  79. )
  80. # 3. Every secret group an area uses is actually wired in the workflow.
  81. wired = set(group_to_vars())
  82. for a in areas:
  83. for group in a.get("secret_groups") or []:
  84. if group not in wired:
  85. errors.append(
  86. f"area {a['name']!r}: secret group {group!r} is not wired "
  87. f"in {WORKFLOW.name} (no env var gates on it)"
  88. )
  89. if errors:
  90. print("ERROR: matrix.yaml is inconsistent:", file=sys.stderr)
  91. for e in errors:
  92. print(f"- {e}", file=sys.stderr)
  93. return 1
  94. enabled = sum(1 for a in areas if a.get("enabled"))
  95. print(
  96. f"matrix.yaml ok: {len(imported_providers())} providers covered, "
  97. f"{enabled} leg(s) enabled"
  98. )
  99. return 0
  100. def cmd_json(matrix: dict) -> int:
  101. include = [
  102. {
  103. "name": a["name"],
  104. "suite": a["suite"],
  105. "labels": a["labels"],
  106. "secret_groups": a.get("secret_groups") or [],
  107. }
  108. for a in matrix["areas"]
  109. if a.get("enabled")
  110. ]
  111. print(json.dumps({"include": include}, separators=(",", ":")))
  112. return 0
  113. def cmd_plan(matrix: dict) -> int:
  114. """Show the credential env vars each enabled leg will receive. No secret
  115. values are read; the list comes from matrix.yaml + the workflow mapping."""
  116. mapping = group_to_vars()
  117. print("Per-leg credential scoping (from matrix.yaml + e2e-reusable.yml):")
  118. for a in matrix["areas"]:
  119. if not a.get("enabled"):
  120. continue
  121. groups = a.get("secret_groups") or []
  122. env_vars = sorted({v for g in groups for v in mapping.get(g, [])})
  123. shown = ", ".join(env_vars) if env_vars else "(none: in-cluster only)"
  124. print(f" {a['name']}: groups={groups or '[]'} -> {shown}")
  125. return 0
  126. def main() -> int:
  127. cmd = sys.argv[1] if len(sys.argv) > 1 else "check"
  128. if cmd not in ("check", "json", "plan"):
  129. print(f"usage: {sys.argv[0]} [check|json|plan]", file=sys.stderr)
  130. return 2
  131. matrix = load_yaml(MATRIX)
  132. return {"check": cmd_check, "json": cmd_json, "plan": cmd_plan}[cmd](matrix)
  133. if __name__ == "__main__":
  134. sys.exit(main())