Skip to content

Commit 956cb51

Browse files
test
Signed-off-by: Sean Conroy <sconroy@redhat.com>
1 parent c194bc7 commit 956cb51

2 files changed

Lines changed: 383 additions & 1 deletion

File tree

.github/workflows/pr-assigner.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- name: Assign reviewers using shared action
17-
uses: konflux-ci/release-service-automations/pr-assigner@5da461efb1b29e523d01989ee09758e85099bda8 # main
17+
uses: seanconroy2021/release-service-automations/pr-assigner@auto-pr
1818
with:
1919
event-type: ${{ github.event.action }}
2020
pr-number: ${{ github.event.pull_request.number }}
2121
removed-assignee: ${{ github.event.action == 'unassigned' && github.event.assignee.login || '' }}
2222
github-token: ${{ secrets.PR_ASSIGNER_PAT_TOKEN }}
2323
slack-webhook: ${{ secrets.PR_ASSIGNER_SLACK_WEBHOOK }}
24+
pto-calendar-url: ${{ secrets.PTO_CALENDAR_URL }}

scripts/validate-taskrunspecs.py

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Validate taskRunSpecs in a ReleasePlanAdmission against the pipeline it references.
4+
5+
When a pipeline task is renamed or removed, any RPA with taskRunSpecs referencing
6+
the old name will silently break at runtime. This script catches that drift by
7+
resolving the pipeline at a given revision and checking that every pipelineTaskName
8+
and step name in the RPA actually exists.
9+
10+
Steps:
11+
1. Parse the RPA YAML to extract revision, pathInRepo, and taskRunSpecs
12+
2. Resolve the pipeline definition at the target revision
13+
3. For each taskRunSpec entry, verify the pipelineTaskName exists in the pipeline
14+
4. For each stepSpec, resolve the task and verify the step name exists
15+
5. Report mismatches and exit non-zero on errors
16+
"""
17+
18+
import argparse
19+
import logging
20+
import os
21+
import shutil
22+
import subprocess
23+
import sys
24+
import tempfile
25+
from typing import Dict, List, Optional, Tuple
26+
27+
import yaml
28+
29+
LOGGER = logging.getLogger("validate-taskrunspecs")
30+
31+
DEFAULT_LOG_FMT = "%(asctime)s [%(levelname)-8s] %(message)s"
32+
DEFAULT_DATE_FMT = "%Y-%m-%d %H:%M:%S %z"
33+
34+
DEFAULT_REPO_URL = "https://github.com/konflux-ci/release-service-catalog.git"
35+
36+
37+
def parse_arguments() -> argparse.Namespace: # pragma: no cover
38+
"""Parse CLI arguments.
39+
40+
Returns:
41+
Parsed argument namespace.
42+
"""
43+
parser = argparse.ArgumentParser(
44+
description="Validate taskRunSpecs in an RPA against the pipeline it references.",
45+
)
46+
parser.add_argument(
47+
"--rpa",
48+
required=True,
49+
help="Path to a ReleasePlanAdmission YAML file",
50+
)
51+
parser.add_argument(
52+
"--revision",
53+
help="Override the revision extracted from the RPA",
54+
)
55+
parser.add_argument(
56+
"--local",
57+
action="store_true",
58+
help="Resolve files from the local working tree instead of cloning",
59+
)
60+
parser.add_argument(
61+
"--repo-url",
62+
default=DEFAULT_REPO_URL,
63+
help="Git repo URL (ignored when --local is set)",
64+
)
65+
parser.add_argument(
66+
"-d",
67+
"--debug",
68+
action="store_true",
69+
help="Enable debug logging",
70+
)
71+
return parser.parse_args()
72+
73+
74+
def load_yaml(path: str) -> dict:
75+
"""Load a YAML file and return the parsed document.
76+
77+
Args:
78+
path: Filesystem path to the YAML file.
79+
80+
Returns:
81+
Parsed YAML as a dictionary.
82+
83+
Raises:
84+
FileNotFoundError: If the file does not exist.
85+
yaml.YAMLError: If the file is not valid YAML.
86+
"""
87+
with open(path) as f:
88+
return yaml.safe_load(f)
89+
90+
91+
def extract_rpa_fields(rpa: dict) -> Tuple[str, str, List[dict]]:
92+
"""Extract the revision, pipeline path, and taskRunSpecs from an RPA.
93+
94+
Args:
95+
rpa: Parsed RPA document.
96+
97+
Returns:
98+
Tuple of (revision, pathInRepo, taskRunSpecs list).
99+
"""
100+
pipeline = rpa.get("spec", {}).get("pipeline", {})
101+
ref_params = pipeline.get("pipelineRef", {}).get("params", [])
102+
params = {p["name"]: p["value"] for p in ref_params}
103+
104+
revision = params.get("revision", "")
105+
path_in_repo = params.get("pathInRepo", "")
106+
task_run_specs = pipeline.get("taskRunSpecs", [])
107+
108+
return revision, path_in_repo, task_run_specs
109+
110+
111+
def clone_repo(url: str, revision: str) -> str:
112+
"""Shallow-clone a git repo at the given revision into a temp directory.
113+
114+
Tries a direct --branch clone first. Falls back to a default clone followed
115+
by fetch + checkout if the revision is a commit SHA rather than a branch/tag.
116+
117+
Args:
118+
url: Git remote URL.
119+
revision: Branch name, tag, or commit SHA.
120+
121+
Returns:
122+
Path to the cloned directory.
123+
124+
Raises:
125+
RuntimeError: If the clone fails entirely.
126+
"""
127+
tmpdir = tempfile.mkdtemp(prefix="validate-trs-")
128+
129+
result = subprocess.run(
130+
[
131+
"git",
132+
"clone",
133+
"--depth",
134+
"1",
135+
"--branch",
136+
revision,
137+
"--single-branch",
138+
"-q",
139+
url,
140+
tmpdir,
141+
],
142+
capture_output=True,
143+
text=True,
144+
)
145+
if result.returncode == 0:
146+
LOGGER.debug(f"Cloned {url} at {revision}")
147+
return tmpdir
148+
149+
LOGGER.debug(
150+
f"Branch clone failed, falling back to default clone: {result.stderr.strip()}"
151+
)
152+
shutil.rmtree(tmpdir, ignore_errors=True)
153+
tmpdir = tempfile.mkdtemp(prefix="validate-trs-")
154+
155+
subprocess.run(
156+
["git", "clone", "--depth", "1", "-q", url, tmpdir],
157+
check=True,
158+
capture_output=True,
159+
)
160+
subprocess.run(
161+
["git", "fetch", "--depth", "1", "origin", revision],
162+
cwd=tmpdir,
163+
check=False,
164+
capture_output=True,
165+
)
166+
result = subprocess.run(
167+
["git", "checkout", revision],
168+
cwd=tmpdir,
169+
check=False,
170+
capture_output=True,
171+
text=True,
172+
)
173+
if result.returncode != 0:
174+
raise RuntimeError(
175+
f"Could not checkout revision '{revision}': {result.stderr.strip()}"
176+
)
177+
178+
return tmpdir
179+
180+
181+
def resolve_file(
182+
path: str, revision: str, local: bool, clone_dir: Optional[str]
183+
) -> Optional[str]:
184+
"""Read a file either from the local tree or from a cloned repo.
185+
186+
Args:
187+
path: Relative path inside the repo.
188+
revision: Git revision (unused when local=True).
189+
local: If True, read from the current working directory.
190+
clone_dir: Path to the cloned repo (used when local=False).
191+
192+
Returns:
193+
File contents as a string, or None if the file does not exist.
194+
"""
195+
if local:
196+
if not os.path.isfile(path):
197+
LOGGER.debug(f"Local file not found: {path}")
198+
return None
199+
with open(path) as f:
200+
return f.read()
201+
202+
target = os.path.join(clone_dir, path)
203+
if not os.path.isfile(target):
204+
LOGGER.debug(f"File not found in clone: {path}")
205+
return None
206+
with open(target) as f:
207+
return f.read()
208+
209+
210+
def extract_pipeline_tasks(pipeline_yaml: str) -> Dict[str, str]:
211+
"""Parse a pipeline definition and return a map of task name to pathInRepo.
212+
213+
Args:
214+
pipeline_yaml: Raw YAML content of the pipeline file.
215+
216+
Returns:
217+
Dict mapping each pipeline task name to its pathInRepo value.
218+
Tasks without a pathInRepo get an empty string.
219+
"""
220+
doc = yaml.safe_load(pipeline_yaml)
221+
tasks: Dict[str, str] = {}
222+
task_blocks = doc.get("spec", {}).get("tasks", [])
223+
finally_blocks = doc.get("spec", {}).get("finally", [])
224+
225+
for block in task_blocks + finally_blocks:
226+
name = block.get("name", "")
227+
ref = block.get("taskRef", {})
228+
if not isinstance(ref, dict):
229+
tasks[name] = ""
230+
continue
231+
232+
params = {p["name"]: p["value"] for p in ref.get("params", [])}
233+
tasks[name] = params.get("pathInRepo", "")
234+
235+
return tasks
236+
237+
238+
def extract_task_steps(task_yaml: str) -> List[str]:
239+
"""Parse a task definition and return the list of step names.
240+
241+
Args:
242+
task_yaml: Raw YAML content of the task file.
243+
244+
Returns:
245+
List of step names in order.
246+
"""
247+
doc = yaml.safe_load(task_yaml)
248+
return [step["name"] for step in doc.get("spec", {}).get("steps", [])]
249+
250+
251+
def validate(
252+
rpa_path: str, revision_override: Optional[str], local: bool, repo_url: str
253+
) -> int:
254+
"""Run the full validation of taskRunSpecs against the pipeline.
255+
256+
Args:
257+
rpa_path: Path to the RPA YAML file.
258+
revision_override: If set, use this instead of the revision in the RPA.
259+
local: Resolve files from the local working tree.
260+
repo_url: Git repo URL for cloning.
261+
262+
Returns:
263+
0 on success, 1 if any errors were found.
264+
"""
265+
rpa = load_yaml(rpa_path)
266+
revision, pipeline_path, task_run_specs = extract_rpa_fields(rpa)
267+
268+
if revision_override:
269+
revision = revision_override
270+
271+
if not revision or not pipeline_path:
272+
LOGGER.error("Could not extract revision or pathInRepo from the RPA")
273+
return 1
274+
275+
has_template_vars = "${" in revision or "${" in pipeline_path
276+
if has_template_vars and not local and not revision_override:
277+
LOGGER.error(
278+
f"RPA contains unresolved template variables (revision='{revision}', "
279+
f"path='{pipeline_path}'). Use --local or --revision to override."
280+
)
281+
return 1
282+
283+
LOGGER.info(f"Revision: {revision}")
284+
LOGGER.info(f"Pipeline: {pipeline_path}")
285+
286+
clone_dir = None
287+
if not local:
288+
LOGGER.info(f"Cloning {repo_url} at {revision}")
289+
clone_dir = clone_repo(repo_url, revision)
290+
291+
pipeline_content = resolve_file(pipeline_path, revision, local, clone_dir)
292+
if pipeline_content is None:
293+
LOGGER.error(f"Pipeline not found: {pipeline_path}")
294+
return 1
295+
296+
pipeline_tasks = extract_pipeline_tasks(pipeline_content)
297+
LOGGER.info(f"Pipeline tasks: {', '.join(sorted(pipeline_tasks))}")
298+
299+
if not task_run_specs:
300+
LOGGER.info("No taskRunSpecs in this RPA, nothing to validate")
301+
return 0
302+
303+
LOGGER.info(f"Checking {len(task_run_specs)} taskRunSpec entries")
304+
305+
errors = 0
306+
warnings = 0
307+
available_names = ", ".join(sorted(pipeline_tasks))
308+
309+
for entry in task_run_specs:
310+
task_name = entry.get("pipelineTaskName", "")
311+
312+
if task_name not in pipeline_tasks:
313+
LOGGER.error(
314+
f"pipelineTaskName '{task_name}' does not exist in pipeline "
315+
f"(available: {available_names})"
316+
)
317+
errors += 1
318+
continue
319+
320+
step_specs = entry.get("stepSpecs", [])
321+
if not step_specs:
322+
LOGGER.info(f" {task_name}: ok")
323+
continue
324+
325+
task_path = pipeline_tasks[task_name]
326+
if not task_path:
327+
LOGGER.warning(f" {task_name}: task path unknown, cannot verify steps")
328+
warnings += 1
329+
continue
330+
331+
task_content = resolve_file(task_path, revision, local, clone_dir)
332+
if task_content is None:
333+
LOGGER.warning(f" {task_name}: task file not found at {task_path}")
334+
warnings += 1
335+
continue
336+
337+
task_steps = extract_task_steps(task_content)
338+
step_errors = 0
339+
340+
for spec in step_specs:
341+
step_name = spec.get("name", "")
342+
if step_name not in task_steps:
343+
LOGGER.error(
344+
f" {task_name}: step '{step_name}' does not exist "
345+
f"(available: {', '.join(task_steps)})"
346+
)
347+
step_errors += 1
348+
errors += 1
349+
350+
if step_errors == 0:
351+
LOGGER.info(f" {task_name}: ok ({len(step_specs)} steps verified)")
352+
353+
if errors:
354+
LOGGER.error(
355+
f"Validation failed: {errors} error(s), {warnings} warning(s). "
356+
"Update the RPA to match the pipeline at the target revision."
357+
)
358+
else:
359+
LOGGER.info(f"Validation passed: {warnings} warning(s)")
360+
361+
return 1 if errors else 0
362+
363+
364+
def main() -> None: # pragma: no cover
365+
args = parse_arguments()
366+
367+
log_level = logging.DEBUG if args.debug else logging.INFO
368+
stream_handler = logging.StreamHandler(sys.stdout)
369+
stream_handler.setLevel(log_level)
370+
logging.basicConfig(
371+
level=log_level,
372+
format=DEFAULT_LOG_FMT,
373+
datefmt=DEFAULT_DATE_FMT,
374+
handlers=[stream_handler],
375+
)
376+
377+
sys.exit(validate(args.rpa, args.revision, args.local, args.repo_url))
378+
379+
380+
if __name__ == "__main__": # pragma: no cover
381+
main()

0 commit comments

Comments
 (0)