Compare commits

...

3 Commits

3 changed files with 246 additions and 11 deletions

69
Jenkinsfile vendored
View File

@ -35,6 +35,8 @@ spec:
environment { environment {
SUITE_NAME = 'ananke' SUITE_NAME = 'ananke'
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 {
@ -52,6 +54,73 @@ spec:
} }
} }
stage('Collect SonarQube evidence') {
steps {
container('publisher') {
sh '''
set -eu
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('publisher') {
sh '''
set -eu
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') { stage('Run quality gate') {
steps { steps {
container('go-tester') { container('go-tester') {

View File

@ -4,8 +4,10 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os import os
from pathlib import Path from pathlib import Path
import re
import sys import sys
import time import time
import urllib.error import urllib.error
@ -15,6 +17,7 @@ import urllib.request
DEFAULT_PUSHGATEWAY_URL = "http://platform-quality-gateway.monitoring.svc.cluster.local:9091" DEFAULT_PUSHGATEWAY_URL = "http://platform-quality-gateway.monitoring.svc.cluster.local:9091"
SOURCE_SCAN_ROOTS = ("cmd", "internal", "scripts", "testing") SOURCE_SCAN_ROOTS = ("cmd", "internal", "scripts", "testing")
SOURCE_EXTENSIONS = {".go", ".py", ".sh"} SOURCE_EXTENSIONS = {".go", ".py", ".sh"}
QUALITY_SUCCESS_STATES = {"ok", "pass", "passed", "success", "compliant"}
def _escape_label(value: str) -> str: def _escape_label(value: str) -> str:
@ -42,7 +45,7 @@ def _post_text(url: str, payload: str, timeout_seconds: float, attempts: int, re
req = urllib.request.Request( req = urllib.request.Request(
url, url,
data=payload.encode("utf-8"), data=payload.encode("utf-8"),
method="POST", method="PUT",
headers={"Content-Type": "text/plain"}, headers={"Content-Type": "text/plain"},
) )
try: try:
@ -74,14 +77,49 @@ def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str,
return 0.0 return 0.0
def _build_payload(suite: str, trigger: str, ok_count: int, failed_count: int) -> str: def _build_payload(
suite: str,
trigger: str,
ok_count: int,
failed_count: int,
*,
tests_passed: int,
tests_failed: int,
tests_errors: int,
tests_skipped: int,
test_cases: list[tuple[str, str]],
coverage_percent: float,
source_lines_over_500: int,
checks: dict[str, str],
) -> str:
lines = [ 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}', f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count}',
f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count}', f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count}',
"# TYPE ananke_quality_gate_tests_total gauge",
f'ananke_quality_gate_tests_total{{suite="{suite}",result="passed"}} {tests_passed}',
f'ananke_quality_gate_tests_total{{suite="{suite}",result="failed"}} {tests_failed}',
f'ananke_quality_gate_tests_total{{suite="{suite}",result="error"}} {tests_errors}',
f'ananke_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {tests_skipped}',
"# TYPE ananke_quality_gate_coverage_percent gauge",
f'ananke_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_percent:.3f}',
"# TYPE platform_quality_gate_workspace_line_coverage_percent gauge",
f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {coverage_percent:.3f}',
"# TYPE platform_quality_gate_source_lines_over_500_total gauge",
f'platform_quality_gate_source_lines_over_500_total{{suite="{suite}"}} {source_lines_over_500}',
"# TYPE platform_quality_gate_test_case_result gauge",
"# TYPE ananke_quality_gate_checks_total gauge",
"# TYPE ananke_quality_gate_publish_info gauge", "# TYPE ananke_quality_gate_publish_info gauge",
f'ananke_quality_gate_publish_info{_label_str({"suite": suite, "trigger": trigger})} 1', f'ananke_quality_gate_publish_info{_label_str({"suite": suite, "trigger": trigger})} 1',
] ]
lines.extend(
f'platform_quality_gate_test_case_result{{suite="{suite}",test="{_escape_label(test_name)}",status="{_escape_label(test_status)}"}} 1'
for test_name, test_status in test_cases
)
lines.extend(
f'ananke_quality_gate_checks_total{{suite="{suite}",check="{check_name}",result="{check_status}"}} 1'
for check_name, check_status in checks.items()
)
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@ -115,6 +153,90 @@ def _count_source_files_over_limit(repo_root: Path, max_lines: int = 500) -> int
return count return count
def _parse_go_test_counts(output_path: Path) -> dict[str, int]:
if not output_path.exists():
return {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}
text = output_path.read_text(encoding="utf-8", errors="ignore")
return {
"passed": len(re.findall(r"^--- PASS:", text, flags=re.M)),
"failed": len(re.findall(r"^--- FAIL:", text, flags=re.M)),
"errors": 0,
"skipped": len(re.findall(r"^--- SKIP:", text, flags=re.M)),
}
def _parse_go_test_cases(output_path: Path) -> list[tuple[str, str]]:
if not output_path.exists():
return []
text = output_path.read_text(encoding="utf-8", errors="ignore")
cases: list[tuple[str, str]] = []
for match in re.finditer(r"^---\s+(PASS|FAIL|SKIP):\s+(\S+)", text, flags=re.M):
raw_status, test_name = match.groups()
status = {"PASS": "passed", "FAIL": "failed", "SKIP": "skipped"}.get(raw_status, "error")
cases.append((test_name.strip(), status))
return cases
def _read_exit_code(path: Path) -> int:
if not path.exists():
return 1
raw = path.read_text(encoding="utf-8").strip()
try:
return int(raw)
except ValueError:
return 1
def _read_status(path: Path, default: str = "failed") -> str:
if not path.exists():
return default
raw = path.read_text(encoding="utf-8").strip().lower()
if raw in {"ok", "pass", "passed", "success"}:
return "ok"
if raw in {"failed", "fail", "error"}:
return "failed"
return default
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 parse_args(argv: list[str]) -> argparse.Namespace: def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument( parser.add_argument(
@ -155,6 +277,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:]) args = parse_args(argv or sys.argv[1:])
repo_root = Path(__file__).resolve().parents[1] repo_root = Path(__file__).resolve().parents[1]
build_dir = repo_root / "build"
remote_ok = 0 remote_ok = 0
remote_failed = 0 remote_failed = 0
@ -183,14 +306,34 @@ def main(argv: list[str] | None = None) -> int:
resolved_failed = max(args.local_failed, remote_failed) resolved_failed = max(args.local_failed, remote_failed)
coverage_percent = _read_coverage_percent(args.coverage_percent_file) coverage_percent = _read_coverage_percent(args.coverage_percent_file)
source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500) source_lines_over_500 = _count_source_files_over_limit(repo_root, max_lines=500)
payload = _build_payload(args.suite, args.trigger, resolved_ok, resolved_failed).rstrip("\n") test_output = Path(os.getenv("ANANKE_QUALITY_OUTPUT_FILE", str(build_dir / "quality-gate.out")))
payload += ( tests = _parse_go_test_counts(test_output)
"\n# TYPE ananke_quality_gate_coverage_percent gauge\n" test_cases = _parse_go_test_cases(test_output)
f'ananke_quality_gate_coverage_percent{{suite="{args.suite}"}} {coverage_percent:.3f}\n' gate_rc = _read_exit_code(Path(os.getenv("ANANKE_QUALITY_EXIT_CODE_PATH", str(build_dir / "quality-gate.rc"))))
"# TYPE platform_quality_gate_workspace_line_coverage_percent gauge\n" docs_status = _read_status(Path(os.getenv("ANANKE_QUALITY_DOCS_STATUS_PATH", str(build_dir / "docs-naming.status"))))
f'platform_quality_gate_workspace_line_coverage_percent{{suite="{args.suite}"}} {coverage_percent:.3f}\n' gate_failed = gate_rc != 0
"# TYPE platform_quality_gate_source_lines_over_500_total gauge\n" checks = {
f'platform_quality_gate_source_lines_over_500_total{{suite="{args.suite}"}} {source_lines_over_500}\n' "tests": "failed" if gate_failed or tests["failed"] > 0 else "ok",
"coverage": "ok" if coverage_percent >= 95.0 else "failed",
"loc": "ok" if source_lines_over_500 == 0 else "failed",
"docs_naming": docs_status,
"gate_glue": "ok",
"sonarqube": _sonarqube_check_status(build_dir),
"supply_chain": _supply_chain_check_status(build_dir),
}
payload = _build_payload(
args.suite,
args.trigger,
resolved_ok,
resolved_failed,
tests_passed=tests["passed"],
tests_failed=tests["failed"],
tests_errors=tests["errors"],
tests_skipped=tests["skipped"],
test_cases=test_cases,
coverage_percent=coverage_percent,
source_lines_over_500=source_lines_over_500,
checks=checks,
) )
if args.dry_run: if args.dry_run:

View File

@ -19,6 +19,25 @@ QUALITY_LAST_SUCCESS=0
QUALITY_LAST_RUN_TS=0 QUALITY_LAST_RUN_TS=0
QUALITY_SUCCESS_PERCENT="0.00" QUALITY_SUCCESS_PERCENT="0.00"
run_with_retry() {
local attempts="$1"
shift
local try=1
local delay=3
local rc=0
while true; do
"$@" && return 0
rc=$?
if [[ "${try}" -ge "${attempts}" ]]; then
return "${rc}"
fi
echo "[quality] retry ${try}/${attempts} after rc=${rc}: $*" >&2
sleep "${delay}"
delay=$((delay * 2))
try=$((try + 1))
done
}
read_quality_counter() { read_quality_counter() {
local key="$1" local key="$1"
if [[ ! -f "${QUALITY_STATE_FILE}" ]]; then if [[ ! -f "${QUALITY_STATE_FILE}" ]]; then
@ -137,9 +156,12 @@ trap 'quality_gate_finalize $?' EXIT
cd "${REPO_DIR}" cd "${REPO_DIR}"
mkdir -p "${BUILD_DIR}" mkdir -p "${BUILD_DIR}"
rm -f "${COVERAGE_PROFILE}" "${COVERAGE_PERCENT_FILE}" rm -f "${COVERAGE_PROFILE}" "${COVERAGE_PERCENT_FILE}"
printf 'failed\n' > "${BUILD_DIR}/docs-naming.status"
echo "[quality] unit tests + workspace coverage profile" echo "[quality] unit tests + workspace coverage profile"
go test -coverprofile="${COVERAGE_PROFILE}" ./... export GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}"
run_with_retry 4 go mod download
run_with_retry 3 go test -coverprofile="${COVERAGE_PROFILE}" ./...
coverage_percent="$(go tool cover -func="${COVERAGE_PROFILE}" | awk '/^total:/ {gsub("%","",$3); print $3}')" coverage_percent="$(go tool cover -func="${COVERAGE_PROFILE}" | awk '/^total:/ {gsub("%","",$3); print $3}')"
if [[ -z "${coverage_percent}" ]]; then if [[ -z "${coverage_percent}" ]]; then
coverage_percent="0" coverage_percent="0"
@ -152,6 +174,7 @@ go test ./hygiene -run TestHygieneContracts/doc_contract -count=1
echo "[quality] hygiene: naming contracts" echo "[quality] hygiene: naming contracts"
go test ./hygiene -run TestHygieneContracts/naming_contract -count=1 go test ./hygiene -run TestHygieneContracts/naming_contract -count=1
printf 'ok\n' > "${BUILD_DIR}/docs-naming.status"
echo "[quality] hygiene: LOC limits" echo "[quality] hygiene: LOC limits"
go test ./hygiene -run TestHygieneContracts/loc_limit -count=1 go test ./hygiene -run TestHygieneContracts/loc_limit -count=1