typhon/Jenkinsfile

306 lines
8.6 KiB
Groovy

pipeline {
agent {
kubernetes {
defaultContainer 'node'
yaml """
apiVersion: v1
kind: Pod
spec:
nodeSelector:
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
- name: node
image: node:22-bookworm
command: ['cat']
tty: true
volumeMounts:
- 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: {}
"""
}
}
options {
disableConcurrentBuilds()
timestamps()
}
parameters {
booleanParam(
name: 'PUBLISH_IMAGE',
defaultValue: true,
description: 'Build and push typhon image to Harbor on successful quality gate.'
)
}
environment {
SUITE_NAME = 'typhon'
PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091'
IMAGE_REPO = 'registry.bstein.dev/bstein/typhon'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Prep Docker Auth') {
steps {
container('docker') {
sh '''
set -euo pipefail
mkdir -p /root/.docker
cp /docker-config/config.json /root/.docker/config.json
'''
}
}
}
stage('Quality Gate') {
steps {
container('node') {
sh '''
set -euo pipefail
npm ci
npm run lint
npm run test:ci
npm run build
mkdir -p build
APP_VERSION="$(node -p \"require('./package.json').version\")"
echo "APP_VERSION=${APP_VERSION}" > build/version.env
node <<'NODE'
const fs = require('fs');
const junitPath = 'build/junit-typhon.xml';
const coveragePath = 'coverage/coverage-summary.json';
if (!fs.existsSync(junitPath)) {
console.error('missing junit report:', junitPath);
process.exit(1);
}
if (!fs.existsSync(coveragePath)) {
console.error('missing coverage summary:', coveragePath);
process.exit(1);
}
const junit = fs.readFileSync(junitPath, 'utf8');
const tests = Number((junit.match(/tests="([0-9]+)"/) || [])[1] || 0);
const failures = Number((junit.match(/failures="([0-9]+)"/) || [])[1] || 0);
const errors = Number((junit.match(/errors="([0-9]+)"/) || [])[1] || 0);
const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0);
const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
const total = cov.total || {};
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: {
lines: 85,
statements: 85,
functions: 85,
branches: 75
}
};
fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2));
NODE
'''
}
}
}
stage('Publish Test Metrics') {
steps {
container('node') {
sh '''
set -euo pipefail
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'));
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok';
function fetchCounter(targetStatus) {
try {
const metrics = execSync(`curl -fsS ${gateway}/metrics`, { encoding: 'utf8' });
const re = new RegExp(`platform_quality_gate_runs_total\\{[^}]*suite=\\"${suite}\\"[^}]*status=\\"${targetStatus}\\"[^}]*\\}\\s+(\\d+(?:\\.\\d+)?)`);
const match = metrics.match(re);
if (!match) return 0;
return Number(match[1]);
} catch {
return 0;
}
}
let ok = fetchCounter('ok');
let failed = fetchCounter('failed');
if (status === 'ok') ok += 1;
if (status === 'failed') failed += 1;
const payload = [
'# 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}`,
'# TYPE typhon_quality_gate_tests_total gauge',
`typhon_quality_gate_tests_total{suite="${suite}",result="total"} ${quality.tests.tests}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="failures"} ${quality.tests.failures}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="errors"} ${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}`,
''
].join('\n');
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
input: payload,
stdio: ['pipe', 'inherit', 'inherit']
});
NODE
'''
}
}
}
stage('Buildx Setup') {
when {
expression { return params.PUBLISH_IMAGE }
}
steps {
container('docker') {
sh '''
set -euo pipefail
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
'''
}
}
}
stage('Build & Push Image') {
when {
expression { return params.PUBLISH_IMAGE }
}
steps {
container('docker') {
withCredentials([
usernamePassword(
credentialsId: 'harbor-robot',
usernameVariable: 'HARBOR_USERNAME',
passwordVariable: 'HARBOR_PASSWORD'
)
]) {
sh '''
set -euo pipefail
. build/version.env
SHORT_SHA="$(git rev-parse --short HEAD)"
IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}"
printf '%s' "${HARBOR_PASSWORD}" | docker login registry.bstein.dev -u "${HARBOR_USERNAME}" --password-stdin
docker buildx build --platform linux/arm64 \
--provenance=false \
--tag "${IMAGE_REPO}:${IMAGE_TAG}" \
--tag "${IMAGE_REPO}:main" \
--push .
{
echo "IMAGE_REPO=${IMAGE_REPO}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_CHANNEL_TAG=main"
} > build/image.env
'''
}
}
}
}
}
post {
always {
script {
if (fileExists('build/junit-typhon.xml')) {
junit allowEmptyResults: true, testResults: 'build/junit-typhon.xml'
}
}
archiveArtifacts artifacts: 'build/**,dist/**,coverage/**', allowEmptyArchive: true, fingerprint: true
}
}
}