typhon/Jenkinsfile

566 lines
17 KiB
Plaintext
Raw Normal View History

def isTimerBuild() {
return !currentBuild.getBuildCauses('hudson.triggers.TimerTrigger$TimerTriggerCause').isEmpty()
}
def isScmBuild() {
return !currentBuild.getBuildCauses('hudson.triggers.SCMTrigger$SCMTriggerCause').isEmpty()
}
def shouldPublishImage() {
return params.PUBLISH_IMAGE
}
pipeline {
agent none
options {
disableConcurrentBuilds()
}
parameters {
booleanParam(
name: 'PUBLISH_IMAGE',
defaultValue: false,
description: 'Build and push the Typhon image to Harbor. Regular SCM and timer runs only publish test telemetry.'
)
}
environment {
SUITE_NAME = 'typhon'
PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091'
IMAGE_REPO = 'registry.bstein.dev/bstein/typhon'
}
stages {
stage('Quality and metrics') {
agent {
kubernetes {
defaultContainer 'node'
yaml """
apiVersion: v1
kind: Pod
spec:
nodeSelector:
2026-05-16 16:15:28 -03:00
hardware: rpi5
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
containers:
- name: node
image: node:22-bookworm-slim
command: ['cat']
tty: true
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
volumes:
- name: workspace-volume
emptyDir: {}
"""
}
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Run quality gate') {
steps {
container('node') {
sh '''
set -eu
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates
rm -rf /var/lib/apt/lists/*
2026-05-16 15:31:25 -03:00
set +e
bash <<'GATE'
set -u
npm ci
lint_rc=0
test_rc=0
build_rc=0
npm run lint || lint_rc=$?
npm run test:ci || test_rc=$?
npm run build || build_rc=$?
2026-05-16 15:31:25 -03:00
mkdir -p build
APP_VERSION="$(node -p "require('./package.json').version")"
echo "APP_VERSION=${APP_VERSION}" > build/version.env
export lint_rc test_rc build_rc
2026-05-16 15:31:25 -03:00
node <<'NODE'
const fs = require('fs');
2026-05-16 15:31:25 -03:00
const path = require('path');
const junitPath = 'build/junit-typhon.xml';
const coveragePath = 'coverage/coverage-summary.json';
2026-05-16 15:31:25 -03:00
const sourceRoots = ['src', 'tests', 'scripts'];
const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']);
function unescapeXml(value) {
return String(value || '')
.split('&quot;').join('"')
.split('&apos;').join("'")
.split('&lt;').join('<')
.split('&gt;').join('>')
.split('&amp;').join('&');
}
2026-05-16 15:31:25 -03:00
function attr(attrs, name) {
const needle = `${name}="`;
2026-05-16 16:43:53 -03:00
let start = attrs.indexOf(needle);
while (start > 0) {
const previous = attrs.charCodeAt(start - 1);
if (previous === 32 || previous === 9 || previous === 10 || previous === 13) {
break;
}
start = attrs.indexOf(needle, start + 1);
}
if (start < 0) return '';
const valueStart = start + needle.length;
const valueEnd = attrs.indexOf('"', valueStart);
return valueEnd < 0 ? '' : unescapeXml(attrs.slice(valueStart, valueEnd));
2026-05-16 15:31:25 -03:00
}
function collectSourceFiles(rootDir) {
const files = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
if (!fs.existsSync(current)) {
continue;
}
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (sourceExts.has(path.extname(entry.name))) {
files.push(fullPath);
}
}
}
return files.sort();
}
2026-05-16 15:31:25 -03:00
function categoryForClassname(classname) {
const normalized = String(classname || '').split(String.fromCharCode(92)).join('/');
2026-05-16 15:31:25 -03:00
const relative = normalized.includes('/tests/')
? normalized.slice(normalized.indexOf('/tests/') + '/tests/'.length)
: (normalized.startsWith('tests/') ? normalized.slice('tests/'.length) : normalized);
const parts = relative.split('/').filter(Boolean);
if (parts.length <= 1 || parts[0].endsWith('.test.ts')) {
return 'unit';
}
return parts[0];
}
function parseTestCases(junit) {
const cases = [];
const re = new RegExp('<testcase([^>]*)>(.*?)</testcase>|<testcase([^>]*)/>', 'gs');
2026-05-16 15:31:25 -03:00
let match;
while ((match = re.exec(junit)) !== null) {
const attrs = match[1] || match[3] || '';
const body = match[2] || '';
const classname = attr(attrs, 'classname') || 'typhon';
const title = attr(attrs, 'name') || classname;
const status = body.includes('<failure') || body.includes('<error')
? 'failed'
: body.includes('<skipped')
? 'skipped'
: 'passed';
cases.push({ category: categoryForClassname(classname), test: `${classname}::${title}`, status });
}
return cases;
}
const junit = fs.existsSync(junitPath) ? fs.readFileSync(junitPath, 'utf8') : '';
const tests = Number((junit.match(new RegExp('tests="([0-9]+)"')) || [])[1] || 0);
const failures = Number((junit.match(new RegExp('failures="([0-9]+)"')) || [])[1] || 0);
const errors = Number((junit.match(new RegExp('errors="([0-9]+)"')) || [])[1] || (junit ? 0 : 1));
const skipped = Number((junit.match(new RegExp('skipped="([0-9]+)"')) || [])[1] || 0);
2026-05-16 15:31:25 -03:00
const cov = fs.existsSync(coveragePath) ? JSON.parse(fs.readFileSync(coveragePath, 'utf8')) : { total: {} };
const total = cov.total || {};
2026-05-16 15:31:25 -03:00
const sourceFiles = sourceRoots.flatMap((root) => collectSourceFiles(root));
const overLimitFiles = sourceFiles
.map((file) => ({ file, lines: fs.readFileSync(file, 'utf8').split(String.fromCharCode(10)).length }))
2026-05-16 15:31:25 -03:00
.filter((item) => item.lines > 500);
const report = {
suite: 'typhon',
tests: { tests, failures, errors, skipped },
coverage: {
lines: total.lines?.pct ?? 0,
statements: total.statements?.pct ?? 0,
functions: total.functions?.pct ?? 0,
branches: total.branches?.pct ?? 0
},
thresholds: {
2026-05-16 15:31:25 -03:00
lines: 95,
statements: 95,
functions: 95,
branches: 75
2026-05-16 15:31:25 -03:00
},
hygiene: {
sourceFilesTotal: sourceFiles.length,
sourceLinesOver500: overLimitFiles.length
},
checks: {
tests: failures > 0 || errors > 0 || Number(process.env.test_rc || 0) !== 0 ? 'failed' : 'ok',
coverage: 'ok',
loc: overLimitFiles.length === 0 ? 'ok' : 'failed',
style: Number(process.env.lint_rc || 0) === 0 ? 'ok' : 'failed',
gate_glue: 'ok',
sonarqube: 'not_applicable',
supply_chain: 'not_applicable'
},
testCases: parseTestCases(junit)
};
2026-05-16 15:31:25 -03:00
report.checks.coverage = (
report.coverage.lines >= report.thresholds.lines &&
report.coverage.statements >= report.thresholds.statements &&
report.coverage.functions >= report.thresholds.functions &&
report.coverage.branches >= report.thresholds.branches
) ? 'ok' : 'failed';
if (report.testCases.length === 0) {
report.testCases.push({ category: 'unit', test: '__no_test_cases__', status: junit ? 'skipped' : 'failed' });
}
fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2));
2026-05-16 15:31:25 -03:00
if (
Number(process.env.lint_rc || 0) !== 0 ||
Number(process.env.test_rc || 0) !== 0 ||
Number(process.env.build_rc || 0) !== 0 ||
Object.values(report.checks).includes('failed')
) {
process.exit(1);
}
NODE
2026-05-16 15:31:25 -03:00
GATE
gate_rc=$?
set -e
printf '%s\n' "${gate_rc}" > build/quality-gate.rc
if [ ! -f build/quality-gate.json ]; then
mkdir -p build
cat > build/quality-gate.json <<'JSON'
{
"suite": "typhon",
"tests": {
"tests": 0,
"failures": 1,
"errors": 0,
"skipped": 0
},
"coverage": {
"lines": 0,
"statements": 0,
"functions": 0,
"branches": 0
},
"thresholds": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 75
},
"hygiene": {
"sourceFilesTotal": 0,
"sourceLinesOver500": 0
},
"checks": {
"tests": "failed",
"coverage": "failed",
"loc": "ok",
"style": "failed",
"gate_glue": "failed",
"sonarqube": "not_applicable",
"supply_chain": "not_applicable"
},
"testCases": [
{
"category": "unit",
"test": "__no_test_cases__",
"status": "failed"
}
]
}
JSON
fi
'''
}
}
}
stage('Publish test metrics') {
steps {
container('node') {
sh '''
set -eu
node <<'NODE'
const fs = require('fs');
const { execSync } = require('child_process');
const gateway = process.env.PUSHGATEWAY_URL;
const suite = process.env.SUITE_NAME || 'typhon';
const qualityPath = 'build/quality-gate.json';
if (!fs.existsSync(qualityPath)) {
throw new Error('quality report missing');
}
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
2026-05-16 15:31:25 -03:00
const status = quality.tests.failures > 0 || quality.tests.errors > 0 || Object.values(quality.checks || {}).includes('failed') ? 'failed' : 'ok';
const branch = process.env.BRANCH_NAME || process.env.GIT_BRANCH || 'unknown';
const buildNumber = process.env.BUILD_NUMBER || 'unknown';
const jenkinsJob = process.env.JOB_NAME || suite;
const sourceFilesTotal = Number(quality.hygiene?.sourceFilesTotal ?? 0);
const sourceLinesOver500 = Number(quality.hygiene?.sourceLinesOver500 ?? 0);
function esc(value) {
return String(value ?? '')
.split(String.fromCharCode(92)).join(String.fromCharCode(92, 92))
.split('"').join(String.fromCharCode(92) + '"')
.split(String.fromCharCode(10)).join(String.fromCharCode(92) + 'n');
2026-05-16 15:31:25 -03:00
}
function labelString(labels) {
return `{${Object.entries(labels).map(([key, value]) => `${key}="${esc(value)}"`).join(',')}}`;
}
function fetchCounter(targetStatus) {
try {
const metrics = execSync(`curl -fsS ${gateway}/metrics`, { encoding: 'utf8' });
for (const line of metrics.split(String.fromCharCode(10))) {
if (
line.startsWith('platform_quality_gate_runs_total{') &&
line.includes(`suite="${suite}"`) &&
line.includes(`status="${targetStatus}"`)
) {
return Number(line.trim().split(' ').filter(Boolean).pop() || 0);
}
}
return 0;
} catch {
return 0;
}
}
let ok = fetchCounter('ok');
let failed = fetchCounter('failed');
if (status === 'ok') ok += 1;
if (status === 'failed') failed += 1;
2026-05-16 15:31:25 -03:00
const passed = Math.max(quality.tests.tests - quality.tests.failures - quality.tests.errors - quality.tests.skipped, 0);
const buildLabels = labelString({ suite, branch, build_number: buildNumber, jenkins_job: jenkinsJob });
const checks = {
tests: 'failed',
coverage: 'failed',
loc: 'failed',
style: 'failed',
gate_glue: 'failed',
sonarqube: 'not_applicable',
supply_chain: 'not_applicable',
...(quality.checks || {})
};
const lines = [
'# TYPE platform_quality_gate_runs_total counter',
`platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok}`,
`platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`,
2026-05-16 15:31:25 -03:00
'# TYPE platform_quality_gate_build_info gauge',
`platform_quality_gate_build_info${buildLabels} 1`,
'# TYPE platform_quality_gate_tests_total gauge',
`platform_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`,
`platform_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`,
`platform_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`,
`platform_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
'# TYPE typhon_quality_gate_tests_total gauge',
2026-05-16 15:31:25 -03:00
`typhon_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
'# TYPE typhon_quality_gate_coverage_percent gauge',
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="lines"} ${quality.coverage.lines}`,
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`,
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`,
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`,
2026-05-16 15:31:25 -03:00
'# TYPE platform_quality_gate_workspace_line_coverage_percent gauge',
`platform_quality_gate_workspace_line_coverage_percent{suite="${suite}"} ${quality.coverage.lines}`,
'# TYPE platform_quality_gate_source_files_total gauge',
`platform_quality_gate_source_files_total{suite="${suite}"} ${sourceFilesTotal}`,
'# TYPE platform_quality_gate_source_lines_over_500_total gauge',
`platform_quality_gate_source_lines_over_500_total{suite="${suite}"} ${sourceLinesOver500}`,
'# TYPE typhon_quality_gate_checks_total gauge',
...Object.entries(checks).map(([check, result]) => `typhon_quality_gate_checks_total${labelString({ suite, check, result })} 1`),
'# TYPE platform_quality_gate_test_case_result gauge',
...(quality.testCases || []).map((item) => `platform_quality_gate_test_case_result${labelString({
suite,
branch,
build_number: buildNumber,
jenkins_job: jenkinsJob,
category: item.category || 'unit',
test: item.test || '__no_test_cases__',
status: item.status || 'skipped'
})} 1`),
''
2026-05-16 15:31:25 -03:00
];
2026-05-16 15:31:25 -03:00
try {
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
input: lines.join(String.fromCharCode(10)),
2026-05-16 15:31:25 -03:00
stdio: ['pipe', 'inherit', 'inherit']
});
} catch (error) {
console.error(`metrics publish failed for suite=${suite}:`, error.message);
}
NODE
'''
}
}
}
stage('Enforce quality gate') {
steps {
container('node') {
sh '''
2026-05-16 15:31:25 -03:00
set -eu
test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0
'''
}
}
}
}
post {
always {
archiveArtifacts artifacts: 'build/**,dist/**,coverage/**', allowEmptyArchive: true, fingerprint: true
2026-05-16 15:31:25 -03:00
}
}
}
stage('Build & push image') {
when {
beforeAgent true
expression { return shouldPublishImage() }
}
agent {
kubernetes {
defaultContainer 'docker'
yaml """
apiVersion: v1
kind: Pod
spec:
nodeSelector:
2026-05-16 16:15:28 -03:00
hardware: rpi5
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
containers:
- name: dind
image: docker:27-dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: ""
command:
- /bin/sh
- -c
args:
- |
set -eu
exec dockerd-entrypoint.sh --mtu=1400 --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375
volumeMounts:
- name: dind-storage
mountPath: /var/lib/docker
- name: workspace-volume
mountPath: /home/jenkins/agent
- name: docker
image: docker:27
command: ['cat']
tty: true
env:
- name: DOCKER_HOST
value: tcp://localhost:2375
- name: DOCKER_TLS_CERTDIR
value: ""
volumeMounts:
- name: docker-config-writable
mountPath: /root/.docker
- name: docker-config-secret
mountPath: /docker-config
- name: workspace-volume
mountPath: /home/jenkins/agent
volumes:
- name: docker-config-secret
secret:
secretName: harbor-robot-pipeline
items:
- key: .dockerconfigjson
path: config.json
- name: docker-config-writable
emptyDir: {}
- name: dind-storage
emptyDir: {}
- name: workspace-volume
emptyDir: {}
"""
}
}
steps {
checkout scm
container('docker') {
sh '''
set -eu
mkdir -p build
mkdir -p /root/.docker
cp /docker-config/config.json /root/.docker/config.json
seq 1 20 | while read _; do
docker info >/dev/null 2>&1 && break || sleep 2
done
BUILDER_NAME="typhon-${BUILD_NUMBER}"
docker buildx rm "${BUILDER_NAME}" >/dev/null 2>&1 || true
docker buildx create --name "${BUILDER_NAME}" --driver docker-container --bootstrap --use
APP_VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' package.json | head -n 1)"
SHORT_SHA="$(printf '%s' "${GIT_COMMIT:-nohash}" | cut -c1-8)"
IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}"
{
echo "IMAGE_REPO=${IMAGE_REPO}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_CHANNEL_TAG=main"
} > build/image.env
'''
withCredentials([
usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USERNAME',
passwordVariable: 'HARBOR_PASSWORD'
)
]) {
sh '''
set -eu
. build/image.env
printf '%s' "${HARBOR_PASSWORD}" | docker login registry.bstein.dev -u "${HARBOR_USERNAME}" --password-stdin
2026-04-13 04:32:33 -03:00
docker buildx build --platform linux/arm64 \
--provenance=false \
--tag "${IMAGE_REPO}:${IMAGE_TAG}" \
--tag "${IMAGE_REPO}:main" \
--push .
'''
}
}
}
}
}
}