Matrix Docker Ansible eploy
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

220 строки
9.2 KiB

  1. # SPDX-FileCopyrightText: 2026 Slavi Pantaleev
  2. #
  3. # SPDX-License-Identifier: AGPL-3.0-or-later
  4. """Select Molecule scenarios affected by a Git comparison, using only the stdlib.
  5. Shared dependencies use literal molecule-shared/... paths and image variable names.
  6. Follow these references transitively, in both revisions. This is deliberately not an
  7. Ansible interpreter: unfamiliar image-pin syntax or unresolved dependencies run all
  8. scenarios instead of risking an incomplete automerge gate.
  9. """
  10. import argparse
  11. import json
  12. import os
  13. import posixpath
  14. import re
  15. import subprocess
  16. import sys
  17. from pathlib import Path
  18. IMAGE_VARS = "molecule-shared/vars.yml"
  19. GLOBAL_FILES = {
  20. "molecule-shared/requirements.txt",
  21. "molecule-shared/requirements.yml",
  22. "molecule-shared/playbook-context.yml",
  23. ".github/workflows/molecule.yml",
  24. "bin/molecule-select-roles.py",
  25. "bin/test-molecule-select-roles.py",
  26. }
  27. SCENARIO = re.compile(r"roles/custom/([^/]+)/molecule/default/molecule\.yml$")
  28. SHARED_PATH = re.compile(r"molecule-shared/[\w./-]+")
  29. IMAGE_NAME = re.compile(r"\bmolecule_shared_image_\w+\b")
  30. IMAGE_PIN = re.compile(r"(molecule_shared_image_\w+):\s*(['\"])([\w./:@+-]+)\2\s*(?:#.*)?$")
  31. class Uncertain(Exception):
  32. """The change cannot safely be narrowed to particular scenarios."""
  33. class Git:
  34. def __init__(self, root):
  35. self.root = root
  36. def run(self, *args, input=None):
  37. return subprocess.run(
  38. ["git", *args], cwd=self.root, input=input, stdout=subprocess.PIPE,
  39. stderr=subprocess.PIPE, check=True,
  40. ).stdout
  41. def commit(self, ref):
  42. return self.run("rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").decode().strip()
  43. def snapshot(self, ref):
  44. entries = {}
  45. for entry in self.run("ls-tree", "-rz", ref, "--", "roles/custom", "molecule-shared").split(b"\0"):
  46. if not entry:
  47. continue
  48. metadata, path = entry.decode().split("\t", 1)
  49. mode, kind, oid = metadata.split()
  50. if path.startswith("molecule-shared/") or "/molecule/" in path:
  51. if kind != "blob":
  52. raise Uncertain(f"Unsupported Git entry: {path}")
  53. entries[path] = (mode, oid)
  54. # Read the blobs in one process; a subprocess per scenario file is slow.
  55. oids = list(dict.fromkeys(oid for mode, oid in entries.values()))
  56. data = self.run("cat-file", "--batch", input="".join(f"{oid}\n" for oid in oids).encode())
  57. blobs = {}
  58. offset = 0
  59. for oid in oids:
  60. end = data.index(b"\n", offset)
  61. size = int(data[offset:end].split()[2])
  62. blobs[oid] = data[end + 1:end + 1 + size].decode()
  63. offset = end + size + 2
  64. return {path: (mode, blobs[oid]) for path, (mode, oid) in entries.items()}
  65. def comparison_base(git, head, env):
  66. """Preserve push/PR comparison semantics, including new Renovate branches."""
  67. try:
  68. if env.get("EVENT_NAME") == "pull_request":
  69. return git.commit(env["BASE_SHA"])
  70. if env.get("EVENT_NAME") == "push":
  71. before = env.get("BEFORE_SHA", "")
  72. if before and set(before) != {"0"}:
  73. try:
  74. return git.commit(before)
  75. except subprocess.CalledProcessError:
  76. pass
  77. default = env.get("DEFAULT_BRANCH", "")
  78. if default and env.get("GITHUB_REF") != f"refs/heads/{default}":
  79. return git.run("merge-base", head, f"refs/remotes/origin/{default}").decode().strip()
  80. except (KeyError, subprocess.CalledProcessError):
  81. pass
  82. return None
  83. def image_pins(snapshot):
  84. """Accept only flat, quoted literal image pins; other YAML runs all scenarios."""
  85. if IMAGE_VARS not in snapshot or snapshot[IMAGE_VARS][0] == "120000":
  86. raise Uncertain("Missing or symlinked shared image pins")
  87. pins = {}
  88. document_started = False
  89. for line in snapshot[IMAGE_VARS][1].splitlines():
  90. if not line.strip() or line.startswith("#"):
  91. continue
  92. if line == "---" and not pins and not document_started:
  93. document_started = True
  94. continue
  95. match = IMAGE_PIN.fullmatch(line)
  96. if not match or match[1] in pins:
  97. raise Uncertain("Unrecognized shared image-pin format")
  98. pins[match[1]] = match[3]
  99. if not pins:
  100. raise Uncertain("No shared image pins")
  101. return pins
  102. def dependencies(snapshot, role):
  103. """Return file and variable dependencies, following shared files and symlinks.
  104. Scan all scenario files, including nested tasks and fixtures. Ignore full-line
  105. comments. Do not scan vars.yml's definitions: loading the mapping does not
  106. mean a scenario uses every image in it.
  107. """
  108. pending = [path for path in snapshot if path.startswith(f"roles/custom/{role}/molecule/")]
  109. files, variables = set(), set()
  110. while pending:
  111. path = pending.pop()
  112. if path in files:
  113. continue
  114. files.add(path)
  115. if path not in snapshot:
  116. raise Uncertain(f"Unresolved dependency: {path}")
  117. mode, content = snapshot[path]
  118. if mode == "120000":
  119. pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), content)))
  120. continue
  121. if path == IMAGE_VARS:
  122. continue
  123. content = "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#"))
  124. references = SHARED_PATH.findall(content)
  125. if content.count("molecule-shared/") != len(references):
  126. raise Uncertain(f"Nonliteral shared dependency in {path}")
  127. pending.extend(references)
  128. names = IMAGE_NAME.findall(content)
  129. if content.count("molecule_shared_image_") != len(names):
  130. raise Uncertain(f"Nonliteral shared image variable in {path}")
  131. variables.update(names)
  132. return files, variables
  133. def select_roles(git, base, head="HEAD", role=""):
  134. # Failure to enumerate the head must fail the job, never report an empty gate.
  135. paths = git.run("ls-tree", "-rz", "--name-only", head, "--", "roles/custom").decode().split("\0")
  136. available = {match[1] for path in paths if (match := SCENARIO.fullmatch(path))}
  137. if role:
  138. if role not in available:
  139. raise ValueError(f"No scenario at roles/custom/{role}/molecule/default")
  140. return [role]
  141. if not base:
  142. print("No comparison requested or available; testing every scenario", file=sys.stderr)
  143. return sorted(available)
  144. try:
  145. # Disable rename detection so both old and new paths contribute consumers.
  146. diff = git.run("diff", "--no-renames", "--name-only", "-z", base, head, "--")
  147. changed = set(diff.decode().split("\0")) - {""}
  148. if changed & GLOBAL_FILES:
  149. raise Uncertain("Test infrastructure changed: " + ", ".join(sorted(changed & GLOBAL_FILES)))
  150. selected = {path.split("/")[2] for path in changed if path.startswith("roles/custom/")}
  151. shared = {path for path in changed if path.startswith("molecule-shared/")}
  152. if shared:
  153. snapshots = [git.snapshot(base), git.snapshot(head)]
  154. changed_images = set()
  155. if IMAGE_VARS in shared:
  156. old, new = map(image_pins, snapshots)
  157. if old.keys() != new.keys():
  158. raise Uncertain("Shared image variables added or removed")
  159. changed_images = {key for key in old if old[key] != new[key]}
  160. shared.remove(IMAGE_VARS)
  161. if shared or changed_images:
  162. consumers = {dependency: set() for dependency in shared | changed_images}
  163. for snapshot in snapshots:
  164. for candidate in available:
  165. files, variables = dependencies(snapshot, candidate)
  166. for dependency in consumers.keys() & (files | variables):
  167. consumers[dependency].add(candidate)
  168. for dependency, roles in sorted(consumers.items()):
  169. if not roles:
  170. raise Uncertain(f"No known consumers for {dependency}")
  171. print(f"{dependency}: {len(roles)} scenario(s)", file=sys.stderr)
  172. selected.update(roles)
  173. return sorted(selected & available)
  174. except (Uncertain, subprocess.CalledProcessError, UnicodeError) as exc:
  175. print(f"Testing every scenario: {exc}", file=sys.stderr)
  176. return sorted(available)
  177. def main():
  178. parser = argparse.ArgumentParser(description=__doc__)
  179. parser.add_argument("--base", help="Compare against this revision; otherwise use GitHub event variables")
  180. parser.add_argument("--head", default="HEAD", help="Revision to test (default: HEAD)")
  181. parser.add_argument("--role", default=os.environ.get("INPUT_ROLE", ""), help="Run one named role")
  182. args = parser.parse_args()
  183. git = Git(Path.cwd())
  184. base = args.base if args.base is not None else comparison_base(git, args.head, os.environ)
  185. roles = select_roles(git, base, args.head, args.role)
  186. result = json.dumps(roles, separators=(",", ":"))
  187. print(result)
  188. if output := os.environ.get("GITHUB_OUTPUT"):
  189. with open(output, "a") as stream:
  190. stream.write(f"roles={result}\n")
  191. if __name__ == "__main__":
  192. main()