diff --git a/Jenkinsfile b/Jenkinsfile index 0586170a3c1c4cd2e2a088fc122e283493224baf..5e26e17e3dfae16ae42edd0abb93729c627fe8ca 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,9 +1,11 @@ #!/usr/bin/env groovy +def ciResults = [:] + pipeline { agent { docker { - image 'yeanwang/x-kernel-builder:v1.2' + image 'yeanwang/x-kernel-builder:v1.3' args '-v /var/run/docker.sock:/var/run/docker.sock -v /var/jenkins_home/cargo/registry:/usr/local/cargo/registry --privileged -u root:root' } } @@ -35,24 +37,48 @@ pipeline { stage('Rustfmt') { steps { - script { - runRustfmt() + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + runRustfmt() + ciResults['Rustfmt'] = [status: 'passed'] + } + } + } + post { + failure { + script { ciResults['Rustfmt'] = [status: 'failed', detail: 'cargo fmt --check 发现格式问题'] } } } } stage('Clippy: x86_64') { steps { - script { - runClippy('x86_64') + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + runClippy('x86_64') + ciResults['Clippy: x86_64'] = [status: 'passed'] + } + } + } + post { + failure { + script { ciResults['Clippy: x86_64'] = [status: 'failed', detail: 'cargo clippy 发现 lint 警告/错误'] } } } } stage('Clippy: aarch64') { steps { - script { - runClippy('aarch64') + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + runClippy('aarch64') + ciResults['Clippy: aarch64'] = [status: 'passed'] + } + } + } + post { + failure { + script { ciResults['Clippy: aarch64'] = [status: 'failed', detail: 'cargo clippy 发现 lint 警告/错误'] } } } } @@ -62,9 +88,15 @@ pipeline { catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { script { runBuildOnly('x86-csv') + ciResults['Build: x86-csv'] = [status: 'passed'] } } } + post { + failure { + script { ciResults['Build: x86-csv'] = [status: 'failed', detail: 'make build 编译失败'] } + } + } } @@ -74,9 +106,15 @@ pipeline { catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { script { executeBuildAndTest('x86_64') + ciResults['Runtime: x86_64'] = [status: 'passed'] } } } + post { + failure { + script { ciResults['Runtime: x86_64'] = [status: 'failed', detail: collectUnitTestSnippet('x86_64')] } + } + } } stage('Runtime Validation: aarch64') { @@ -84,10 +122,17 @@ pipeline { catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { script { executeBuildAndTest('aarch64') + ciResults['Runtime: aarch64'] = [status: 'passed'] } } } + post { + failure { + script { ciResults['Runtime: aarch64'] = [status: 'failed', detail: collectUnitTestSnippet('aarch64')] } + } + } } + } post { @@ -95,25 +140,18 @@ pipeline { archiveArtifacts artifacts: '**/artifacts/**/*', allowEmptyArchive: true archiveArtifacts artifacts: '**/logs/**/*', allowEmptyArchive: true archiveArtifacts artifacts: '**/unittest-output.log', allowEmptyArchive: true + archiveArtifacts artifacts: '**/coverage-html/**/*', allowEmptyArchive: true + archiveArtifacts artifacts: '**/coverage.info', allowEmptyArchive: true + archiveArtifacts artifacts: '**/coverage.xml', allowEmptyArchive: true + archiveArtifacts artifacts: '**/coverage.txt', allowEmptyArchive: true script { + def coverageSummary = collectCoverageSummary() + def comment = buildCiComment(ciResults, coverageSummary) + notifyGiteePullRequest(comment) fixWorkspaceOwnership(env.WORKSPACE) } cleanWs deleteDirs: true, disableDeferredWipeout: true, notFailBuild: true } - success { - script { - currentBuild.description = 'Jenkins CI passed' - echo 'Jenkins CI passed' - notifyGiteePullRequest("✅ Jenkins CI 构建成功\n\n- Job: ${env.JOB_NAME}\n- Build: #${env.BUILD_NUMBER}\n- URL: ${env.BUILD_URL}") - } - } - unsuccessful { - script { - currentBuild.description = 'Jenkins CI failed' - echo 'Jenkins CI failed' - notifyGiteePullRequest("❌ Jenkins CI 构建失败\n\n- Job: ${env.JOB_NAME}\n- Build: #${env.BUILD_NUMBER}\n- URL: ${env.BUILD_URL}") - } - } } } @@ -184,6 +222,7 @@ def executeBuildAndTest(arch) { echo "Verifying architecture: ${arch}" runUnitTests(arch) + generateCoverageHtml(arch) dir('test-harness') { git branch: "${env.TEST_HARNESS_BRANCH}", @@ -259,6 +298,24 @@ exit 1 """ } +def generateCoverageHtml(String arch) { + def triple = targetTripleFor(arch) + def covInfo = "target/${triple}/release/coverage.info" + def htmlOut = "target/${triple}/release/coverage-html" + sh """#!/bin/bash +set -euo pipefail +if [ ! -f "${covInfo}" ]; then + echo "No coverage.info found, skipping HTML report" + exit 0 +fi +if ! command -v genhtml &>/dev/null; then + apt-get update -qq && apt-get install -y -qq lcov >/dev/null 2>&1 +fi +genhtml "${covInfo}" --output-directory "${htmlOut}" --title "x-kernel coverage (${arch})" +echo "HTML coverage report generated at ${htmlOut}/" +""" +} + def prepareSource() { ws("${WORKSPACE}/source-cache") { def sourceWorkspace = pwd() @@ -415,3 +472,131 @@ rustup target add x86_64-unknown-linux-musl || true error("Unsupported architecture: ${arch}") } } + +def targetTripleFor(String arch) { + switch (arch) { + case 'aarch64': + return 'aarch64-unknown-none-softfloat' + case 'x86_64': + return 'x86_64-unknown-none' + default: + error("Unsupported architecture: ${arch}") + } +} + +def collectUnitTestSnippet(String arch) { + try { + def logFile = "${WORKSPACE}/${arch}/unittest-output.log" + if (!fileExists(logFile)) { + return '未找到 unittest-output.log,阶段可能在日志创建前失败,请查看 Jenkins Stages 详情。' + } + def log = readFile(logFile) + def lines = log.split('\n') + def keywords = [ + 'panicked at', + 'TESTS_FAILED', + 'error[E', + 'error:', + 'could not compile', + 'make: ***', + 'Unit test command exited with status' + ] + for (keyword in keywords) { + for (int i = 0; i < lines.size(); i++) { + if (lines[i].contains(keyword)) { + def from = Math.max(0, i - 3) + def to = Math.min(lines.size() - 1, i + 8) + return lines[from..to].join('\n').trim() + } + } + } + return lines.size() > 0 + ? lines[Math.max(0, lines.size() - 20).. + try { + def triple = targetTripleFor(arch) + def covFile = "${WORKSPACE}/${arch}/target/${triple}/release/coverage.txt" + if (fileExists(covFile)) { + def content = readFile(covFile) + def lines = content.split('\n') + def totalLine = lines.find { it.contains('TOTAL') } + if (totalLine) { + def cols = totalLine.trim().split(/\s+/) + if (cols.size() >= 10) { + rows.add("| ${arch} | ${cols[9]} | ${cols[6]} | ${cols[3]} | ${cols[7]} | ${cols[8]} |") + } + } + } + } catch (e) { + // skip + } + } + if (rows.isEmpty()) return '' + def header = "| 架构 | 行覆盖率 | 函数覆盖率 | 区域覆盖率 | 总行数 | 未覆盖行 |\n|------|---------|-----------|-----------|--------|---------|" + return header + '\n' + rows.join('\n') +} + +def buildCiComment(Map results, String coverageSummary = '') { + def stagesUrl = "${env.BUILD_URL}stages/" + def stageOrder = [ + 'Rustfmt', 'Clippy: x86_64', 'Clippy: aarch64', + 'Build: x86-csv', 'Runtime: x86_64', 'Runtime: aarch64' + ] + def normalizedResults = [:] + stageOrder.each { name -> + normalizedResults[name] = results[name] ?: [ + status: 'not_run', + detail: '该阶段未执行,通常是前序阶段失败导致。请查看 Jenkins Stages 详情。' + ] + } + def allPassed = currentBuild.currentResult == 'SUCCESS' && + stageOrder.every { normalizedResults[it].status == 'passed' } + def header = allPassed + ? "## ✅ Jenkins CI 构建成功" + : "## ❌ Jenkins CI 构建失败" + + def rows = stageOrder.collect { name -> + def r = normalizedResults[name] + def icon = r.status == 'passed' ? '✅' : (r.status == 'not_run' ? '⏭' : '❌') + "| ${name} | ${icon} |" + }.join('\n') + + def table = """\ +${header} + +| 阶段 | 状态 | +|------|------| +${rows} + + [查看详细日志 (Jenkins Stages)](${stagesUrl}) +- Job: `${env.JOB_NAME}` Build: `#${env.BUILD_NUMBER}`""" + + def coverageBlock = '' + if (coverageSummary?.trim()) { + def baseUrl = "${env.BUILD_URL}artifact" + def links = ['x86_64', 'aarch64'].collect { arch -> + def triple = (arch == 'aarch64') ? 'aarch64-unknown-none-softfloat' : 'x86_64-unknown-none' + "[${arch} HTML 报告](${baseUrl}/${arch}/target/${triple}/release/coverage-html/index.html)" + }.join(' | ') + coverageBlock = "\n### 📊 代码覆盖率\n\n${coverageSummary}\n\n${links}\n" + } + + def errorBlocks = stageOrder.findAll { name -> + normalizedResults[name].status != 'passed' && normalizedResults[name].detail?.trim() + }.collect { name -> + def detail = normalizedResults[name].detail.take(1000) + "\n### ❌ ${name}\n\n
\n查看错误详情\n\n" + + '```' + "\n${detail}\n" + '```' + "\n
" + }.join('\n') + + def body = table + coverageBlock + return errorBlocks ? "${body}\n${errorBlocks}" : body +} \ No newline at end of file diff --git a/Jenkinsfile.tee-test b/Jenkinsfile.tee-test new file mode 100644 index 0000000000000000000000000000000000000000..cf3518ad96b78fe565f6ee1dbd15303dbe29c961 --- /dev/null +++ b/Jenkinsfile.tee-test @@ -0,0 +1,337 @@ +#!/usr/bin/env groovy + +def teeResults = [:] + +pipeline { + agent { + docker { + image 'yeanwang/x-kernel-builder:v1.3' + args '-v /var/run/docker.sock:/var/run/docker.sock -v /var/jenkins_home/cargo/registry:/usr/local/cargo/registry --privileged -u root:root' + } + } + + options { + skipDefaultCheckout(true) + disableConcurrentBuilds() + timestamps() + } + + environment { + CI = 'true' + PROJECT_REPO = 'https://gitee.com/openkylin/x-kernel' + LIBUTEE_REPO = 'https://gitee.com/openkylin/rust-libutee' + DEFAULT_BRANCH = 'main' + CARGO_TERM_COLOR = 'always' + } + + stages { + stage('Prepare Source') { + steps { + script { + prepareSource() + } + } + } + + stage('TEE Storage Test: x86_64') { + steps { + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + teeResults['x86_64'] = runTeeStorageTest('x86_64') + } + } + } + post { + failure { + script { + if (!teeResults.containsKey('x86_64')) { + teeResults['x86_64'] = [arch: 'x86_64', passed: 0, failed: 0, status: 'failed', errorSnippet: '构建或启动阶段失败,请查看 Jenkins 日志'] + } + } + } + } + } + + stage('TEE Storage Test: aarch64') { + steps { + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + teeResults['aarch64'] = runTeeStorageTest('aarch64') + } + } + } + post { + failure { + script { + if (!teeResults.containsKey('aarch64')) { + teeResults['aarch64'] = [arch: 'aarch64', passed: 0, failed: 0, status: 'failed', errorSnippet: '构建或启动阶段失败,请查看 Jenkins 日志'] + } + } + } + } + } + } + + post { + always { + archiveArtifacts artifacts: '**/tee-test-output.log', allowEmptyArchive: true + script { + def comment = buildTeeComment(teeResults) + notifyGiteePullRequest(comment) + fixWorkspaceOwnership(env.WORKSPACE) + } + cleanWs deleteDirs: true, disableDeferredWipeout: true, notFailBuild: true + } + } +} + +def runTeeStorageTest(String arch) { + def result = [arch: arch, passed: 0, failed: 0, status: 'unknown', errorSnippet: ''] + def muslTarget = "${arch}-unknown-linux-musl" + def muslLinker = "${arch}-linux-musl-gcc" + def targetUpper = muslTarget.toUpperCase().replaceAll('-', '_') + + ws("${WORKSPACE}/tee-test-${arch}") { + def stageWorkspace = pwd() + fixWorkspaceOwnership(stageWorkspace) + try { + deleteDir() + restoreSource() + + sh """#!/bin/bash +set -euo pipefail + +${runtimeTargetSetupFor(arch)} +rustup +nightly-2026-03-08 target add ${muslTarget} || true + +LIBUTEE_DIR=\$(mktemp -d) +trap 'rm -rf "\${LIBUTEE_DIR}"' EXIT + +echo "==> Cloning rust-libutee..." +git clone --depth 1 ${env.LIBUTEE_REPO} "\${LIBUTEE_DIR}" +git config --global --add safe.directory "\${LIBUTEE_DIR}" + +echo "==> Building storage_test for ${muslTarget}..." +( cd "\${LIBUTEE_DIR}" && CC=${muslLinker} cargo +nightly-2026-03-08 build --bin storage_test --release --target ${muslTarget} ) + +echo "==> Building tee_apps/sh with TEE_INIT_APPS=/tee/storage_test..." +TEE_INIT_APPS="/tee/storage_test" RUSTFLAGS= CC=${muslLinker} \\ + CARGO_TARGET_${targetUpper}_LINKER=${muslLinker} \\ + cargo build --release --target ${muslTarget} --manifest-path tee_apps/sh/Cargo.toml + +echo "==> Creating rootfs..." +env -u CARGO_BUILD_TARGET RUSTFLAGS= cargo run -p crate_rootfs --release -- \\ + --image disk.img --size-bytes 64M \\ + --copy target/${muslTarget}/release/sh:/bin/sh \\ + --copy "\${LIBUTEE_DIR}/target/${muslTarget}/release/storage_test":/tee/storage_test + +echo "==> Building kernel..." +cp ${defconfigFor(arch)} .config +make build + +echo "==> Running TEE storage test..." +set +e +timeout 300 stdbuf -oL -eL make justrun 2>&1 | tee tee-test-output.log +QEMU_STATUS=\${PIPESTATUS[0]} +set -e + +if [ "\${QEMU_STATUS}" -eq 124 ]; then + echo "TEE_RESULT: TIMEOUT" +elif [ "\${QEMU_STATUS}" -ne 0 ]; then + echo "TEE_RESULT: QEMU_ERROR(\${QEMU_STATUS})" +fi +""" + + def logText = readFile("${stageWorkspace}/tee-test-output.log") + result.passed = logText.split('<<< test success', -1).length - 1 + result.failed = logText.split('<<< test failed', -1).length - 1 + + if (logText.contains('TEE_RESULT: TIMEOUT')) { + result.status = 'timeout' + result.errorSnippet = "QEMU 运行超时(300s),测试未能完成\n通过: ${result.passed},失败: ${result.failed}" + } else if (logText.contains('TEE_RESULT: QEMU_ERROR')) { + result.status = 'failed' + result.errorSnippet = extractSnippet(logText, 'TEE_RESULT: QEMU_ERROR', 5) + } else if (logText.contains('panicked at')) { + result.status = 'panic' + result.errorSnippet = extractSnippet(logText, 'panicked at', 8) + } else if (result.failed > 0) { + result.status = 'failed' + result.errorSnippet = extractSnippet(logText, '<<< test failed', 5) + } else if (result.passed > 0) { + result.status = 'passed' + } else { + result.status = 'no_output' + result.errorSnippet = '未检测到任何测试输出,QEMU 可能未正常启动' + } + + if (result.status != 'passed') { + error("TEE Storage Test ${arch}: ${result.status} (passed=${result.passed}, failed=${result.failed})") + } + + } finally { + fixWorkspaceOwnership(stageWorkspace) + } + } + + return result +} + +def extractSnippet(String log, String keyword, int contextLines) { + def lines = log.split('\n') + def snippetLines = [] + for (int i = 0; i < lines.size(); i++) { + if (lines[i].contains(keyword)) { + def from = Math.max(0, i - 2) + def to = Math.min(lines.size() - 1, i + contextLines) + for (int j = from; j <= to; j++) { + snippetLines << lines[j] + } + if (snippetLines.size() >= 30) break + } + } + return snippetLines.take(30).join('\n') +} + +def buildTeeComment(Map results) { + def stagesUrl = "${env.BUILD_URL}stages/" + + // 确保两个架构都有结果记录,没有的补充为失败 + ['x86_64', 'aarch64'].each { arch -> + if (!results.containsKey(arch)) { + results[arch] = [arch: arch, passed: 0, failed: 0, status: 'failed', errorSnippet: '未执行或提前失败,请查看 Jenkins 日志'] + } + } + + def allPassed = results.values().every { it.status == 'passed' } + def header = allPassed + ? "## ✅ TEE 功能测试通过" + : "## ❌ TEE 功能测试失败" + + def rows = ['x86_64', 'aarch64'].collect { arch -> + def r = results[arch] + def total = r.passed + r.failed + def statusIcon = r.status == 'passed' ? '✅' : (r.status == 'panic' ? '💥' : (r.status == 'timeout' ? '⏱' : '❌')) + "| ${arch} | ${r.passed} | ${r.failed} | ${total} | ${statusIcon} |" + }.join('\n') + + def table = """\ +${header} + +| 架构 | 通过 | 失败 | 合计 | 状态 | +|------|------|------|------|------| +${rows} + +🔗 [查看详细日志 (Jenkins Stages)](${stagesUrl}) +- Job: `${env.JOB_NAME}` Build: `#${env.BUILD_NUMBER}`""" + + def errorBlocks = ['x86_64', 'aarch64'] + .findAll { arch -> results[arch]?.status != 'passed' && results[arch]?.errorSnippet?.trim() } + .collect { arch -> + def r = results[arch] + def label = r.status == 'panic' ? '💥 Panic' : (r.status == 'timeout' ? '⏱ 超时' : '❌ 失败') + "\n### ${label} — ${arch}\n\n" + '```' + "\n${r.errorSnippet.take(800)}\n" + '```' + }.join('\n') + + return errorBlocks ? "${table}\n${errorBlocks}" : table +} + +def prepareSource() { + ws("${WORKSPACE}/source-cache") { + def sourceWorkspace = pwd() + fixWorkspaceOwnership(sourceWorkspace) + try { + deleteDir() + checkoutProject() + markSafeDirectory() + stash name: 'x-kernel-source', includes: '**', useDefaultExcludes: false + } finally { + fixWorkspaceOwnership(sourceWorkspace) + } + } +} + +def restoreSource() { + unstash 'x-kernel-source' + markSafeDirectory() +} + +def checkoutProject() { + if (env.giteePullRequestIid?.trim()) { + checkout scm + return + } + + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${env.DEFAULT_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [], + userRemoteConfigs: [[url: env.PROJECT_REPO]] + ]) +} + +def markSafeDirectory() { + sh "git config --global --add safe.directory ${pwd()}" +} + +def notifyGiteePullRequest(String message) { + if (env.giteePullRequestIid?.trim()) { + addGiteeMRComment comment: message + } else { + echo "Skipping Gitee PR comment (not a PR build). Message:\n${message}" + } +} + +def defconfigFor(String arch) { + switch (arch) { + case 'aarch64': + return 'platforms/aarch64-qemu-virt/defconfig' + case 'x86_64': + return 'platforms/x86_64-qemu-virt/defconfig' + default: + error("Unsupported architecture: ${arch}") + } +} + +def runtimeTargetSetupFor(String arch) { + switch (arch) { + case 'aarch64': + return ''' +rustup target add aarch64-unknown-none || true +rustup target add aarch64-unknown-none-softfloat || true +rustup target add aarch64-unknown-linux-musl || true +''' + case 'x86_64': + return ''' +rustup target add x86_64-unknown-none || true +rustup target add x86_64-unknown-linux-musl || true +''' + default: + error("Unsupported architecture: ${arch}") + } +} + +def fixWorkspaceOwnership(String workspacePath) { + if (!workspacePath?.trim()) { + return + } + + sh """#!/bin/bash +set -euo pipefail +workspace_path='${workspacePath}' +reference_path="\$(dirname "\${workspace_path}")" + +if [[ ! -e "\${reference_path}" ]]; then + exit 0 +fi + +if [[ ! -e "\${workspace_path}" ]]; then + exit 0 +fi + +owner="\$(stat -c '%u:%g' "\${reference_path}")" +chown -R "\${owner}" "\${workspace_path}" || true +chmod -R u+rwX "\${workspace_path}" || true +""" +}