2026-04-13 01:48:32 -03:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 '''
|
2026-04-13 03:59:28 -03:00
|
|
|
set -eu
|
2026-04-13 01:48:32 -03:00
|
|
|
mkdir -p /root/.docker
|
|
|
|
|
cp /docker-config/config.json /root/.docker/config.json
|
|
|
|
|
'''
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stage('Quality Gate') {
|
|
|
|
|
steps {
|
|
|
|
|
container('node') {
|
|
|
|
|
sh '''
|
2026-04-13 03:59:28 -03:00
|
|
|
set -eu
|
2026-04-13 01:48:32 -03:00
|
|
|
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');
|
2026-04-13 03:47:32 -03:00
|
|
|
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);
|
2026-04-13 01:48:32 -03:00
|
|
|
|
|
|
|
|
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 '''
|
2026-04-13 03:59:28 -03:00
|
|
|
set -eu
|
2026-04-13 01:48:32 -03:00
|
|
|
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}`,
|
|
|
|
|
''
|
2026-04-13 04:03:12 -03:00
|
|
|
].join('\\n');
|
2026-04-13 01:48:32 -03:00
|
|
|
|
|
|
|
|
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 '''
|
2026-04-13 03:59:28 -03:00
|
|
|
set -eu
|
2026-04-13 01:48:32 -03:00
|
|
|
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 '''
|
2026-04-13 03:59:28 -03:00
|
|
|
set -eu
|
2026-04-13 01:48:32 -03:00
|
|
|
. build/version.env
|
2026-04-13 04:07:06 -03:00
|
|
|
SHORT_SHA="$(printf '%s' "${GIT_COMMIT:-nohash}" | cut -c1-8)"
|
2026-04-13 01:48:32 -03:00
|
|
|
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 {
|
|
|
|
|
archiveArtifacts artifacts: 'build/**,dist/**,coverage/**', allowEmptyArchive: true, fingerprint: true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|