#!/usr/bin/env python3 """Read bounded Jenkins build evidence through Hermes' Kubernetes access.""" from __future__ import annotations import argparse import json import re import subprocess import sys import time import xml.etree.ElementTree as ET from dataclasses import asdict, dataclass JENKINS_HOME = "/var/jenkins_home/jobs" SAFE_JOB = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") SAFE_BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") HIDDEN_ANSI = re.compile(r"\x1b\[8m.*?\x1b\[0m", re.DOTALL) ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") @dataclass(frozen=True) class BuildEvidence: """One Jenkins build's terminal state and bounded supporting evidence.""" job: str branch: str | None number: int revision: str | None result: str | None building: bool timestamp_ms: int | None duration_ms: int | None log_tail: str def _validate_job(value: str) -> str: """Accept one literal Jenkins job directory segment.""" if not SAFE_JOB.fullmatch(value): raise ValueError("job must contain only letters, digits, dot, underscore, or dash") return value def _validate_branch(value: str) -> str: """Accept a branch name while rejecting traversal and absolute paths.""" if not SAFE_BRANCH.fullmatch(value) or value.startswith("/"): raise ValueError("branch contains unsupported characters") if any(part in {"", ".", ".."} for part in value.split("/")): raise ValueError("branch contains an unsafe path component") return value def _kubectl(*command: str) -> str: """Run one read-only command in the Jenkins controller container.""" result = subprocess.run( [ "kubectl", "-n", "jenkins", "exec", "deployment/jenkins", "-c", "jenkins", "--", *command, ], check=True, capture_output=True, text=True, timeout=30, ) return result.stdout def _read(path: str) -> str: """Read one known Jenkins metadata file without shell interpolation.""" return _kubectl("cat", path) def _directories(path: str) -> list[str]: """List immediate Jenkins directories using a fixed find invocation.""" output = _kubectl( "find", path, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\n" ) return [line for line in output.splitlines() if line] def _head_name(config_xml: str) -> str | None: """Extract the logical branch name from a multibranch job config.""" root = ET.fromstring(config_xml) for head in root.findall(".//head"): name = head.findtext("name") if name: return name return root.findtext("displayName") def resolve_job_path(job: str, branch: str | None) -> str: """Resolve a job and optional logical branch to its controller path.""" base = f"{JENKINS_HOME}/{_validate_job(job)}" if branch is None: return base wanted = _validate_branch(branch) branch_root = f"{base}/branches" for encoded in _directories(branch_root): config_path = f"{branch_root}/{encoded}/config.xml" try: if _head_name(_read(config_path)) == wanted: return f"{branch_root}/{encoded}" except (ET.ParseError, subprocess.SubprocessError): continue raise ValueError(f"Jenkins branch not found: {job}/{branch}") def parse_build_xml( xml_text: str, *, job: str, branch: str | None, number: int, log_tail: str = "", ) -> BuildEvidence: """Parse top-level build state without mistaking nested CPS state as final.""" root = ET.fromstring(xml_text) revision = None for tag in ("hash", "sha1"): candidate = root.findtext(f".//revision/{tag}") if candidate: revision = candidate break result = root.findtext("result") timestamp = root.findtext("timestamp") duration = root.findtext("duration") return BuildEvidence( job=job, branch=branch, number=number, revision=revision, result=result, building=result is None, timestamp_ms=int(timestamp) if timestamp else None, duration_ms=int(duration) if duration else None, log_tail=log_tail, ) def sanitize_log(value: str) -> str: """Remove Jenkins hidden hyperlinks and terminal control sequences.""" return ANSI_ESCAPE.sub("", HIDDEN_ANSI.sub("", value)) def _find_build_for_job( job: str, branch: str | None, commit: str | None, log_lines: int, ) -> BuildEvidence | None: """Return the newest build matching an optional exact/prefix revision.""" job_path = resolve_job_path(job, branch) numbers = sorted( (int(value) for value in _directories(f"{job_path}/builds") if value.isdigit()), reverse=True, ) for number in numbers: build_path = f"{job_path}/builds/{number}" try: evidence = parse_build_xml( _read(f"{build_path}/build.xml"), job=job, branch=branch, number=number, ) except (ET.ParseError, subprocess.SubprocessError): continue if commit and not (evidence.revision or "").startswith(commit): continue log_tail = sanitize_log( _kubectl("tail", "-n", str(log_lines), f"{build_path}/log") ) return BuildEvidence(**{**asdict(evidence), "log_tail": log_tail}) return None def find_build( job: str, branch: str | None, commit: str | None, log_lines: int, ) -> BuildEvidence | None: """Find a build, accepting the common omitted ``-branches`` suffix.""" candidates = [job] if branch is not None and not job.endswith("-branches"): candidates.append(f"{job}-branches") last_error: Exception | None = None for candidate in candidates: try: return _find_build_for_job(candidate, branch, commit, log_lines) except (OSError, ValueError, subprocess.SubprocessError) as exc: last_error = exc if last_error is not None: raise last_error return None def parse_args(argv: list[str] | None = None) -> argparse.Namespace: """Parse the bounded Jenkins evidence request.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("job") parser.add_argument("--branch") parser.add_argument("--commit", help="full or unambiguous leading commit SHA") parser.add_argument("--wait", action="store_true", help="wait for a terminal result") parser.add_argument("--timeout", type=int, default=900) parser.add_argument("--poll", type=int, default=10) parser.add_argument("--log-lines", type=int, default=40) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: """Print one JSON evidence object, optionally waiting until it is terminal.""" args = parse_args(argv) if args.timeout < 1 or args.poll < 1 or not 0 <= args.log_lines <= 200: print("timeout/poll must be positive and log-lines must be 0..200", file=sys.stderr) return 2 deadline = time.monotonic() + args.timeout try: while True: evidence = find_build(args.job, args.branch, args.commit, args.log_lines) if evidence and (not args.wait or not evidence.building): print(json.dumps(asdict(evidence), indent=2, sort_keys=True)) return 0 if not args.wait or time.monotonic() >= deadline: value = asdict(evidence) if evidence else None print(json.dumps({"timed_out": args.wait, "evidence": value}, indent=2)) return 2 if args.wait else 1 time.sleep(args.poll) except (OSError, ValueError, subprocess.SubprocessError, ET.ParseError) as exc: print(f"Jenkins evidence read failed: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())