Compare commits
2 Commits
main
...
codex/typh
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ea93d9385 | |||
| 9e039e34c5 |
722
Jenkinsfile
vendored
722
Jenkinsfile
vendored
@ -1,466 +1,12 @@
|
|||||||
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 {
|
pipeline {
|
||||||
agent none
|
agent {
|
||||||
|
kubernetes {
|
||||||
options {
|
defaultContainer 'node'
|
||||||
disableConcurrentBuilds()
|
yaml """
|
||||||
}
|
|
||||||
|
|
||||||
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
|
apiVersion: v1
|
||||||
kind: Pod
|
kind: Pod
|
||||||
spec:
|
spec:
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
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/*
|
|
||||||
|
|
||||||
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=$?
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const junitPath = 'build/junit-typhon.xml';
|
|
||||||
const coveragePath = 'coverage/coverage-summary.json';
|
|
||||||
|
|
||||||
const sourceRoots = ['src', 'tests', 'scripts'];
|
|
||||||
const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']);
|
|
||||||
|
|
||||||
function unescapeXml(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.split('"').join('"')
|
|
||||||
.split(''').join("'")
|
|
||||||
.split('<').join('<')
|
|
||||||
.split('>').join('>')
|
|
||||||
.split('&').join('&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function attr(attrs, name) {
|
|
||||||
const needle = `${name}="`;
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryForClassname(classname) {
|
|
||||||
const normalized = String(classname || '').split(String.fromCharCode(92)).join('/');
|
|
||||||
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');
|
|
||||||
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);
|
|
||||||
|
|
||||||
const cov = fs.existsSync(coveragePath) ? JSON.parse(fs.readFileSync(coveragePath, 'utf8')) : { total: {} };
|
|
||||||
const total = cov.total || {};
|
|
||||||
const sourceFiles = sourceRoots.flatMap((root) => collectSourceFiles(root));
|
|
||||||
const overLimitFiles = sourceFiles
|
|
||||||
.map((file) => ({ file, lines: fs.readFileSync(file, 'utf8').split(String.fromCharCode(10)).length }))
|
|
||||||
.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: {
|
|
||||||
lines: 95,
|
|
||||||
statements: 95,
|
|
||||||
functions: 95,
|
|
||||||
branches: 75
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
|
||||||
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
|
|
||||||
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'));
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
maxBuffer: 16 * 1024 * 1024
|
|
||||||
});
|
|
||||||
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;
|
|
||||||
|
|
||||||
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}`,
|
|
||||||
'# 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',
|
|
||||||
`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}`,
|
|
||||||
'# 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`),
|
|
||||||
''
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
|
|
||||||
input: lines.join(String.fromCharCode(10)),
|
|
||||||
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 '''
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Build & push image') {
|
|
||||||
when {
|
|
||||||
beforeAgent true
|
|
||||||
expression { return shouldPublishImage() }
|
|
||||||
}
|
|
||||||
agent {
|
|
||||||
kubernetes {
|
|
||||||
defaultContainer 'docker'
|
|
||||||
yaml """
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
spec:
|
|
||||||
nodeSelector:
|
|
||||||
hardware: rpi5
|
|
||||||
kubernetes.io/arch: arm64
|
kubernetes.io/arch: arm64
|
||||||
node-role.kubernetes.io/worker: "true"
|
node-role.kubernetes.io/worker: "true"
|
||||||
containers:
|
containers:
|
||||||
@ -499,6 +45,13 @@ spec:
|
|||||||
mountPath: /docker-config
|
mountPath: /docker-config
|
||||||
- name: workspace-volume
|
- name: workspace-volume
|
||||||
mountPath: /home/jenkins/agent
|
mountPath: /home/jenkins/agent
|
||||||
|
- name: node
|
||||||
|
image: node:22-bookworm
|
||||||
|
command: ['cat']
|
||||||
|
tty: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: workspace-volume
|
||||||
|
mountPath: /home/jenkins/agent
|
||||||
volumes:
|
volumes:
|
||||||
- name: docker-config-secret
|
- name: docker-config-secret
|
||||||
secret:
|
secret:
|
||||||
@ -513,17 +66,227 @@ spec:
|
|||||||
- name: workspace-volume
|
- name: workspace-volume
|
||||||
emptyDir: {}
|
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 {
|
steps {
|
||||||
checkout scm
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Prep Docker Auth') {
|
||||||
|
steps {
|
||||||
container('docker') {
|
container('docker') {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
mkdir -p build
|
|
||||||
mkdir -p /root/.docker
|
mkdir -p /root/.docker
|
||||||
cp /docker-config/config.json /root/.docker/config.json
|
cp /docker-config/config.json /root/.docker/config.json
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Quality Gate') {
|
||||||
|
steps {
|
||||||
|
container('node') {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
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 path = require('path');
|
||||||
|
|
||||||
|
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 sourceRoots = ['src', 'tests', 'scripts'];
|
||||||
|
const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']);
|
||||||
|
const maxSourceLines = 500;
|
||||||
|
|
||||||
|
function collectOverLimitFiles(rootDir) {
|
||||||
|
const offenders = [];
|
||||||
|
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))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lines = fs.readFileSync(fullPath, 'utf8').split(/\\r?\\n/).length;
|
||||||
|
if (lines > maxSourceLines) {
|
||||||
|
offenders.push({ file: fullPath, lines });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return offenders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overLimitFiles = sourceRoots.flatMap((root) => collectOverLimitFiles(root));
|
||||||
|
if (overLimitFiles.length > 0) {
|
||||||
|
console.error('source files exceed 500 LOC:');
|
||||||
|
for (const item of overLimitFiles) {
|
||||||
|
console.error(`- ${item.file}: ${item.lines}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
hygiene: {
|
||||||
|
sourceLinesOver500: overLimitFiles.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2));
|
||||||
|
NODE
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok';
|
||||||
|
const sourceLinesOver500 = Number(quality.hygiene?.sourceLinesOver500 ?? 0);
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
'# 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_lines_over_500_total gauge',
|
||||||
|
`platform_quality_gate_source_lines_over_500_total{suite="${suite}"} ${sourceLinesOver500}`,
|
||||||
|
''
|
||||||
|
].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 -eu
|
||||||
seq 1 20 | while read _; do
|
seq 1 20 | while read _; do
|
||||||
docker info >/dev/null 2>&1 && break || sleep 2
|
docker info >/dev/null 2>&1 && break || sleep 2
|
||||||
done
|
done
|
||||||
@ -531,17 +294,17 @@ spec:
|
|||||||
BUILDER_NAME="typhon-${BUILD_NUMBER}"
|
BUILDER_NAME="typhon-${BUILD_NUMBER}"
|
||||||
docker buildx rm "${BUILDER_NAME}" >/dev/null 2>&1 || true
|
docker buildx rm "${BUILDER_NAME}" >/dev/null 2>&1 || true
|
||||||
docker buildx create --name "${BUILDER_NAME}" --driver docker-container --bootstrap --use
|
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)"
|
stage('Build & Push Image') {
|
||||||
SHORT_SHA="$(printf '%s' "${GIT_COMMIT:-nohash}" | cut -c1-8)"
|
when {
|
||||||
IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}"
|
expression { return params.PUBLISH_IMAGE }
|
||||||
|
}
|
||||||
{
|
steps {
|
||||||
echo "IMAGE_REPO=${IMAGE_REPO}"
|
container('docker') {
|
||||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
|
||||||
echo "IMAGE_CHANNEL_TAG=main"
|
|
||||||
} > build/image.env
|
|
||||||
'''
|
|
||||||
withCredentials([
|
withCredentials([
|
||||||
usernamePassword(
|
usernamePassword(
|
||||||
credentialsId: 'harbor-robot',
|
credentialsId: 'harbor-robot',
|
||||||
@ -551,7 +314,10 @@ spec:
|
|||||||
]) {
|
]) {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
. build/image.env
|
. build/version.env
|
||||||
|
SHORT_SHA="$(printf '%s' "${GIT_COMMIT:-nohash}" | cut -c1-8)"
|
||||||
|
IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}"
|
||||||
|
|
||||||
printf '%s' "${HARBOR_PASSWORD}" | docker login registry.bstein.dev -u "${HARBOR_USERNAME}" --password-stdin
|
printf '%s' "${HARBOR_PASSWORD}" | docker login registry.bstein.dev -u "${HARBOR_USERNAME}" --password-stdin
|
||||||
|
|
||||||
docker buildx build --platform linux/arm64 \
|
docker buildx build --platform linux/arm64 \
|
||||||
@ -559,10 +325,22 @@ spec:
|
|||||||
--tag "${IMAGE_REPO}:${IMAGE_TAG}" \
|
--tag "${IMAGE_REPO}:${IMAGE_TAG}" \
|
||||||
--tag "${IMAGE_REPO}:main" \
|
--tag "${IMAGE_REPO}:main" \
|
||||||
--push .
|
--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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ module.exports = {
|
|||||||
outputDirectory: "build",
|
outputDirectory: "build",
|
||||||
outputName: "junit-typhon.xml",
|
outputName: "junit-typhon.xml",
|
||||||
suiteName: "typhon",
|
suiteName: "typhon",
|
||||||
classNameTemplate: "{filepath}",
|
classNameTemplate: "{classname}",
|
||||||
titleTemplate: "{title}",
|
titleTemplate: "{title}",
|
||||||
ancestorSeparator: " › "
|
ancestorSeparator: " › "
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,26 +41,17 @@ describe("AppConfig", () => {
|
|||||||
POLL_INTERVAL_SECONDS: "4"
|
POLL_INTERVAL_SECONDS: "4"
|
||||||
})
|
})
|
||||||
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600");
|
).toThrow("POLL_INTERVAL_SECONDS must be between 5 and 600");
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
AppConfig.fromEnv({
|
|
||||||
ACI_EMAIL: "x",
|
|
||||||
ACI_PASSWORD: "y",
|
|
||||||
LISTEN_PORT: "not-a-number"
|
|
||||||
})
|
|
||||||
).toThrow("LISTEN_PORT must be an integer");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports ble mode without cloud credentials", () => {
|
it("supports ble mode without cloud credentials", () => {
|
||||||
const config = AppConfig.fromEnv({
|
const config = AppConfig.fromEnv({
|
||||||
TYPHON_MODE: "ble",
|
TYPHON_MODE: "ble",
|
||||||
ENABLE_CONTROL_API: "yes",
|
ENABLE_CONTROL_API: "true",
|
||||||
LOG_LEVEL: "debug",
|
|
||||||
TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6",
|
TY_BLE_DEFAULT_MAC: "58:8c:81:c6:fc:f6",
|
||||||
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66",
|
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66",
|
||||||
TY_BLE_DEVICE_TYPE: "11",
|
TY_BLE_DEVICE_TYPE: "11",
|
||||||
TY_BLE_SCAN_TIMEOUT_MS: "25000",
|
TY_BLE_SCAN_TIMEOUT_MS: "25000",
|
||||||
TY_BLE_PORT_BASE: "0"
|
TY_BLE_PORT_BASE: "1"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config.mode).toBe("ble");
|
expect(config.mode).toBe("ble");
|
||||||
@ -74,8 +65,7 @@ describe("AppConfig", () => {
|
|||||||
]);
|
]);
|
||||||
expect(config.bleDeviceType).toBe(11);
|
expect(config.bleDeviceType).toBe(11);
|
||||||
expect(config.bleScanTimeoutMs).toBe(25000);
|
expect(config.bleScanTimeoutMs).toBe(25000);
|
||||||
expect(config.blePortBase).toBe(0);
|
expect(config.blePortBase).toBe(1);
|
||||||
expect(config.logLevel).toBe("debug");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates mode and MAC inputs", () => {
|
it("validates mode and MAC inputs", () => {
|
||||||
@ -90,26 +80,9 @@ describe("AppConfig", () => {
|
|||||||
TY_BLE_DEFAULT_MAC: "not-a-mac"
|
TY_BLE_DEFAULT_MAC: "not-a-mac"
|
||||||
})).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF");
|
})).toThrow("TY_BLE_DEFAULT_MAC must be a MAC address like AA:BB:CC:DD:EE:FF");
|
||||||
|
|
||||||
expect(() => AppConfig.fromEnv({
|
|
||||||
TYPHON_MODE: "ble",
|
|
||||||
TY_BLE_ALLOWED_MACS: "11:22:33:44:55:66,not-a-mac"
|
|
||||||
})).toThrow("TY_BLE_ALLOWED_MACS must contain MAC addresses like AA:BB:CC:DD:EE:FF");
|
|
||||||
|
|
||||||
expect(() => AppConfig.fromEnv({
|
expect(() => AppConfig.fromEnv({
|
||||||
TYPHON_MODE: "ble",
|
TYPHON_MODE: "ble",
|
||||||
TY_BLE_PORT_BASE: "2"
|
TY_BLE_PORT_BASE: "2"
|
||||||
})).toThrow("TY_BLE_PORT_BASE must be 0 or 1");
|
})).toThrow("TY_BLE_PORT_BASE must be 0 or 1");
|
||||||
|
|
||||||
expect(() => AppConfig.fromEnv({
|
|
||||||
ACI_EMAIL: "x",
|
|
||||||
ACI_PASSWORD: "y",
|
|
||||||
ENABLE_CONTROL_API: "maybe"
|
|
||||||
})).toThrow("ENABLE_CONTROL_API must be a boolean");
|
|
||||||
|
|
||||||
expect(() => AppConfig.fromEnv({
|
|
||||||
ACI_EMAIL: "x",
|
|
||||||
ACI_PASSWORD: "y",
|
|
||||||
LOG_LEVEL: "loud"
|
|
||||||
})).toThrow("LOG_LEVEL must be one of: debug, info, warn, error");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -177,17 +177,4 @@ describe("TyphonMetrics", () => {
|
|||||||
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(90);
|
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(90);
|
||||||
expect(valueForMetric(json, "typhon_last_successful_poll_timestamp_seconds", {})).toBe(1_700_000_000);
|
expect(valueForMetric(json, "typhon_last_successful_poll_timestamp_seconds", {})).toBe(1_700_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps data age at zero before the first successful poll and reports runtime mode", async () => {
|
|
||||||
const registry = new Registry();
|
|
||||||
const metrics = new TyphonMetrics("0.1.0", false, registry);
|
|
||||||
|
|
||||||
metrics.refreshDataAgeGauge(1_700_000_090);
|
|
||||||
metrics.setRuntimeMode("ble");
|
|
||||||
|
|
||||||
const json = await registry.getMetricsAsJSON();
|
|
||||||
expect(valueForMetric(json, "typhon_data_age_seconds", {})).toBe(0);
|
|
||||||
expect(valueForMetric(json, "typhon_runtime_mode", { mode: "cloud" })).toBe(0);
|
|
||||||
expect(valueForMetric(json, "typhon_runtime_mode", { mode: "ble" })).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user