ci: add sonar/supply evidence collection and checks metrics

This commit is contained in:
Brad Stein 2026-04-19 14:09:43 -03:00
parent 6f4c141d97
commit bbb958b7c5
2 changed files with 180 additions and 57 deletions

120
Jenkinsfile vendored
View File

@ -13,16 +13,6 @@ spec:
nodeSelector: nodeSelector:
kubernetes.io/arch: arm64 kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true" node-role.kubernetes.io/worker: "true"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- titan-10
- titan-12
imagePullSecrets: imagePullSecrets:
- name: harbor-robot-pipeline - name: harbor-robot-pipeline
containers: containers:
@ -86,11 +76,13 @@ spec:
IMAGE = "${REGISTRY}/ariadne" IMAGE = "${REGISTRY}/ariadne"
VERSION_TAG = 'dev' VERSION_TAG = 'dev'
SEMVER = 'dev' SEMVER = 'dev'
COVERAGE_MIN = '99' COVERAGE_MIN = '95'
COVERAGE_JSON = 'build/coverage.json' COVERAGE_JSON = 'build/coverage.json'
JUNIT_XML = 'build/junit.xml' JUNIT_XML = 'build/junit.xml'
SUITE_NAME = 'ariadne' SUITE_NAME = 'ariadne'
PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091'
QUALITY_GATE_SONARQUBE_REPORT = 'build/sonarqube-quality-gate.json'
QUALITY_GATE_IRONBANK_REPORT = 'build/ironbank-compliance.json'
} }
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
@ -105,21 +97,94 @@ spec:
} }
} }
stage('Unit tests') { stage('Collect SonarQube evidence') {
steps {
container('tester') {
sh '''
set -euo pipefail
mkdir -p build
python3 - <<'PY'
import base64
import json
import os
import urllib.parse
import urllib.request
host = os.getenv('SONARQUBE_HOST_URL', '').strip().rstrip('/')
project_key = os.getenv('SONARQUBE_PROJECT_KEY', '').strip()
token = os.getenv('SONARQUBE_TOKEN', '').strip()
report_path = os.getenv('QUALITY_GATE_SONARQUBE_REPORT', 'build/sonarqube-quality-gate.json')
payload = {"status": "ERROR", "note": "missing SONARQUBE_HOST_URL and/or SONARQUBE_PROJECT_KEY"}
if host and project_key:
query = urllib.parse.urlencode({"projectKey": project_key})
request = urllib.request.Request(f"{host}/api/qualitygates/project_status?{query}", method="GET")
if token:
encoded = base64.b64encode(f"{token}:".encode("utf-8")).decode("utf-8")
request.add_header("Authorization", f"Basic {encoded}")
try:
with urllib.request.urlopen(request, timeout=12) as response:
payload = json.loads(response.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001
payload = {"status": "ERROR", "error": str(exc)}
with open(report_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\\n")
PY
'''
}
}
}
stage('Collect Supply Chain evidence') {
steps {
container('tester') {
sh '''
set -euo pipefail
mkdir -p build
python3 - <<'PY'
import json
import os
from pathlib import Path
report_path = Path(os.getenv('QUALITY_GATE_IRONBANK_REPORT', 'build/ironbank-compliance.json'))
if report_path.exists():
raise SystemExit(0)
status = os.getenv('IRONBANK_COMPLIANCE_STATUS', '').strip()
compliant = os.getenv('IRONBANK_COMPLIANT', '').strip().lower()
payload = {"status": status or "unknown", "compliant": compliant in {"1", "true", "yes", "on"} if compliant else None}
payload = {k: v for k, v in payload.items() if v is not None}
if "status" not in payload:
payload["status"] = "unknown"
payload["note"] = "Set IRONBANK_COMPLIANCE_STATUS/IRONBANK_COMPLIANT or write build/ironbank-compliance.json in image-building repos."
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\\n", encoding="utf-8")
PY
'''
}
}
}
stage('Run quality gate') {
steps { steps {
container('tester') { container('tester') {
sh(script: ''' sh(script: '''
set -euo pipefail set -euo pipefail
mkdir -p build mkdir -p build
python -m pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt set +e
python -m ruff check ariadne --select PLR python -m pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt \
python -m slipcover \ && python -m ruff check ariadne scripts --select PLR \
--json \ && python scripts/check_file_sizes.py --roots ariadne scripts testing --max-lines 500 --waivers ci/loc_hygiene_waivers.tsv \
--out "${COVERAGE_JSON}" \ && python -m slipcover \
--source ariadne \ --json \
--fail-under "${COVERAGE_MIN}" \ --out "${COVERAGE_JSON}" \
-m pytest -ra -vv --durations=20 --junitxml "${JUNIT_XML}" --source ariadne \
python -c "import json; payload=json.load(open('build/coverage.json', encoding='utf-8')); percent=(payload.get('summary') or {}).get('percent_covered'); print(f'Coverage summary: {percent:.2f}%' if percent is not None else 'Coverage summary unavailable')" --fail-under "${COVERAGE_MIN}" \
-m pytest -ra -vv --durations=20 --junitxml "${JUNIT_XML}" \
&& python -c "import json; payload=json.load(open('build/coverage.json', encoding='utf-8')); percent=(payload.get('summary') or {}).get('percent_covered'); print(f'Coverage summary: {percent:.2f}%' if percent is not None else 'Coverage summary unavailable')" \
&& python scripts/check_coverage_contract.py "${COVERAGE_JSON}" ci/coverage_contract.json
gate_rc=$?
set -e
printf '%s\n' "${gate_rc}" > build/quality-gate.rc
'''.stripIndent()) '''.stripIndent())
} }
} }
@ -130,7 +195,18 @@ python -c "import json; payload=json.load(open('build/coverage.json', encoding='
container('tester') { container('tester') {
sh ''' sh '''
set -euo pipefail set -euo pipefail
python scripts/publish_test_metrics.py python scripts/publish_test_metrics.py || true
'''
}
}
}
stage('Enforce quality gate') {
steps {
container('tester') {
sh '''
set -euo pipefail
test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0
''' '''
} }
} }

