Files
tracer-cloud--opensre/integrations/jenkins/client.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

430 lines
16 KiB
Python

"""Jenkins REST API client.
Wraps the Jenkins endpoints used to correlate builds/deployments with incidents:
recent builds, build console logs, the job list, and currently running builds.
Credentials come from the user's Jenkins integration stored locally or via env vars.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
import httpx
from pydantic import ValidationError
from integrations.jenkins import JenkinsConfig
from platform.observability.errors.service import capture_service_error
logger = logging.getLogger(__name__)
_MAX_JOB_NAME_LEN = 256
_MAX_LOG_CHARS = 50_000
# Cap the number of jobs returned by discovery so a server with thousands of
# jobs cannot blow past the request timeout. list_jobs/list_running_builds
# report whether the cap truncated the result.
_MAX_JOBS = 200
# Jenkins folders nest jobs; descend this many levels when discovering jobs.
# Realistic folder hierarchies are 1-3 deep, so this comfortably covers them.
_MAX_FOLDER_DEPTH = 10
# Jenkins encodes a job's last-build status in a "color" field (a legacy ball-color scheme).
_COLOR_STATUS = {
"blue": "SUCCESS",
"green": "SUCCESS",
"red": "FAILURE",
"yellow": "UNSTABLE",
"aborted": "ABORTED",
"grey": "NOT_BUILT",
"disabled": "DISABLED",
"notbuilt": "NOT_BUILT",
}
def _safe_job_name(raw: str) -> str | None:
"""Validate a job name (top-level or folder path) before building a URL path.
A '/' separates folder segments (e.g. ``team/payment-service``), which
``_job_api_path`` maps to Jenkins' nested ``/job/`` path. Rejects empty,
oversized, traversal-prone, or malformed names (empty/whitespace segments,
backslashes). Each segment is trimmed; the normalized display name is
returned.
"""
cleaned = (raw or "").strip().strip("/")
if not cleaned or len(cleaned) > _MAX_JOB_NAME_LEN:
return None
if ".." in cleaned or "\\" in cleaned:
return None
segments = [segment.strip() for segment in cleaned.split("/")]
if any(not segment for segment in segments):
return None
return "/".join(segments)
def _job_api_path(name: str) -> str:
"""Map a validated job name to its Jenkins API path.
``payment-service`` -> ``job/payment-service``;
``team/payment-service`` -> ``job/team/job/payment-service`` (folder jobs).
Input is assumed already validated by ``_safe_job_name``.
"""
return "/".join(f"job/{segment}" for segment in name.split("/"))
def _iso_from_ms(value: object) -> str:
"""Convert a Jenkins epoch-millisecond timestamp to an ISO-8601 UTC string."""
if not isinstance(value, (int, float, str)):
return ""
try:
ms = int(value)
except (TypeError, ValueError):
return ""
if ms <= 0:
return ""
return datetime.fromtimestamp(ms / 1000, tz=UTC).isoformat()
def _status_from_color(color: object) -> tuple[str, bool]:
"""Map a Jenkins job "color" to a (status, is_building) pair.
A trailing "_anime" suffix means a build is currently in progress.
"""
raw = str(color or "").strip().lower()
building = raw.endswith("_anime")
base = raw[: -len("_anime")] if building else raw
return _COLOR_STATUS.get(base, base.upper() or "UNKNOWN"), building
def _shape_build(job_name: str, build: dict[str, Any]) -> dict[str, Any]:
"""Normalize one raw Jenkins build object into our stable output shape."""
result = build.get("result")
building = bool(build.get("building")) or result is None
return {
"job": job_name,
"number": build.get("number"),
# result is null while a build is still running; surface RUNNING explicitly.
"status": "RUNNING" if building else str(result or "UNKNOWN"),
"building": building,
"timestamp": _iso_from_ms(build.get("timestamp")),
"duration_ms": build.get("duration", 0),
"url": build.get("url", ""),
}
def _shape_stage(stage: dict[str, Any]) -> dict[str, Any]:
"""Normalize one Pipeline Stage View stage into our stable output shape."""
return {
"name": stage.get("name", ""),
"status": stage.get("status", ""),
"duration_ms": stage.get("durationMillis", 0),
"start_time": _iso_from_ms(stage.get("startTimeMillis")),
}
def _as_dict(value: object) -> dict[str, Any]:
"""Coerce a decoded JSON value to a dict (defends against malformed responses)."""
return value if isinstance(value, dict) else {}
def _as_dict_list(value: object) -> list[dict[str, Any]]:
"""Coerce a decoded JSON value to a list of dicts, dropping non-dict items."""
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _nested_jobs_tree(leaf_fields: str, depth: int) -> str:
"""Build a Jenkins ``tree`` query that descends folders ``depth`` levels.
Each level requests ``leaf_fields`` plus a nested ``jobs[...]`` for the next
level, so folder-organized jobs are returned alongside top-level ones.
"""
tree = leaf_fields
for _ in range(max(0, depth - 1)):
tree = f"{leaf_fields},jobs[{tree}]"
return f"jobs[{tree}]"
def _flatten_jobs(raw_jobs: list[dict[str, Any]], prefix: str = "") -> list[tuple[str, dict]]:
"""Flatten a (possibly nested) Jenkins jobs tree into ``(full_path, job)`` pairs.
A node with a nested ``jobs`` list is a folder and is recursed into; its
children's names are prefixed with ``folder/`` so the path matches what
``_job_api_path`` expects. Leaf jobs are returned with their full path.
"""
flat: list[tuple[str, dict]] = []
for job in raw_jobs:
name = str(job.get("name", "")).strip()
if not name:
continue
full_path = f"{prefix}{name}"
nested = job.get("jobs")
if isinstance(nested, list):
flat.extend(_flatten_jobs(_as_dict_list(nested), prefix=f"{full_path}/"))
else:
flat.append((full_path, job))
return flat
def _coerce_build_number(value: object) -> int | None:
"""Coerce a build number to a positive int, or None if invalid.
Jenkins build numbers start at 1; reject zero/negative and non-numeric input.
"""
if isinstance(value, bool) or not isinstance(value, (int, str)):
return None
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number >= 1 else None
class JenkinsClient:
"""Synchronous client for the Jenkins REST API."""
def __init__(self, config: JenkinsConfig) -> None:
self.config = config
self._client: httpx.Client | None = None
def _get_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(
base_url=self.config.api_base_url,
auth=self.config.auth,
timeout=self.config.timeout_seconds,
)
return self._client
def close(self) -> None:
"""Close the underlying HTTP connection pool."""
if self._client is not None:
self._client.close()
self._client = None
def __enter__(self) -> JenkinsClient:
return self
def __exit__(self, *_: object) -> None:
self.close()
def list_builds(
self,
job_name: str,
limit: int = 10,
status: str = "",
) -> dict[str, Any]:
"""List recent builds for a job, newest first.
Args:
job_name: The Jenkins job (project) name.
limit: Maximum number of builds to return (capped at 50).
status: Optional status filter, e.g. "FAILURE", "SUCCESS", "RUNNING".
"""
safe_name = _safe_job_name(job_name)
if not safe_name:
return {"success": False, "error": "invalid job name"}
# Cap the server-side fetch so we never transfer a job's full history.
# With a status filter we pull a wider window so the filter has enough
# rows to work with; otherwise fetch just what the caller asked for.
fetch_count = 50 if status else max(1, min(limit, 50))
tree = f"builds[number,result,timestamp,duration,url,building]{{0,{fetch_count}}}"
try:
resp = self._get_client().get(
f"/{_job_api_path(safe_name)}/api/json", params={"tree": tree}
)
resp.raise_for_status()
data = _as_dict(resp.json())
builds = [_shape_build(safe_name, b) for b in _as_dict_list(data.get("builds"))]
if status:
wanted = status.strip().upper()
builds = [b for b in builds if b["status"] == wanted]
builds = builds[: max(1, min(limit, 50))]
failed = [b for b in builds if b["status"] == "FAILURE"]
return {
"success": True,
"job": safe_name,
"builds": builds,
"failed_builds": failed,
"total": len(builds),
}
except Exception as exc:
return self._error("list_builds", exc, {"job": job_name, "status": status})
def get_build_log(
self,
job_name: str,
build_number: int,
max_chars: int = _MAX_LOG_CHARS,
) -> dict[str, Any]:
"""Fetch the console log for a specific build, tail-truncated to ``max_chars``."""
safe_name = _safe_job_name(job_name)
if not safe_name:
return {"success": False, "error": "invalid job name"}
number = _coerce_build_number(build_number)
if number is None:
return {"success": False, "error": "invalid build number"}
try:
resp = self._get_client().get(f"/{_job_api_path(safe_name)}/{number}/consoleText")
resp.raise_for_status()
text = resp.text
truncated = len(text) > max_chars
# Keep the tail — failures and stack traces live at the end of a build log.
log = text[-max_chars:] if truncated else text
return {
"success": True,
"job": safe_name,
"build_number": number,
"log": log,
"truncated": truncated,
}
except Exception as exc:
return self._error("get_build_log", exc, {"job": job_name, "build": build_number})
def get_pipeline_stages(self, job_name: str, build_number: int) -> dict[str, Any]:
"""Fetch pipeline stages for a build via the Stage View API (wfapi).
Freestyle jobs (and servers without the Pipeline Stage View plugin) have
no stages: those return ``is_pipeline=False`` with an empty stage list
rather than an error.
"""
safe_name = _safe_job_name(job_name)
if not safe_name:
return {"success": False, "error": "invalid job name"}
number = _coerce_build_number(build_number)
if number is None:
return {"success": False, "error": "invalid build number"}
try:
resp = self._get_client().get(f"/{_job_api_path(safe_name)}/{number}/wfapi/describe")
if resp.status_code == 404:
# Not a Pipeline job, or the Stage View plugin is absent.
return {
"success": True,
"job": safe_name,
"build_number": number,
"is_pipeline": False,
"stages": [],
}
resp.raise_for_status()
data = _as_dict(resp.json())
stages = [_shape_stage(s) for s in _as_dict_list(data.get("stages"))]
return {
"success": True,
"job": safe_name,
"build_number": number,
"is_pipeline": True,
"status": data.get("status", ""),
"stages": stages,
}
except Exception as exc:
return self._error("get_pipeline_stages", exc, {"job": job_name, "build": build_number})
def list_jobs(self) -> dict[str, Any]:
"""List jobs with their last-build status (decoded from the color field).
Recurses Jenkins folders up to ``_MAX_FOLDER_DEPTH`` (folder jobs are
reported by their full ``folder/job`` path), capped at ``_MAX_JOBS``;
``truncated`` is True when the cap or depth limit dropped jobs.
"""
leaf = "name,url,color,lastBuild[number,result,timestamp,url]"
tree = _nested_jobs_tree(leaf, _MAX_FOLDER_DEPTH)
try:
resp = self._get_client().get("/api/json", params={"tree": tree})
resp.raise_for_status()
data = _as_dict(resp.json())
flat = _flatten_jobs(_as_dict_list(data.get("jobs")))
jobs = []
for full_path, job in flat[:_MAX_JOBS]:
status, building = _status_from_color(job.get("color"))
last = _as_dict(job.get("lastBuild"))
jobs.append(
{
"name": full_path,
"url": job.get("url", ""),
"status": status,
"building": building,
"last_build_number": last.get("number"),
"last_build_at": _iso_from_ms(last.get("timestamp")),
}
)
return {
"success": True,
"jobs": jobs,
"total": len(jobs),
"truncated": len(flat) > _MAX_JOBS,
}
except Exception as exc:
return self._error("list_jobs", exc, {})
def list_running_builds(self) -> dict[str, Any]:
"""List builds currently in progress across all jobs.
Recurses Jenkins folders up to ``_MAX_FOLDER_DEPTH`` (5 most-recent builds
per job), scanning at most ``_MAX_JOBS`` jobs; ``truncated`` is True when
the cap or depth limit dropped jobs. Running builds are reported by their
full ``folder/job`` path.
"""
leaf = "name,builds[number,building,result,timestamp,url]{0,5}"
tree = _nested_jobs_tree(leaf, _MAX_FOLDER_DEPTH)
try:
resp = self._get_client().get("/api/json", params={"tree": tree})
resp.raise_for_status()
data = _as_dict(resp.json())
flat = _flatten_jobs(_as_dict_list(data.get("jobs")))
running = []
for full_path, job in flat[:_MAX_JOBS]:
for build in _as_dict_list(job.get("builds")):
if build.get("building"):
running.append(_shape_build(full_path, build))
return {
"success": True,
"running_builds": running,
"total": len(running),
"truncated": len(flat) > _MAX_JOBS,
}
except Exception as exc:
return self._error("list_running_builds", exc, {})
def _error(
self,
method: str,
exc: Exception,
extras: dict[str, Any],
) -> dict[str, Any]:
capture_service_error(
exc, logger=logger, integration="jenkins", method=method, extras=extras
)
if isinstance(exc, httpx.HTTPStatusError):
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
return {"success": False, "error": str(exc)}
def make_jenkins_client(
base_url: str | None,
username: str | None = None,
api_token: str | None = None,
) -> JenkinsClient | None:
"""Build a configured JenkinsClient.
Returns None unless URL, username, and token are all present. Jenkins Basic
auth sends ``username:api_token``; an empty username yields a ``:token`` pair
that Jenkins rejects with 401, so the factory refuses to build such a client
— every caller gets a clean "not configured" path instead of a 401.
"""
url = (base_url or "").strip()
user = (username or "").strip()
token = (api_token or "").strip()
if not (url and user and token):
return None
try:
config = JenkinsConfig(base_url=url, username=user, api_token=token)
except ValidationError:
return None
return JenkinsClient(config)