diff --git a/ci/Jenkinsfile.titan-iac b/ci/Jenkinsfile.titan-iac index 77990d77..2f6d26d4 100644 --- a/ci/Jenkinsfile.titan-iac +++ b/ci/Jenkinsfile.titan-iac @@ -22,6 +22,8 @@ spec: environment { PIP_DISABLE_PIP_VERSION_CHECK = '1' PYTHONUNBUFFERED = '1' + SUITE_NAME = 'titan-iac' + PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' } stages { stage('Checkout') { @@ -36,7 +38,11 @@ spec: } stage('Glue tests') { steps { - sh 'pytest -q ci/tests/glue' + sh ''' + set -eu + mkdir -p build + pytest -q ci/tests/glue --junitxml=build/junit-glue.xml + ''' } } stage('Resolve Flux branch') { @@ -73,4 +79,97 @@ spec: } } } + post { + always { + script { + env.QUALITY_STATUS = currentBuild.currentResult == 'SUCCESS' ? 'ok' : 'failed' + } + sh ''' + set -eu + python - <<'PY' +import os +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + +suite = os.getenv("SUITE_NAME", "titan-iac") +status = os.getenv("QUALITY_STATUS", "failed") +gateway = os.getenv("PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091").rstrip("/") +junit_path = Path("build/junit-glue.xml") + +totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} +if junit_path.exists(): + root = ET.parse(junit_path).getroot() + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) if root.tag == "testsuites" else [] + for node in suites: + for key in totals: + raw = node.attrib.get(key) or "0" + try: + totals[key] += int(float(raw)) + except ValueError: + pass + +passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0) + +def read_metrics() -> str: + try: + with urllib.request.urlopen(f"{gateway}/metrics", timeout=10) as resp: + return resp.read().decode("utf-8", errors="replace") + except Exception: + return "" + +def read_counter(text: str, counter_status: str) -> float: + for line in text.splitlines(): + if not line.startswith("platform_quality_gate_runs_total{"): + continue + if 'job="platform-quality-ci"' not in line: + continue + if f'suite="{suite}"' not in line: + continue + if f'status="{counter_status}"' not in line: + continue + parts = line.split() + if len(parts) < 2: + continue + try: + return float(parts[1]) + except ValueError: + return 0.0 + return 0.0 + +metrics = read_metrics() +ok_count = read_counter(metrics, "ok") +failed_count = read_counter(metrics, "failed") +if status == "ok": + ok_count += 1 +else: + failed_count += 1 + +payload = "\n".join( + [ + "# 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="failed"}} {failed_count:.0f}', + "# TYPE titan_iac_quality_gate_tests_total gauge", + f'titan_iac_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}', + f'titan_iac_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}', + f'titan_iac_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}', + f'titan_iac_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}', + ] +) + "\n" + +req = urllib.request.Request( + f"{gateway}/metrics/job/platform-quality-ci/suite/{suite}", + data=payload.encode("utf-8"), + method="POST", + headers={"Content-Type": "text/plain"}, +) +with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status >= 400: + raise RuntimeError(f"push failed: {resp.status}") +PY + ''' + archiveArtifacts artifacts: 'build/junit-glue.xml', allowEmptyArchive: true + } + } } diff --git a/services/jellyfin/oidc/Jenkinsfile b/services/jellyfin/oidc/Jenkinsfile index 6886dc98..7c551100 100644 --- a/services/jellyfin/oidc/Jenkinsfile +++ b/services/jellyfin/oidc/Jenkinsfile @@ -27,6 +27,8 @@ spec: ORAS_VERSION = "1.2.0" DOTNET_CLI_TELEMETRY_OPTOUT = "1" DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" + SUITE_NAME = "jellyfin-oidc-plugin" + PUSHGATEWAY_URL = "http://platform-quality-gateway.monitoring.svc.cluster.local:9091" } stages { stage('Checkout') { @@ -538,6 +540,29 @@ EOF } } } + stage('Quality gate smoke tests') { + steps { + container('dotnet') { + sh ''' + set -euo pipefail + apt-get update + apt-get install -y --no-install-recommends unzip + WORKDIR="$(pwd)/build" + ARTIFACT="${WORKDIR}/artifact/OIDC_Authentication_${PLUGIN_VERSION}-net9.zip" + test -s "${ARTIFACT}" + unzip -l "${ARTIFACT}" > "${WORKDIR}/artifact-list.txt" + grep -q 'JellyfinOIDCPlugin.v2.dll' "${WORKDIR}/artifact-list.txt" + cat > "${WORKDIR}/quality-summary.env" <<'EOF' +tests=1 +passed=1 +failed=0 +errors=0 +skipped=0 +EOF + ''' + } + } + } stage('Push to Harbor') { steps { container('dotnet') { @@ -560,8 +585,99 @@ EOF } post { always { + script { + env.QUALITY_STATUS = currentBuild.currentResult == 'SUCCESS' ? 'ok' : 'failed' + } container('dotnet') { - archiveArtifacts artifacts: 'build/artifact/*.zip', allowEmptyArchive: true + sh ''' + set -euo pipefail + python - <<'PY' +import os +import urllib.request +from pathlib import Path + +suite = os.getenv("SUITE_NAME", "jellyfin-oidc-plugin") +status = os.getenv("QUALITY_STATUS", "failed") +gateway = os.getenv("PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091").rstrip("/") +summary_path = Path("build/quality-summary.env") + +totals = {"tests": 1, "passed": 0, "failed": 1, "errors": 0, "skipped": 0} +if summary_path.exists(): + for raw in summary_path.read_text(encoding="utf-8").splitlines(): + if "=" not in raw: + continue + key, value = raw.split("=", 1) + key = key.strip() + if key in totals: + try: + totals[key] = int(float(value.strip())) + except ValueError: + pass +if status != "ok": + totals["failed"] = max(totals["failed"], 1) + totals["passed"] = 0 + +def read_metrics() -> str: + try: + with urllib.request.urlopen(f"{gateway}/metrics", timeout=10) as resp: + return resp.read().decode("utf-8", errors="replace") + except Exception: + return "" + +def read_counter(text: str, counter_status: str) -> float: + for line in text.splitlines(): + if not line.startswith("platform_quality_gate_runs_total{"): + continue + if 'job="platform-quality-ci"' not in line: + continue + if f'suite="{suite}"' not in line: + continue + if f'status="{counter_status}"' not in line: + continue + parts = line.split() + if len(parts) < 2: + continue + try: + return float(parts[1]) + except ValueError: + return 0.0 + return 0.0 + +metrics = read_metrics() +ok_count = read_counter(metrics, "ok") +failed_count = read_counter(metrics, "failed") +if status == "ok": + ok_count += 1 +else: + failed_count += 1 + +payload = "\n".join( + [ + "# 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="failed"}} {failed_count:.0f}', + "# TYPE jellyfin_oidc_quality_gate_tests_total gauge", + f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="passed"}} {totals["passed"]}', + f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failed"]}', + f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}', + f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}', + ] +) + "\n" + +req = urllib.request.Request( + f"{gateway}/metrics/job/platform-quality-ci/suite/{suite}", + data=payload.encode("utf-8"), + method="POST", + headers={"Content-Type": "text/plain"}, +) +with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status >= 400: + raise RuntimeError(f"push failed: {resp.status}") +PY + ''' + } + container('dotnet') { + archiveArtifacts artifacts: 'build/artifact/*.zip,build/artifact-list.txt,build/quality-summary.env', allowEmptyArchive: true } } } diff --git a/services/jenkins/configmap-jcasc.yaml b/services/jenkins/configmap-jcasc.yaml index d8f11f4c..8409ce3e 100644 --- a/services/jenkins/configmap-jcasc.yaml +++ b/services/jenkins/configmap-jcasc.yaml @@ -167,6 +167,32 @@ data: } } } + pipelineJob('atlasbot') { + properties { + pipelineTriggers { + triggers { + scmTrigger { + scmpoll_spec('H/5 * * * *') + ignorePostCommitHooks(false) + } + } + } + } + definition { + cpsScm { + scm { + git { + remote { + url('https://scm.bstein.dev/bstein/atlasbot.git') + credentials('gitea-pat') + } + branches('*/main') + } + } + scriptPath('Jenkinsfile') + } + } + } pipelineJob('metis') { properties { pipelineTriggers { @@ -238,13 +264,39 @@ data: url('https://scm.bstein.dev/bstein/titan-iac.git') credentials('gitea-pat') } - branches('*/feature/sso-hardening') + branches('*/main') } } scriptPath('services/logging/Jenkinsfile.data-prepper') } } } + pipelineJob('Soteria') { + properties { + pipelineTriggers { + triggers { + scmTrigger { + scmpoll_spec('H/5 * * * *') + ignorePostCommitHooks(false) + } + } + } + } + definition { + cpsScm { + scm { + git { + remote { + url('https://scm.bstein.dev/bstein/soteria.git') + credentials('gitea-pat') + } + branches('*/main') + } + } + scriptPath('Jenkinsfile') + } + } + } multibranchPipelineJob('titan-iac-quality-gate') { branchSources { branchSource { diff --git a/services/logging/Jenkinsfile.data-prepper b/services/logging/Jenkinsfile.data-prepper index 4f7c6a77..cdedc40d 100644 --- a/services/logging/Jenkinsfile.data-prepper +++ b/services/logging/Jenkinsfile.data-prepper @@ -31,6 +31,10 @@ spec: """ } } + environment { + SUITE_NAME = 'data-prepper' + PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' + } parameters { string(name: 'HARBOR_REPO', defaultValue: 'registry.bstein.dev/streaming/data-prepper', description: 'Docker repository for Data Prepper') string(name: 'IMAGE_TAG', defaultValue: '2.8.0', description: 'Image tag to publish') @@ -53,6 +57,7 @@ spec: if [ -z "${HARBOR_REPO:-}" ] || [ "${HARBOR_REPO}" = "registry.bstein.dev/monitoring/data-prepper" ]; then HARBOR_REPO="registry.bstein.dev/streaming/data-prepper" fi + IMAGE_TAG_SAFE="${IMAGE_TAG:-2.8.0}" mkdir -p /kaniko/.docker ref_host="$(echo "${HARBOR_REPO}" | cut -d/ -f1)" auth="$(printf "%s:%s" "${HARBOR_USERNAME}" "${HARBOR_PASSWORD}" | base64 | tr -d '\\n')" @@ -65,7 +70,7 @@ spec: } } EOF - dest_args="--destination ${HARBOR_REPO}:${IMAGE_TAG}" + dest_args="--destination ${HARBOR_REPO}:${IMAGE_TAG_SAFE}" if [ "${PUSH_LATEST}" = "true" ]; then dest_args="${dest_args} --destination ${HARBOR_REPO}:latest" fi @@ -79,5 +84,81 @@ EOF } } } + stage('Quality gate smoke build') { + steps { + container('kaniko') { + sh ''' + set -euo pipefail + /kaniko/executor \ + --context "${WORKSPACE}" \ + --dockerfile "${WORKSPACE}/dockerfiles/Dockerfile.data-prepper" \ + --verbosity info \ + --no-push + ''' + } + } + } + } + post { + success { + container('git') { + sh ''' + set -euo pipefail + apk add --no-cache curl >/dev/null 2>&1 || true + suite="${SUITE_NAME}" + gateway="${PUSHGATEWAY_URL}" + fetch_counter() { + status="$1" + line="$(curl -fsS "${gateway}/metrics" 2>/dev/null | awk -v suite="${suite}" -v status="${status}" ' + /^platform_quality_gate_runs_total\{/ { + if (index($0, "job=\\"platform-quality-ci\\"") && index($0, "suite=\\"" suite "\\"") && index($0, "status=\\"" status "\\"")) { + print $2 + exit + } + } + ' || true)" + [ -n "${line}" ] && printf '%s\n' "${line}" || printf '0\n' + } + ok_count="$(fetch_counter ok)" + failed_count="$(fetch_counter failed)" + ok_count=$((ok_count + 1)) + cat </dev/null +# TYPE platform_quality_gate_runs_total counter +platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok_count} +platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed_count} +METRICS + ''' + } + } + failure { + container('git') { + sh ''' + set -euo pipefail + apk add --no-cache curl >/dev/null 2>&1 || true + suite="${SUITE_NAME}" + gateway="${PUSHGATEWAY_URL}" + fetch_counter() { + status="$1" + line="$(curl -fsS "${gateway}/metrics" 2>/dev/null | awk -v suite="${suite}" -v status="${status}" ' + /^platform_quality_gate_runs_total\{/ { + if (index($0, "job=\\"platform-quality-ci\\"") && index($0, "suite=\\"" suite "\\"") && index($0, "status=\\"" status "\\"")) { + print $2 + exit + } + } + ' || true)" + [ -n "${line}" ] && printf '%s\n' "${line}" || printf '0\n' + } + ok_count="$(fetch_counter ok)" + failed_count="$(fetch_counter failed)" + failed_count=$((failed_count + 1)) + cat </dev/null +# TYPE platform_quality_gate_runs_total counter +platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok_count} +platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed_count} +METRICS + ''' + } + } } }