193 lines
7.5 KiB
YAML
193 lines
7.5 KiB
YAML
|
|
# services/quality/sonarqube-exporter-configmap.yaml
|
||
|
|
apiVersion: v1
|
||
|
|
kind: ConfigMap
|
||
|
|
metadata:
|
||
|
|
name: sonarqube-exporter-script
|
||
|
|
namespace: quality
|
||
|
|
data:
|
||
|
|
exporter.py: |
|
||
|
|
#!/usr/bin/env python3
|
||
|
|
import base64
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import urllib.error
|
||
|
|
import urllib.parse
|
||
|
|
import urllib.request
|
||
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
|
|
|
||
|
|
SONARQUBE_URL = os.getenv("SONARQUBE_URL", "http://sonarqube.quality.svc.cluster.local:9000").strip().rstrip("/")
|
||
|
|
SONARQUBE_TOKEN = os.getenv("SONARQUBE_TOKEN", "").strip()
|
||
|
|
SONARQUBE_TIMEOUT_SECONDS = float(os.getenv("SONARQUBE_TIMEOUT_SECONDS", "10"))
|
||
|
|
SONARQUBE_EXPORTER_PORT = int(os.getenv("SONARQUBE_EXPORTER_PORT", "9798"))
|
||
|
|
SONARQUBE_EXPORTER_CACHE_TTL_SECONDS = int(os.getenv("SONARQUBE_EXPORTER_CACHE_TTL_SECONDS", "45"))
|
||
|
|
SONARQUBE_PROJECT_LIMIT = int(os.getenv("SONARQUBE_PROJECT_LIMIT", "200"))
|
||
|
|
|
||
|
|
CACHE_LOCK = threading.Lock()
|
||
|
|
CACHE_EXPIRES_AT = 0.0
|
||
|
|
CACHE_BODY = ""
|
||
|
|
|
||
|
|
|
||
|
|
def _escape(value: str) -> str:
|
||
|
|
return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
|
||
|
|
|
||
|
|
|
||
|
|
def _fetch_json(path: str):
|
||
|
|
url = f"{SONARQUBE_URL}{path}"
|
||
|
|
req = urllib.request.Request(url, method="GET")
|
||
|
|
if SONARQUBE_TOKEN:
|
||
|
|
encoded = base64.b64encode(f"{SONARQUBE_TOKEN}:".encode("utf-8")).decode("utf-8")
|
||
|
|
req.add_header("Authorization", f"Basic {encoded}")
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=SONARQUBE_TIMEOUT_SECONDS) as resp:
|
||
|
|
payload = json.loads(resp.read().decode("utf-8"))
|
||
|
|
return payload, ""
|
||
|
|
except urllib.error.HTTPError as exc:
|
||
|
|
return None, f"http_{exc.code}"
|
||
|
|
except Exception as exc: # noqa: BLE001
|
||
|
|
return None, exc.__class__.__name__
|
||
|
|
|
||
|
|
|
||
|
|
def _metrics_body() -> str:
|
||
|
|
lines = []
|
||
|
|
now = time.time()
|
||
|
|
scrape_success = 1
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_exporter_last_scrape_timestamp_seconds Unix timestamp when exporter last refreshed data.")
|
||
|
|
lines.append("# TYPE sonarqube_exporter_last_scrape_timestamp_seconds gauge")
|
||
|
|
lines.append(f"sonarqube_exporter_last_scrape_timestamp_seconds {now:.3f}")
|
||
|
|
|
||
|
|
system_payload, system_error = _fetch_json("/api/system/status")
|
||
|
|
system_status = "unknown"
|
||
|
|
sonarqube_up = 0
|
||
|
|
if isinstance(system_payload, dict):
|
||
|
|
system_status = str(system_payload.get("status") or "unknown")
|
||
|
|
elif system_error:
|
||
|
|
system_status = system_error
|
||
|
|
scrape_success = 0
|
||
|
|
|
||
|
|
if system_status.upper() in {
|
||
|
|
"UP",
|
||
|
|
"STARTING",
|
||
|
|
"DB_MIGRATION_NEEDED",
|
||
|
|
"DB_MIGRATION_RUNNING",
|
||
|
|
}:
|
||
|
|
sonarqube_up = 1
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_up SonarQube API reachability and health (1=reachable/healthy-ish, 0=down).")
|
||
|
|
lines.append("# TYPE sonarqube_up gauge")
|
||
|
|
lines.append(f"sonarqube_up {sonarqube_up}")
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_system_status Current SonarQube system status label.")
|
||
|
|
lines.append("# TYPE sonarqube_system_status gauge")
|
||
|
|
lines.append(f'sonarqube_system_status{{status="{_escape(system_status)}"}} 1')
|
||
|
|
|
||
|
|
projects_payload, projects_error = _fetch_json("/api/projects/search?ps=500&p=1")
|
||
|
|
project_items = []
|
||
|
|
projects_total = 0
|
||
|
|
if isinstance(projects_payload, dict):
|
||
|
|
paging = projects_payload.get("paging") or {}
|
||
|
|
projects_total = int(paging.get("total") or 0)
|
||
|
|
project_items = list(projects_payload.get("components") or [])
|
||
|
|
else:
|
||
|
|
scrape_success = 0
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_projects_total Total discovered SonarQube projects.")
|
||
|
|
lines.append("# TYPE sonarqube_projects_total gauge")
|
||
|
|
lines.append(f"sonarqube_projects_total {projects_total}")
|
||
|
|
|
||
|
|
gate_counts = {}
|
||
|
|
gate_fetch_errors = 0
|
||
|
|
inspected = 0
|
||
|
|
project_samples = []
|
||
|
|
for project in project_items:
|
||
|
|
if inspected >= SONARQUBE_PROJECT_LIMIT:
|
||
|
|
break
|
||
|
|
key = str(project.get("key") or "").strip()
|
||
|
|
if not key:
|
||
|
|
continue
|
||
|
|
inspected += 1
|
||
|
|
|
||
|
|
gate_payload, gate_error = _fetch_json(
|
||
|
|
"/api/qualitygates/project_status?projectKey=" + urllib.parse.quote_plus(key)
|
||
|
|
)
|
||
|
|
if not isinstance(gate_payload, dict):
|
||
|
|
gate_fetch_errors += 1
|
||
|
|
continue
|
||
|
|
project_status = gate_payload.get("projectStatus") or {}
|
||
|
|
gate_status = str(project_status.get("status") or "UNKNOWN").upper()
|
||
|
|
gate_counts[gate_status] = gate_counts.get(gate_status, 0) + 1
|
||
|
|
is_ok = 1 if gate_status == "OK" else 0
|
||
|
|
project_samples.append(
|
||
|
|
f'sonarqube_project_quality_gate_pass{{project_key="{_escape(key)}",status="{_escape(gate_status)}"}} {is_ok}'
|
||
|
|
)
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_project_quality_gate_pass Project quality gate pass state (1=OK, 0=not OK).")
|
||
|
|
lines.append("# TYPE sonarqube_project_quality_gate_pass gauge")
|
||
|
|
lines.extend(project_samples)
|
||
|
|
lines.append("# HELP sonarqube_quality_gate_projects_total Number of projects by quality gate status.")
|
||
|
|
lines.append("# TYPE sonarqube_quality_gate_projects_total gauge")
|
||
|
|
for status, count in sorted(gate_counts.items()):
|
||
|
|
lines.append(f'sonarqube_quality_gate_projects_total{{status="{_escape(status)}"}} {count}')
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_quality_gate_fetch_errors_total Number of project gate API fetch failures in the last scrape.")
|
||
|
|
lines.append("# TYPE sonarqube_quality_gate_fetch_errors_total gauge")
|
||
|
|
lines.append(f"sonarqube_quality_gate_fetch_errors_total {gate_fetch_errors}")
|
||
|
|
|
||
|
|
lines.append("# HELP sonarqube_exporter_scrape_success Exporter scrape success (1=success, 0=partial/error).")
|
||
|
|
lines.append("# TYPE sonarqube_exporter_scrape_success gauge")
|
||
|
|
lines.append(f"sonarqube_exporter_scrape_success {scrape_success}")
|
||
|
|
|
||
|
|
if projects_error:
|
||
|
|
lines.append("# HELP sonarqube_exporter_projects_error Indicates projects API failure on the most recent scrape.")
|
||
|
|
lines.append("# TYPE sonarqube_exporter_projects_error gauge")
|
||
|
|
lines.append(f'sonarqube_exporter_projects_error{{error="{_escape(projects_error)}"}} 1')
|
||
|
|
|
||
|
|
return "\n".join(lines) + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _get_metrics() -> str:
|
||
|
|
global CACHE_BODY, CACHE_EXPIRES_AT
|
||
|
|
now = time.time()
|
||
|
|
with CACHE_LOCK:
|
||
|
|
if CACHE_BODY and now < CACHE_EXPIRES_AT:
|
||
|
|
return CACHE_BODY
|
||
|
|
CACHE_BODY = _metrics_body()
|
||
|
|
CACHE_EXPIRES_AT = now + max(5, SONARQUBE_EXPORTER_CACHE_TTL_SECONDS)
|
||
|
|
return CACHE_BODY
|
||
|
|
|
||
|
|
|
||
|
|
class Handler(BaseHTTPRequestHandler):
|
||
|
|
def do_GET(self): # noqa: N802
|
||
|
|
if self.path in ("/-/healthy", "/healthz"):
|
||
|
|
body = b"ok\n"
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||
|
|
self.send_header("Content-Length", str(len(body)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body)
|
||
|
|
return
|
||
|
|
if self.path == "/metrics":
|
||
|
|
body = _get_metrics().encode("utf-8")
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||
|
|
self.send_header("Content-Length", str(len(body)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body)
|
||
|
|
return
|
||
|
|
self.send_response(404)
|
||
|
|
self.end_headers()
|
||
|
|
|
||
|
|
def log_message(self, fmt, *args): # noqa: A003
|
||
|
|
return
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
server = HTTPServer(("0.0.0.0", SONARQUBE_EXPORTER_PORT), Handler)
|
||
|
|
server.serve_forever()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|