244 lines
7.9 KiB
Python
244 lines
7.9 KiB
Python
#!/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())
|