Select Molecule scenarios by shared dependency usage

This commit is contained in:
Slavi Pantaleev
2026-09-19 08:32:06 +03:00
parent 8ffa38b4b7
commit 9f131990ba
7 changed files with 494 additions and 79 deletions
+219
View File
@@ -0,0 +1,219 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Select Molecule scenarios affected by a Git comparison, using only the stdlib.
Shared dependencies use literal molecule-shared/... paths and image variable names.
Follow these references transitively, in both revisions. This is deliberately not an
Ansible interpreter: unfamiliar image-pin syntax or unresolved dependencies run all
scenarios instead of risking an incomplete automerge gate.
"""
import argparse
import json
import os
import posixpath
import re
import subprocess
import sys
from pathlib import Path
IMAGE_VARS = "molecule-shared/vars.yml"
GLOBAL_FILES = {
"molecule-shared/requirements.txt",
"molecule-shared/requirements.yml",
"molecule-shared/playbook-context.yml",
".github/workflows/molecule.yml",
"bin/molecule-select-roles.py",
"bin/test-molecule-select-roles.py",
}
SCENARIO = re.compile(r"roles/custom/([^/]+)/molecule/default/molecule\.yml$")
SHARED_PATH = re.compile(r"molecule-shared/[\w./-]+")
IMAGE_NAME = re.compile(r"\bmolecule_shared_image_\w+\b")
IMAGE_PIN = re.compile(r"(molecule_shared_image_\w+):\s*(['\"])([\w./:@+-]+)\2\s*(?:#.*)?$")
class Uncertain(Exception):
"""The change cannot safely be narrowed to particular scenarios."""
class Git:
def __init__(self, root):
self.root = root
def run(self, *args, input=None):
return subprocess.run(
["git", *args], cwd=self.root, input=input, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True,
).stdout
def commit(self, ref):
return self.run("rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}").decode().strip()
def snapshot(self, ref):
entries = {}
for entry in self.run("ls-tree", "-rz", ref, "--", "roles/custom", "molecule-shared").split(b"\0"):
if not entry:
continue
metadata, path = entry.decode().split("\t", 1)
mode, kind, oid = metadata.split()
if path.startswith("molecule-shared/") or "/molecule/" in path:
if kind != "blob":
raise Uncertain(f"Unsupported Git entry: {path}")
entries[path] = (mode, oid)
# Read the blobs in one process; a subprocess per scenario file is slow.
oids = list(dict.fromkeys(oid for mode, oid in entries.values()))
data = self.run("cat-file", "--batch", input="".join(f"{oid}\n" for oid in oids).encode())
blobs = {}
offset = 0
for oid in oids:
end = data.index(b"\n", offset)
size = int(data[offset:end].split()[2])
blobs[oid] = data[end + 1:end + 1 + size].decode()
offset = end + size + 2
return {path: (mode, blobs[oid]) for path, (mode, oid) in entries.items()}
def comparison_base(git, head, env):
"""Preserve push/PR comparison semantics, including new Renovate branches."""
try:
if env.get("EVENT_NAME") == "pull_request":
return git.commit(env["BASE_SHA"])
if env.get("EVENT_NAME") == "push":
before = env.get("BEFORE_SHA", "")
if before and set(before) != {"0"}:
try:
return git.commit(before)
except subprocess.CalledProcessError:
pass
default = env.get("DEFAULT_BRANCH", "")
if default and env.get("GITHUB_REF") != f"refs/heads/{default}":
return git.run("merge-base", head, f"refs/remotes/origin/{default}").decode().strip()
except (KeyError, subprocess.CalledProcessError):
pass
return None
def image_pins(snapshot):
"""Accept only flat, quoted literal image pins; other YAML runs all scenarios."""
if IMAGE_VARS not in snapshot or snapshot[IMAGE_VARS][0] == "120000":
raise Uncertain("Missing or symlinked shared image pins")
pins = {}
document_started = False
for line in snapshot[IMAGE_VARS][1].splitlines():
if not line.strip() or line.startswith("#"):
continue
if line == "---" and not pins and not document_started:
document_started = True
continue
match = IMAGE_PIN.fullmatch(line)
if not match or match[1] in pins:
raise Uncertain("Unrecognized shared image-pin format")
pins[match[1]] = match[3]
if not pins:
raise Uncertain("No shared image pins")
return pins
def dependencies(snapshot, role):
"""Return file and variable dependencies, following shared files and symlinks.
Scan all scenario files, including nested tasks and fixtures. Ignore full-line
comments. Do not scan vars.yml's definitions: loading the mapping does not
mean a scenario uses every image in it.
"""
pending = [path for path in snapshot if path.startswith(f"roles/custom/{role}/molecule/")]
files, variables = set(), set()
while pending:
path = pending.pop()
if path in files:
continue
files.add(path)
if path not in snapshot:
raise Uncertain(f"Unresolved dependency: {path}")
mode, content = snapshot[path]
if mode == "120000":
pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), content)))
continue
if path == IMAGE_VARS:
continue
content = "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#"))
references = SHARED_PATH.findall(content)
if content.count("molecule-shared/") != len(references):
raise Uncertain(f"Nonliteral shared dependency in {path}")
pending.extend(references)
names = IMAGE_NAME.findall(content)
if content.count("molecule_shared_image_") != len(names):
raise Uncertain(f"Nonliteral shared image variable in {path}")
variables.update(names)
return files, variables
def select_roles(git, base, head="HEAD", role=""):
# Failure to enumerate the head must fail the job, never report an empty gate.
paths = git.run("ls-tree", "-rz", "--name-only", head, "--", "roles/custom").decode().split("\0")
available = {match[1] for path in paths if (match := SCENARIO.fullmatch(path))}
if role:
if role not in available:
raise ValueError(f"No scenario at roles/custom/{role}/molecule/default")
return [role]
if not base:
print("No comparison requested or available; testing every scenario", file=sys.stderr)
return sorted(available)
try:
# Disable rename detection so both old and new paths contribute consumers.
diff = git.run("diff", "--no-renames", "--name-only", "-z", base, head, "--")
changed = set(diff.decode().split("\0")) - {""}
if changed & GLOBAL_FILES:
raise Uncertain("Test infrastructure changed: " + ", ".join(sorted(changed & GLOBAL_FILES)))
selected = {path.split("/")[2] for path in changed if path.startswith("roles/custom/")}
shared = {path for path in changed if path.startswith("molecule-shared/")}
if shared:
snapshots = [git.snapshot(base), git.snapshot(head)]
changed_images = set()
if IMAGE_VARS in shared:
old, new = map(image_pins, snapshots)
if old.keys() != new.keys():
raise Uncertain("Shared image variables added or removed")
changed_images = {key for key in old if old[key] != new[key]}
shared.remove(IMAGE_VARS)
if shared or changed_images:
consumers = {dependency: set() for dependency in shared | changed_images}
for snapshot in snapshots:
for candidate in available:
files, variables = dependencies(snapshot, candidate)
for dependency in consumers.keys() & (files | variables):
consumers[dependency].add(candidate)
for dependency, roles in sorted(consumers.items()):
if not roles:
raise Uncertain(f"No known consumers for {dependency}")
print(f"{dependency}: {len(roles)} scenario(s)", file=sys.stderr)
selected.update(roles)
return sorted(selected & available)
except (Uncertain, subprocess.CalledProcessError, UnicodeError) as exc:
print(f"Testing every scenario: {exc}", file=sys.stderr)
return sorted(available)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", help="Compare against this revision; otherwise use GitHub event variables")
parser.add_argument("--head", default="HEAD", help="Revision to test (default: HEAD)")
parser.add_argument("--role", default=os.environ.get("INPUT_ROLE", ""), help="Run one named role")
args = parser.parse_args()
git = Git(Path.cwd())
base = args.base if args.base is not None else comparison_base(git, args.head, os.environ)
roles = select_roles(git, base, args.head, args.role)
result = json.dumps(roles, separators=(",", ":"))
print(result)
if output := os.environ.get("GITHUB_OUTPUT"):
with open(output, "a") as stream:
stream.write(f"roles={result}\n")
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Regression tests for the Molecule CI matrix; no Docker or Python packages needed."""
import contextlib
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
# Importing the script should not leave __pycache__ in the repository.
sys.dont_write_bytecode = True
SCRIPT = Path(__file__).with_name("molecule-select-roles.py")
SPEC = importlib.util.spec_from_file_location("selector", SCRIPT)
selector = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(selector)
class SelectionTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.git = selector.Git(self.root)
self.git.run("init", "--quiet", "--initial-branch=main")
self.git.run("config", "user.name", "Molecule selector test")
self.git.run("config", "user.email", "test@example.com")
self.git.run("config", "commit.gpgsign", "false")
self.roles = ["matrix-database", "matrix-livekit", "matrix-web"]
self.write(selector.IMAGE_VARS, '\n'.join([
'---',
'molecule_shared_image_curl: "curl:1"',
'molecule_shared_image_postgres: "postgres:1"',
'molecule_shared_image_livekit: "livekit:1"',
]) + '\n')
self.write("molecule-shared/requirements.yml", "roles: []\n")
self.write("molecule-shared/tasks/postgres.yml", '{{ molecule_shared_image_postgres }}\n')
self.write("molecule-shared/tasks/probe.yml", "molecule-shared/probe.py\n")
self.write("molecule-shared/probe.py", "print('probe')\n")
for role in self.roles:
self.write(f"roles/custom/{role}/molecule/default/molecule.yml", "---\n")
self.scenario(role, "verify.yml", '{{ molecule_shared_image_curl }}\n')
self.scenario(role, "prepare.yml", "molecule-shared/vars.yml\n")
self.path(f"roles/custom/{role}/molecule/default/requirements.yml").symlink_to(
"../../../../../molecule-shared/requirements.yml"
)
self.scenario("matrix-database", "prepare.yml", "molecule-shared/vars.yml\nmolecule-shared/tasks/postgres.yml\n")
self.scenario("matrix-livekit", "tasks/sfu.yml", '{{ molecule_shared_image_livekit }}\n')
self.scenario("matrix-web", "tasks/probe.yml", "molecule-shared/tasks/probe.yml\n")
self.base = self.commit()
self.git.run("update-ref", "refs/remotes/origin/main", self.base)
def path(self, name):
return self.root / name
def write(self, name, content):
path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
def scenario(self, role, name, content):
self.write(f"roles/custom/{role}/molecule/default/{name}", content)
def commit(self):
self.git.run("add", "--all")
self.git.run("commit", "--quiet", "--allow-empty", "-m", "Test fixture")
return self.git.commit("HEAD")
def bump(self, image):
path = self.path(selector.IMAGE_VARS)
path.write_text(path.read_text().replace(f'"{image}:1"', f'"{image}:2"'))
def selected(self, base=None):
self.commit()
with contextlib.redirect_stderr(io.StringIO()):
return selector.select_roles(self.git, base or self.base)
def test_livekit_bump_only_runs_its_consumer(self):
self.bump("livekit")
self.assertEqual(self.selected(), ["matrix-livekit"])
def test_postgres_bump_follows_shared_task(self):
self.bump("postgres")
self.assertEqual(self.selected(), ["matrix-database"])
def test_curl_bump_runs_every_consumer(self):
self.bump("curl")
self.assertEqual(self.selected(), self.roles)
def test_multiple_image_bumps_union_with_direct_role_changes(self):
self.bump("livekit")
self.bump("postgres")
self.write("roles/custom/matrix-web/defaults/main.yml", "version: 2\n")
self.assertEqual(self.selected(), self.roles)
def test_direct_changes_ignore_roles_without_scenarios(self):
self.write("roles/custom/matrix-web/defaults/main.yml", "version: 2\n")
self.write("roles/custom/matrix-untested/defaults/main.yml", "version: 2\n")
self.assertEqual(self.selected(), ["matrix-web"])
def test_documentation_change_runs_nothing(self):
self.write("docs/example.md", "Documentation\n")
self.assertEqual(self.selected(), [])
def test_image_pin_comments_and_quoting_do_not_run_scenarios(self):
path = self.path(selector.IMAGE_VARS)
path.write_text("# New comment\n" + path.read_text().replace('"', "'"))
self.assertEqual(self.selected(), [])
def test_shared_task_change_only_runs_consumers(self):
self.write("molecule-shared/tasks/postgres.yml", '{{ molecule_shared_image_postgres }}\n# Changed\n')
self.assertEqual(self.selected(), ["matrix-database"])
def test_shared_fixture_change_follows_nested_references(self):
self.write("molecule-shared/probe.py", "print('updated probe')\n")
self.assertEqual(self.selected(), ["matrix-web"])
def test_deleted_helper_uses_previous_consumers(self):
self.path("molecule-shared/probe.py").unlink()
self.write("molecule-shared/tasks/probe.yml", "# Probe removed\n")
self.assertEqual(self.selected(), ["matrix-web"])
def test_renamed_helper_unions_previous_and_current_consumers(self):
self.path("molecule-shared/probe.py").rename(self.path("molecule-shared/new-probe.py"))
self.write("molecule-shared/tasks/probe.yml", "# Probe moved\n")
self.scenario("matrix-livekit", "tasks/new-probe.yml", "molecule-shared/new-probe.py\n")
self.assertEqual(self.selected(), ["matrix-livekit", "matrix-web"])
def test_deleted_role_is_not_selected(self):
self.path("roles/custom/matrix-livekit/molecule/default/molecule.yml").unlink()
self.assertEqual(self.selected(), [])
def test_new_scenario_is_selected(self):
self.write("roles/custom/matrix-new/molecule/default/molecule.yml", "---\n")
self.assertEqual(self.selected(), ["matrix-new"])
def test_global_changes_run_all(self):
for name in sorted(selector.GLOBAL_FILES):
with self.subTest(name=name):
self.git.run("reset", "--hard", self.base)
self.write(name, "# Infrastructure changed\n")
self.assertEqual(self.selected(), self.roles)
def test_unknown_shared_file_runs_all(self):
self.write("molecule-shared/new-helper.yml", "---\n")
self.assertEqual(self.selected(), self.roles)
def test_unconsumed_image_bump_runs_all(self):
self.scenario("matrix-livekit", "tasks/sfu.yml", "# No image reference\n")
self.base = self.commit()
self.bump("livekit")
self.assertEqual(self.selected(), self.roles)
def test_unsupported_pins_run_all(self):
original = self.path(selector.IMAGE_VARS).read_text()
for content in [
'---\n' + original,
original + 'other_setting: true\n',
original + 'molecule_shared_image_new: "new:1"\n',
original.replace('molecule_shared_image_livekit: "livekit:1"\n', ''),
original + 'molecule_shared_image_curl: "curl:2"\n',
original.replace('"livekit:1"', '"{{ another_variable }}"'),
]:
with self.subTest(content=content):
self.write(selector.IMAGE_VARS, content)
self.assertEqual(self.selected(), self.roles)
def test_missing_pins_run_all(self):
self.path(selector.IMAGE_VARS).unlink()
self.assertEqual(self.selected(), self.roles)
def test_unresolved_references_run_all(self):
for reference in [
'molecule-shared/tasks/{{ helper }}.yml',
'molecule-shared/{{ helper }}.yml',
'molecule-shared/missing.yml',
"{{ lookup('vars', 'molecule_shared_image_' + name) }}",
]:
with self.subTest(reference=reference):
self.scenario("matrix-web", "tasks/dynamic.yml", reference)
self.bump("livekit")
self.assertEqual(self.selected(), self.roles)
def test_shared_reference_cycles_terminate(self):
self.write("molecule-shared/tasks/postgres.yml", 'molecule-shared/tasks/postgres.yml\n{{ molecule_shared_image_postgres }}\n')
self.assertEqual(self.selected(), ["matrix-database"])
def test_comments_do_not_introduce_unresolved_references(self):
self.scenario("matrix-web", "tasks/comments.yml", "# See molecule-shared/probe.py.\n# Helpers live in molecule-shared/.\n")
self.base = self.commit()
self.bump("livekit")
self.assertEqual(self.selected(), ["matrix-livekit"])
def test_symlinked_shared_helper_is_followed(self):
self.path("molecule-shared/tasks/probe.yml").unlink()
self.path("molecule-shared/tasks/probe.yml").symlink_to("../probe.py")
self.base = self.commit()
self.write("molecule-shared/probe.py", "print('changed')\n")
self.assertEqual(self.selected(), ["matrix-web"])
def test_invalid_comparison_runs_all(self):
self.assertEqual(self.selected(base="missing-commit"), self.roles)
def test_manual_dispatch_all_or_one_role(self):
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(selector.select_roles(self.git, None), self.roles)
self.assertEqual(selector.select_roles(self.git, None, role="matrix-web"), ["matrix-web"])
with self.assertRaises(ValueError):
selector.select_roles(self.git, None, role="../outside")
def test_event_comparison_bases(self):
self.bump("livekit")
head = self.commit()
push = {"EVENT_NAME": "push", "DEFAULT_BRANCH": "main", "GITHUB_REF": "refs/heads/renovate/test"}
for before in [self.base, "0" * 40, "unavailable", ""]:
with self.subTest(before=before):
self.assertEqual(selector.comparison_base(self.git, head, dict(push, BEFORE_SHA=before)), self.base)
self.assertEqual(selector.comparison_base(self.git, head, {"EVENT_NAME": "pull_request", "BASE_SHA": self.base}), self.base)
self.assertIsNone(selector.comparison_base(self.git, head, {"EVENT_NAME": "workflow_dispatch"}))
self.assertIsNone(selector.comparison_base(self.git, head, dict(push, GITHUB_REF="refs/heads/main")))
self.assertIsNone(selector.comparison_base(self.git, head, dict(push, DEFAULT_BRANCH="missing")))
self.assertIsNone(selector.comparison_base(self.git, head, {"EVENT_NAME": "pull_request", "BASE_SHA": "missing"}))
def test_cli_writes_github_output(self):
self.bump("livekit")
self.commit()
output = self.path("github-output")
output.write_text("previous=value\n")
env = dict(os.environ, INPUT_ROLE="", GITHUB_OUTPUT=str(output))
result = subprocess.run(
[sys.executable, str(SCRIPT), "--base", self.base], cwd=self.root,
env=env, capture_output=True, text=True, check=True,
)
self.assertEqual(json.loads(result.stdout), ["matrix-livekit"])
self.assertEqual(output.read_text(), 'previous=value\nroles=["matrix-livekit"]\n')
if __name__ == "__main__":
unittest.main()