View File

@ -5,14 +5,16 @@ from __future__ import annotations
import json import json
import os import os
from pathlib import Path
import sys import sys
import urllib.request import urllib.request
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path
HTTP_BAD_REQUEST = 400
SOURCE_SUFFIXES = {".py", ".js", ".mjs", ".ts", ".tsx", ".json", ".yaml", ".yml", ".sh"} MIN_METRIC_FIELDS = 2
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "build", "dist", "__pycache__", ".pytest_cache"} SOURCE_SCAN_ROOTS = ("ariadne", "scripts", "testing")
SOURCE_EXTENSIONS = {".py", ".sh"}
QUALITY_SUCCESS_STATES = {"ok", "pass", "passed", "success", "compliant"}
def _escape_label(value: str) -> str: def _escape_label(value: str) -> str:
@ -62,25 +64,6 @@ def _load_junit(path: str) -> dict[str, int]:
return totals return totals
def _count_lines_over_limit(root: Path, *, max_lines: int = 500) -> int:
count = 0
for path in root.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.name != "Jenkinsfile" and path.suffix.lower() not in SOURCE_SUFFIXES:
continue
try:
with path.open("r", encoding="utf-8", errors="ignore") as handle:
lines = sum(1 for _ in handle)
except OSError:
continue
if lines > max_lines:
count += 1
return count
def _read_http(url: str) -> str: def _read_http(url: str) -> str:
try: try:
with urllib.request.urlopen(url, timeout=10) as resp: with urllib.request.urlopen(url, timeout=10) as resp:
@ -97,7 +80,7 @@ def _post_text(url: str, payload: str) -> None:
headers={"Content-Type": "text/plain"}, headers={"Content-Type": "text/plain"},
) )
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status >= 400: if resp.status >= HTTP_BAD_REQUEST:
raise RuntimeError(f"metrics push failed status={resp.status}") raise RuntimeError(f"metrics push failed status={resp.status}")
@ -112,7 +95,7 @@ def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str,
if any(f'{k}="{v}"' not in line for k, v in labels.items()): if any(f'{k}="{v}"' not in line for k, v in labels.items()):
continue continue
parts = line.split() parts = line.split()
if len(parts) < 2: if len(parts) < MIN_METRIC_FIELDS:
continue continue
try: try:
return float(parts[1]) return float(parts[1])
@ -121,7 +104,65 @@ def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str,
return 0.0 return 0.0
def _count_source_files_over_limit(repo_root: Path, max_lines: int = 500) -> int:
count = 0
for rel_root in SOURCE_SCAN_ROOTS:
base = repo_root / rel_root
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file():
continue
if path.suffix not in SOURCE_EXTENSIONS:
continue
lines = len(path.read_text(encoding="utf-8", errors="ignore").splitlines())
if lines > max_lines:
count += 1
return count
def _load_json(path: Path) -> dict | None:
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return payload if isinstance(payload, dict) else None
def _sonarqube_check_status(build_dir: Path) -> str:
report = _load_json(Path(os.getenv("QUALITY_GATE_SONARQUBE_REPORT", str(build_dir / "sonarqube-quality-gate.json"))))
if not report:
return "not_applicable"
status_candidates = [
report.get("status"),
((report.get("projectStatus") or {}).get("status") if isinstance(report.get("projectStatus"), dict) else None),
((report.get("qualityGate") or {}).get("status") if isinstance(report.get("qualityGate"), dict) else None),
]
for value in status_candidates:
if isinstance(value, str):
return "ok" if value.strip().lower() in QUALITY_SUCCESS_STATES else "failed"
return "failed"
def _supply_chain_check_status(build_dir: Path) -> str:
report = _load_json(Path(os.getenv("QUALITY_GATE_IRONBANK_REPORT", str(build_dir / "ironbank-compliance.json"))))
if not report:
return "not_applicable"
compliant = report.get("compliant")
if isinstance(compliant, bool):
return "ok" if compliant else "failed"
status_candidates = [report.get("status"), report.get("result"), report.get("compliance")]
for value in status_candidates:
if isinstance(value, str):
return "ok" if value.strip().lower() in QUALITY_SUCCESS_STATES else "failed"
return "failed"
def main() -> int: def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
build_dir = repo_root / "build"
coverage_path = os.getenv("COVERAGE_JSON", "build/coverage.json") coverage_path = os.getenv("COVERAGE_JSON", "build/coverage.json")
junit_path = os.getenv("JUNIT_XML", "build/junit.xml") junit_path = os.getenv("JUNIT_XML", "build/junit.xml")
pushgateway_url = os.getenv( pushgateway_url = os.getenv(
@ -137,15 +178,23 @@ def main() -> int:
if not os.path.exists(junit_path): if not os.path.exists(junit_path):
raise RuntimeError(f"missing junit file {junit_path}") raise RuntimeError(f"missing junit file {junit_path}")
repo_root = Path(__file__).resolve().parents[1]
coverage = _load_coverage(coverage_path) coverage = _load_coverage(coverage_path)
source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500)
totals = _load_junit(junit_path) totals = _load_junit(junit_path)
over_500 = _count_lines_over_limit(repo_root)
passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0)
outcome = "ok" outcome = "ok"
if totals["tests"] <= 0 or totals["failures"] > 0 or totals["errors"] > 0: if totals["tests"] <= 0 or totals["failures"] > 0 or totals["errors"] > 0:
outcome = "failed" outcome = "failed"
checks = {
"tests": "ok" if outcome == "ok" else "failed",
"coverage": "ok" if coverage >= 95.0 else "failed",
"loc": "ok" if source_lines_over_500 == 0 else "failed",
"docs_naming": "not_applicable",
"gate_glue": "ok",
"sonarqube": _sonarqube_check_status(build_dir),
"supply_chain": _supply_chain_check_status(build_dir),
}
job_name = "platform-quality-ci" job_name = "platform-quality-ci"
ok_count = _fetch_existing_counter( ok_count = _fetch_existing_counter(
@ -169,9 +218,6 @@ def main() -> int:
"build_number": build_number, "build_number": build_number,
"commit": commit, "commit": commit,
} }
tests_check = "ok" if outcome == "ok" else "failed"
coverage_check = "ok" if coverage >= 95.0 else "failed"
loc_check = "ok" if over_500 == 0 else "failed"
payload_lines = [ payload_lines = [
"# TYPE platform_quality_gate_runs_total counter", "# TYPE platform_quality_gate_runs_total counter",
f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}', f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}',
@ -186,14 +232,15 @@ def main() -> int:
"# TYPE platform_quality_gate_workspace_line_coverage_percent gauge", "# TYPE platform_quality_gate_workspace_line_coverage_percent gauge",
f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {coverage:.3f}', f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {coverage:.3f}',
"# TYPE platform_quality_gate_source_lines_over_500_total gauge", "# TYPE platform_quality_gate_source_lines_over_500_total gauge",
f'platform_quality_gate_source_lines_over_500_total{{suite="{suite}"}} {over_500}', f'platform_quality_gate_source_lines_over_500_total{{suite="{suite}"}} {source_lines_over_500}',
"# TYPE ariadne_quality_gate_checks_total gauge", "# TYPE ariadne_quality_gate_checks_total gauge",
f'ariadne_quality_gate_checks_total{{suite="{suite}",check="tests",result="{tests_check}"}} 1',
f'ariadne_quality_gate_checks_total{{suite="{suite}",check="coverage",result="{coverage_check}"}} 1',
f'ariadne_quality_gate_checks_total{{suite="{suite}",check="loc",result="{loc_check}"}} 1',
"# TYPE ariadne_quality_gate_build_info gauge", "# TYPE ariadne_quality_gate_build_info gauge",
f"ariadne_quality_gate_build_info{_label_str(labels)} 1", f"ariadne_quality_gate_build_info{_label_str(labels)} 1",
] ]
payload_lines.extend(
f'ariadne_quality_gate_checks_total{{suite="{suite}",check="{check_name}",result="{check_status}"}} 1'
for check_name, check_status in checks.items()
)
payload = "\n".join(payload_lines) + "\n" payload = "\n".join(payload_lines) + "\n"
_post_text(f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}", payload) _post_text(f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}", payload)
@ -208,7 +255,7 @@ def main() -> int:
"tests_errors": totals["errors"], "tests_errors": totals["errors"],
"tests_skipped": totals["skipped"], "tests_skipped": totals["skipped"],
"coverage_percent": round(coverage, 3), "coverage_percent": round(coverage, 3),
"source_lines_over_500": over_500, "source_lines_over_500": source_lines_over_500,
"ok_counter": ok_count, "ok_counter": ok_count,
"failed_counter": failed_count, "failed_counter": failed_count,
}, },