ci: publish typhon quality telemetry

This commit is contained in:
Brad Stein 2026-05-16 15:31:25 -03:00
parent 3f08b6428f
commit b094123518
4 changed files with 300 additions and 40 deletions

278
Jenkinsfile vendored
View File

@ -106,43 +106,116 @@ spec:
} }
} }
stage('Quality Gate') { stage('Run quality gate') {
steps { steps {
container('node') { container('node') {
sh ''' sh '''
set -eu set -eu
set +e
bash <<'GATE'
set -u
npm ci npm ci
npm run lint lint_rc=0
npm run test:ci test_rc=0
npm run build build_rc=0
npm run lint || lint_rc=$?
npm run test:ci || test_rc=$?
npm run build || build_rc=$?
mkdir -p build mkdir -p build
APP_VERSION="$(node -p \"require('./package.json').version\")" APP_VERSION="$(node -p "require('./package.json').version")"
echo "APP_VERSION=${APP_VERSION}" > build/version.env echo "APP_VERSION=${APP_VERSION}" > build/version.env
export lint_rc test_rc build_rc
node <<'NODE' node <<'NODE'
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const junitPath = 'build/junit-typhon.xml'; const junitPath = 'build/junit-typhon.xml';
const coveragePath = 'coverage/coverage-summary.json'; const coveragePath = 'coverage/coverage-summary.json';
if (!fs.existsSync(junitPath)) { const sourceRoots = ['src', 'tests', 'scripts'];
console.error('missing junit report:', junitPath); const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']);
process.exit(1);
} function unescapeXml(value) {
if (!fs.existsSync(coveragePath)) { return String(value || '')
console.error('missing coverage summary:', coveragePath); .replace(/&quot;/g, '"')
process.exit(1); .replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
} }
const junit = fs.readFileSync(junitPath, 'utf8'); function attr(attrs, name) {
const match = attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`));
return match ? unescapeXml(match[1]) : '';
}
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 || '').replace(/\\/g, '/');
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 = /<testcase\b([^>]*)>([\s\S]*?)<\/testcase>|<testcase\b([^>]*)\/>/g;
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(/tests="([0-9]+)"/) || [])[1] || 0); const tests = Number((junit.match(/tests="([0-9]+)"/) || [])[1] || 0);
const failures = Number((junit.match(/failures="([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 errors = Number((junit.match(/errors="([0-9]+)"/) || [])[1] || (junit ? 0 : 1));
const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0); const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0);
const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8')); const cov = fs.existsSync(coveragePath) ? JSON.parse(fs.readFileSync(coveragePath, 'utf8')) : { total: {} };
const total = cov.total || {}; const total = cov.total || {};
const sourceFiles = sourceRoots.flatMap((root) => collectSourceFiles(root));
const overLimitFiles = sourceFiles
.map((file) => ({ file, lines: fs.readFileSync(file, 'utf8').split(/\r?\n/).length }))
.filter((item) => item.lines > 500);
const report = { const report = {
suite: 'typhon', suite: 'typhon',
@ -154,21 +227,103 @@ const report = {
branches: total.branches?.pct ?? 0 branches: total.branches?.pct ?? 0
}, },
thresholds: { thresholds: {
lines: 85, lines: 95,
statements: 85, statements: 95,
functions: 85, functions: 95,
branches: 75 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)); 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 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') { stage('Publish test metrics') {
steps { steps {
container('node') { container('node') {
sh ''' sh '''
@ -186,7 +341,20 @@ if (!fs.existsSync(qualityPath)) {
} }
const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8')); const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8'));
const status = quality.tests.failures > 0 || quality.tests.errors > 0 ? 'failed' : 'ok'; 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 ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
function labelString(labels) {
return `{${Object.entries(labels).map(([key, value]) => `${key}="${esc(value)}"`).join(',')}}`;
}
function fetchCounter(targetStatus) { function fetchCounter(targetStatus) {
try { try {
@ -205,34 +373,86 @@ let failed = fetchCounter('failed');
if (status === 'ok') ok += 1; if (status === 'ok') ok += 1;
if (status === 'failed') failed += 1; if (status === 'failed') failed += 1;
const payload = [ 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', '# 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="ok"} ${ok}`,
`platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`, `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', '# 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="passed"} ${passed}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="failures"} ${quality.tests.failures}`, `typhon_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="errors"} ${quality.tests.errors}`, `typhon_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`,
`typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`, `typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`,
'# TYPE typhon_quality_gate_coverage_percent gauge', '# 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="lines"} ${quality.coverage.lines}`,
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`, `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="functions"} ${quality.coverage.functions}`,
`typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`, `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`),
'' ''
].join('\\n'); ];
try {
execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, { execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, {
input: payload, input: lines.join('\\n'),
stdio: ['pipe', 'inherit', 'inherit'] stdio: ['pipe', 'inherit', 'inherit']
}); });
} catch (error) {
console.error(`metrics publish failed for suite=${suite}:`, error.message);
}
NODE NODE
''' '''
} }
} }
} }
stage('Buildx Setup') { stage('Enforce quality gate') {
steps {
container('node') {
sh '''
set -eu
test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0
'''
}
}
}
stage('Buildx setup') {
when { when {
expression { return params.PUBLISH_IMAGE } expression { return params.PUBLISH_IMAGE }
} }
@ -252,7 +472,7 @@ NODE
} }
} }
stage('Build & Push Image') { stage('Build & push image') {
when { when {
expression { return params.PUBLISH_IMAGE } expression { return params.PUBLISH_IMAGE }
} }

View File

@ -23,7 +23,7 @@ module.exports = {
outputDirectory: "build", outputDirectory: "build",
outputName: "junit-typhon.xml", outputName: "junit-typhon.xml",
suiteName: "typhon", suiteName: "typhon",
classNameTemplate: "{classname}", classNameTemplate: "{filepath}",
titleTemplate: "{title}", titleTemplate: "{title}",
ancestorSeparator: " " ancestorSeparator: " "
} }

View File

@ -41,17 +41,26 @@ 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: "true", ENABLE_CONTROL_API: "yes",
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: "1" TY_BLE_PORT_BASE: "0"
}); });
expect(config.mode).toBe("ble"); expect(config.mode).toBe("ble");
@ -65,7 +74,8 @@ 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(1); expect(config.blePortBase).toBe(0);
expect(config.logLevel).toBe("debug");
}); });
it("validates mode and MAC inputs", () => { it("validates mode and MAC inputs", () => {
@ -80,9 +90,26 @@ 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");
}); });
}); });

View File

@ -177,4 +177,17 @@ 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);
});
}); });