145 lines
4.9 KiB
Python
Executable File
145 lines
4.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Trigger a bounded reviewed-main Hermes image release job."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
JENKINS_ORIGIN = "https://ci.bstein.dev"
|
|
JENKINS_BUILD_URL = f"{JENKINS_ORIGIN}/buildByToken/buildWithParameters"
|
|
JOBS = {
|
|
"agent": {
|
|
"job": "hermes-agent-image",
|
|
"confirmation": "PUBLISH HERMES AGENT",
|
|
},
|
|
"webui": {
|
|
"job": "hermes-webui-image",
|
|
"confirmation": "PUBLISH HERMES WEBUI",
|
|
},
|
|
"stt": {
|
|
"job": "hermes-voice-image",
|
|
"confirmation": "PUBLISH HERMES STT",
|
|
"parameters": {"IMAGE_COMPONENT": "stt"},
|
|
},
|
|
"tts": {
|
|
"job": "hermes-voice-image",
|
|
"confirmation": "PUBLISH HERMES TTS",
|
|
"parameters": {"IMAGE_COMPONENT": "tts"},
|
|
},
|
|
}
|
|
JOB_NAME = JOBS["agent"]["job"]
|
|
TOKEN_FILE = Path("/runtime-access/jenkins-image-build-token")
|
|
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
QUEUE_PATH_PATTERN = re.compile(r"^/queue/item/[0-9]+/?$")
|
|
|
|
|
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
"""Keep a queued-build redirect from becoming an unauthorized job read."""
|
|
|
|
def redirect_request(self, _request, _file, _code, _message, _headers, _url):
|
|
return None
|
|
|
|
|
|
def _open_without_redirect(request: urllib.request.Request, timeout: int):
|
|
"""Return the Build Token Root response, including its expected HTTP 303."""
|
|
opener = urllib.request.build_opener(_NoRedirect())
|
|
try:
|
|
return opener.open(request, timeout=timeout)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 303:
|
|
return exc
|
|
raise
|
|
|
|
|
|
def trigger_build(
|
|
revision: str,
|
|
*,
|
|
component: str = "agent",
|
|
token_file: Path = TOKEN_FILE,
|
|
opener=_open_without_redirect,
|
|
) -> dict[str, str | int]:
|
|
"""Post one allow-listed job's fixed parameters using the release token."""
|
|
revision = revision.strip()
|
|
if not REVISION_PATTERN.fullmatch(revision):
|
|
raise ValueError("revision must be a lowercase full 40-character commit")
|
|
job = JOBS.get(component)
|
|
if job is None:
|
|
raise ValueError("component must be agent, webui, stt, or tts")
|
|
token = token_file.read_text(encoding="utf-8").strip()
|
|
if not token:
|
|
raise RuntimeError("Jenkins image-build token is empty")
|
|
fields = {
|
|
"job": job["job"],
|
|
"token": token,
|
|
"PUBLISH_IMAGE": "true",
|
|
"EXPECTED_SOURCE_REVISION": revision,
|
|
"CONFIRM_PUBLISH": job["confirmation"],
|
|
}
|
|
fields.update(job.get("parameters", {}))
|
|
payload = urllib.parse.urlencode(fields).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
JENKINS_BUILD_URL,
|
|
data=payload,
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
method="POST",
|
|
)
|
|
with opener(request, timeout=20) as response:
|
|
status = int(response.status)
|
|
location = response.headers.get("Location", "")
|
|
if status not in {201, 303}:
|
|
raise RuntimeError(f"Jenkins trigger returned HTTP {status}")
|
|
if not location:
|
|
raise RuntimeError("Jenkins trigger omitted the queue Location")
|
|
queue_url = urllib.parse.urljoin(f"{JENKINS_ORIGIN}/", location)
|
|
parsed_queue = urllib.parse.urlsplit(queue_url)
|
|
expected_origin = urllib.parse.urlsplit(JENKINS_ORIGIN)
|
|
if (
|
|
parsed_queue.scheme != expected_origin.scheme
|
|
or parsed_queue.netloc != expected_origin.netloc
|
|
or parsed_queue.query
|
|
or parsed_queue.fragment
|
|
or not QUEUE_PATH_PATTERN.fullmatch(parsed_queue.path)
|
|
):
|
|
raise RuntimeError("Jenkins returned an invalid queue Location")
|
|
# Never return the submitted URL: its form body contains the job token.
|
|
queue_path = parsed_queue.path
|
|
return {
|
|
"component": component,
|
|
"follow_command": (
|
|
"/opt/coordinator/hermes_image_release_status.py "
|
|
f"--component {component} --revision {revision} --wait"
|
|
),
|
|
"job": job["job"],
|
|
"queue_path": queue_path,
|
|
"source_revision": revision,
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
"""Validate one revision, trigger the bounded job, and print safe metadata."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--component", choices=sorted(JOBS), default="agent", help="image to release"
|
|
)
|
|
parser.add_argument("revision", help="reviewed full commit contained by main")
|
|
args = parser.parse_args()
|
|
try:
|
|
result = trigger_build(args.revision, component=args.component)
|
|
except (OSError, ValueError, RuntimeError, urllib.error.URLError) as exc:
|
|
print(json.dumps({"error": str(exc)}, sort_keys=True))
|
|
return 1
|
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - exercised through main()
|
|
raise SystemExit(main())
|