diff --git a/plugins/storage/volume/ontap/pom.xml b/plugins/storage/volume/ontap/pom.xml index c18bf559f92a..05929c188c92 100644 --- a/plugins/storage/volume/ontap/pom.xml +++ b/plugins/storage/volume/ontap/pom.xml @@ -173,4 +173,42 @@ + + + + ontap-coverage + + + ontap.coverage + + + + + + org.jacoco + jacoco-maven-plugin + ${cs.jacoco-plugin.version} + + + ontap-coverage-agent + + prepare-agent + + + + ontap-coverage-report + verify + + report + + + + + + + + diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile index 8d1dead65701..5bb4d8277dfa 100644 --- a/private-cicd/Jenkinsfile +++ b/private-cicd/Jenkinsfile @@ -146,6 +146,103 @@ boolean hasIntegrationStarted(build) { return environment?.get('PRESUBMIT_INTEGRATION_STARTED') == 'true' } +@NonCPS +String revisionHeadSha(revision) { + if (revision == null) { + return '' + } + try { + def hash = revision.pullHash?.toString() ?: '' + if (hash ==~ /^[0-9a-fA-F]{40}$/) { + return hash + } + } catch (Exception ignored) { + return '' + } + return '' +} + +@NonCPS +String indexedHeadSha(String jobName, int buildNumber) { + def job = jenkins.model.Jenkins.get().getItemByFullName(jobName) + if (job == null) { + return '' + } + // A manual build does not copy the indexed revision onto the build. The scan stores it on the job. + def hash = revisionHeadSha(job.getAction(jenkins.scm.api.SCMRevisionAction)?.revision) + if (hash) { + return hash + } + def build = job.getBuildByNumber(buildNumber) + return revisionHeadSha(build?.getAction(jenkins.scm.api.SCMRevisionAction)?.revision) +} + +// A multibranch child only contains builds for one PR. Stop an older run of +// that child before it holds the VM. A queued duplicate of the same indexed +// SHA is cancelled; a queued item with no SHA is left alone. +@NonCPS +def supersedeMultibranchBuilds(String jobName, int buildNumber, String sha) { + def jenkins = jenkins.model.Jenkins.get() + def job = jenkins.getItemByFullName(jobName) + if (job == null) { + throw new IllegalStateException("Cannot find Jenkins job '${jobName}'.") + } + + def results = [] + def latest = revisionHeadSha(job.getAction(jenkins.scm.api.SCMRevisionAction)?.revision) + if (latest && sha && !latest.equalsIgnoreCase(sha)) { + def self = job.getBuildByNumber(buildNumber) + if (self != null && self.isBuilding() && !hasIntegrationStarted(self)) { + self.doStop() + results << "Stopping build #${buildNumber}: a newer head ${latest.take(12)} is indexed." + } else if (self != null && hasIntegrationStarted(self)) { + results << "Not stopping build #${buildNumber}: ONTAP integration has started." + } + return results + } + jenkins.queue.items.findAll { item -> + item.task?.fullName == job.fullName + }.each { item -> + def queued = revisionHeadSha(item.getAction(jenkins.scm.api.SCMRevisionAction)?.revision) + if (queued && sha && queued.equalsIgnoreCase(sha)) { + jenkins.queue.cancel(item) + results << "Cancelled a queued build for ${sha.take(12)}." + } + } + job.getBuilds().findAll { build -> + build.isBuilding() && build.number < buildNumber + }.each { build -> + if (hasIntegrationStarted(build)) { + results << "Not aborting build #${build.number}: ONTAP integration has started." + } else { + build.doStop() + results << "Aborted superseded build #${build.number}." + } + } + return results +} + +@NonCPS +int diffNumberForResolvedSha(String jobName, int buildNumber, String sha) { + def job = jenkins.model.Jenkins.get().getItemByFullName(jobName) + if (job == null || !sha) { + return 1 + } + def previousShas = [] as Set + job.getBuilds().findAll { + it.number < buildNumber + }.each { build -> + def previousSha = build.getAction( + org.jenkinsci.plugins.workflow.cps.EnvActionImpl + )?.getOverriddenEnvironment()?.get('RESOLVED_SOURCE_SHA') + if (previousSha) { + previousShas << previousSha + } + } + previousShas.remove(sha) + return previousShas.size() + 1 +} + @NonCPS def abortOlderBuildsForSource(String jobName, int buildNumber, String sourceKey) { def job = jenkins.model.Jenkins.get().getItemByFullName(jobName) @@ -198,6 +295,44 @@ boolean isValidBranchName(String branchName) { } } +boolean isMultibranchPullRequest() { + return env.CHANGE_ID?.trim() +} + +// Branch Source does not fill Declarative parameters. A multibranch run stores +// one resolved pull-request context in the environment and reads it here. +def sourceValue(String name) { + if (env.PRESUBMIT_MULTIBRANCH_READY == 'true') { + switch (name) { + case 'SOURCE_MODE': + return env.EFFECTIVE_SOURCE_MODE + case 'PR_ID': + return env.EFFECTIVE_PR_ID + case 'PR_ACTION': + return env.EFFECTIVE_PR_ACTION + case 'PR_DRAFT': + return env.EFFECTIVE_PR_DRAFT + case 'PR_REPOSITORY': + return env.EFFECTIVE_PR_REPOSITORY + case 'PR_BASE_BRANCH': + return env.EFFECTIVE_PR_BASE_BRANCH + case 'PR_HEAD_BRANCH': + return env.EFFECTIVE_PR_HEAD_BRANCH + case 'PR_HEAD_SHA': + return env.EFFECTIVE_PR_HEAD_SHA + case 'PR_AUTHOR_LOGIN': + return env.EFFECTIVE_PR_AUTHOR_LOGIN + case 'PR_AUTHOR_EMAIL': + return env.EFFECTIVE_PR_AUTHOR_EMAIL + case 'PR_URL': + return env.EFFECTIVE_PR_URL + case 'PR_TITLE': + return env.EFFECTIVE_PR_TITLE + } + } + return params.get(name) +} + def validateInventory(Map inventory) { if (containsPlaceholder(inventory)) { error('VM inventory still contains PLACEHOLDER- values.') @@ -321,13 +456,13 @@ void recordStageEvent(String stageName, String status) { } String presubmitSubjectBase() { - def recipientName = params.PR_AUTHOR_LOGIN?.trim() + def recipientName = sourceValue('PR_AUTHOR_LOGIN')?.trim() if (!recipientName && env.PRESUBMIT_MAIL_TO?.contains('@')) { recipientName = env.PRESUBMIT_MAIL_TO.substring(0, env.PRESUBMIT_MAIL_TO.indexOf('@')) } - def sourceName = params.SOURCE_MODE == 'branch' ? - "branch ${params.SOURCE_BRANCH}" : "PR-${params.PR_ID}" - def title = params.PR_TITLE?.replaceAll(/[\r\n]+/, ' ')?.trim() + def sourceName = sourceValue('SOURCE_MODE') == 'branch' ? + "branch ${params.SOURCE_BRANCH}" : "PR-${sourceValue('PR_ID')}" + def title = sourceValue('PR_TITLE')?.replaceAll(/[\r\n]+/, ' ')?.trim() if (title?.length() > 80) { title = title.take(77) + '...' } @@ -365,15 +500,15 @@ boolean hasGithubAppCredential() { List githubEnvironment() { return [ - "GITHUB_REPOSITORY=${params.PR_REPOSITORY}", - "GITHUB_HEAD_SHA=${params.PR_HEAD_SHA}", - "PR_ID=${params.PR_ID}", - "PR_TITLE=${params.PR_TITLE ?: ''}" + "GITHUB_REPOSITORY=${sourceValue('PR_REPOSITORY')}", + "GITHUB_HEAD_SHA=${sourceValue('PR_HEAD_SHA')}", + "PR_ID=${sourceValue('PR_ID')}", + "PR_TITLE=${sourceValue('PR_TITLE') ?: ''}" ] } void createGithubCheck() { - if (params.SOURCE_MODE != 'pull_request' || !hasGithubAppCredential()) { + if (sourceValue('SOURCE_MODE') != 'pull_request' || !hasGithubAppCredential()) { echo 'GitHub Check creation skipped: GitHub App credential is not configured.' return } @@ -394,7 +529,7 @@ void createGithubCheck() { throw new IllegalStateException("GitHub returned an invalid Check Run ID '${checkRunId}'.") } env.GITHUB_CHECK_RUN_ID = checkRunId - echo "Created GitHub Check Run ${checkRunId} on ${params.PR_HEAD_SHA}." + echo "Created GitHub Check Run ${checkRunId} on ${sourceValue('PR_HEAD_SHA')}." } } } catch (Exception e) { @@ -410,8 +545,36 @@ void createGithubCheck() { } } -void completeGithubCheck(String conclusion) { - if (!env.GITHUB_CHECK_RUN_ID?.trim() || !hasGithubAppCredential()) { +// Builds the ONTAP plugin coverage summary from the unit-test report written by +// the Maven build and the functional dumps collected from the lab VM. It never +// fails the build: a missing part is named in the summary. Returns the path of +// the markdown summary, or '' when there is none. +String buildCoverageSummary() { + if ((env.PRESUBMIT_COVERAGE ?: 'true') == 'false' || + !fileExists('private-cicd/scripts/coverage-report.sh')) { + return '' + } + try { + withEnv([ + "RESULTS_DIR=${env.WORKSPACE}/${env.PRESUBMIT_RESULTS}", + "SOURCE_DIR=${env.WORKSPACE}/${env.SOURCE_DIR}" + ]) { + sh 'private-cicd/scripts/coverage-report.sh' + } + } catch (Exception e) { + echo "Unable to build the coverage summary: ${e}" + } + def summary = "${env.PRESUBMIT_RESULTS}/coverage/summary.md" + return fileExists(summary) ? summary : '' +} + +// Creates or updates the single coverage comment on the pull request. Turn it +// off with PRESUBMIT_COVERAGE_COMMENT=false. The App needs Pull requests or +// Issues write; without it only this step fails and the build is unchanged. +void postCoverageComment(String summaryFile) { + if (!summaryFile || !hasGithubAppCredential() || + sourceValue('SOURCE_MODE') != 'pull_request' || + (env.PRESUBMIT_COVERAGE_COMMENT ?: 'true') == 'false') { return } try { @@ -423,22 +586,67 @@ void completeGithubCheck(String conclusion) { ) ]) { withEnv(githubEnvironment() + [ - "GITHUB_CHECK_CONCLUSION=${conclusion}" + "GITHUB_COVERAGE_FILE=${env.WORKSPACE}/${summaryFile}" ]) { - sh 'private-cicd/scripts/github-check-run.sh complete' - echo "Completed GitHub Check Run ${env.GITHUB_CHECK_RUN_ID} with conclusion ${conclusion}." + sh 'private-cicd/scripts/github-check-run.sh coverage-comment' } } } catch (Exception e) { + echo "Coverage comment not posted; the presubmit result is unchanged: ${e}" + } +} + +// Publishes the final result. When the run could not be created at the start +// (no run ID), a completed check run is created instead, so the result still +// reaches the pull request. This matters once the check is required: a pull +// request without a reported result stays blocked. +void completeGithubCheck(String conclusion, String failedStage = '', String reportUrl = '', + String coverageFile = '') { + if (!hasGithubAppCredential() || sourceValue('SOURCE_MODE') != 'pull_request') { + return + } + def runId = env.GITHUB_CHECK_RUN_ID?.trim() + try { + withCredentials([ + usernamePassword( + credentialsId: params.GITHUB_APP_CREDENTIALS_ID, + usernameVariable: 'GITHUB_APP', + passwordVariable: 'GITHUB_ACCESS_TOKEN' + ) + ]) { + withEnv(githubEnvironment() + [ + "GITHUB_CHECK_CONCLUSION=${conclusion}", + "GITHUB_CHECK_FAILED_STAGE=${failedStage}", + "GITHUB_CHECK_REPORT_URL=${reportUrl}", + "GITHUB_CHECK_COVERAGE_FILE=${coverageFile}" + ]) { + if (runId) { + sh 'private-cicd/scripts/github-check-run.sh complete' + echo "Completed GitHub Check Run ${runId} with conclusion ${conclusion}." + } else { + def created = sh( + script: 'private-cicd/scripts/github-check-run.sh create', + returnStdout: true + ).trim() + echo "No GitHub Check Run existed; published a completed one (${created}) " + + "with conclusion ${conclusion}." + } + } + } + } catch (Exception e) { + currentBuild.description = ( + (currentBuild.description ? currentBuild.description + ' | ' : '') + + 'GitHub Check result not published' + ) echo "GitHub Check completion failed; presubmit result is unchanged: ${e}" } } void resolvePresubmitMailRecipient() { - def webhookEmail = params.PR_AUTHOR_EMAIL?.trim() + def webhookEmail = sourceValue('PR_AUTHOR_EMAIL')?.trim() env.PRESUBMIT_MAIL_TO = webhookEmail?.contains('@') && !webhookEmail.equalsIgnoreCase('null') ? webhookEmail : '' - if (env.PRESUBMIT_MAIL_TO || params.SOURCE_MODE != 'pull_request' || + if (env.PRESUBMIT_MAIL_TO || sourceValue('SOURCE_MODE') != 'pull_request' || !hasGithubAppCredential()) { return } @@ -485,20 +693,21 @@ String presubmitInfoRow(String label, String valueHtml) { } String presubmitStartHtml() { - def source = params.SOURCE_MODE == 'branch' ? - "branch ${params.SOURCE_BRANCH}" : "PR-${params.PR_ID}" - def sourceHtml = params.PR_URL ==~ /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+$/ ? - "${htmlEscape(source)}" : + def source = sourceValue('SOURCE_MODE') == 'branch' ? + "branch ${params.SOURCE_BRANCH}" : "PR-${sourceValue('PR_ID')}" + def sourceHtml = sourceValue('PR_URL') ==~ /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+$/ ? + "${htmlEscape(source)}" : htmlEscape(source) def diffNumber = env.PRESUBMIT_DIFF_NUMBER?.trim() def rows = presubmitInfoRow('Source', sourceHtml) + - presubmitInfoRow('Title', htmlEscape(params.PR_TITLE ?: 'not provided')) + presubmitInfoRow('Title', htmlEscape(sourceValue('PR_TITLE') ?: 'not provided')) if (diffNumber) { rows += presubmitInfoRow('Diff', "#${htmlEscape(diffNumber)}") } rows += presubmitInfoRow( 'Commit', "${htmlEscape(env.RESOLVED_SOURCE_SHA)}") - rows += presubmitInfoRow('Started', htmlEscape(env.PRESUBMIT_STARTED_AT)) + rows += presubmitInfoRow( + 'Started', htmlEscape(env.PRESUBMIT_STARTED_AT_IST ?: env.PRESUBMIT_STARTED_AT)) def consoleUrl = "${env.BUILD_URL}console" return """

CloudStack ONTAP presubmit

@@ -524,12 +733,13 @@ results follows when this run finishes.

// GitHub App diagnostic skip them, as does a request that `Check eligibility` // rejected by setting NOT_BUILT. boolean isWorkerStageEnabled() { - return params.SOURCE_MODE in ['pull_request', 'branch'] && - currentBuild.result != 'NOT_BUILT' + return sourceValue('SOURCE_MODE') in ['pull_request', 'branch'] && + currentBuild.result != 'NOT_BUILT' && + currentBuild.currentResult != 'NOT_BUILT' } boolean isDiagnoseRun() { - return params.SOURCE_MODE == 'diagnose' + return sourceValue('SOURCE_MODE') == 'diagnose' } void discoverPullRequests() { @@ -660,18 +870,44 @@ void discoverPullRequests() { } } +// Branch mode leaves PR_ID and PR_HEAD_SHA empty, so scripts receive a +// mode-independent identity. The branch is sanitised because it reaches +// a filesystem path on the target VM. A multibranch pull request has already +// set both values in prepareMultibranchPullRequest(). +void resolveSourceIdentity() { + if (isMultibranchPullRequest()) { + return + } + if (params.SOURCE_MODE == 'branch') { + env.RESOLVED_SOURCE_ID = + 'branch-' + params.SOURCE_BRANCH.replaceAll('[^A-Za-z0-9._-]', '-') + env.RESOLVED_SOURCE_SHA = params.SOURCE_SHA + } else { + env.RESOLVED_SOURCE_ID = 'pr-' + params.PR_ID + env.RESOLVED_SOURCE_SHA = params.PR_HEAD_SHA + } +} + void validateSourceRequest() { + resolveSourceIdentity() sh 'rm -f "${WORKSPACE}@tmp/presubmit-stage-events.tsv"' env.PRESUBMIT_INTEGRATION_STARTED = 'false' - env.PRESUBMIT_STARTED_AT = new Date().format( + def startedAt = new Date() + // The UTC value feeds the report script and durations. The IST value is + // only what the start mail displays. + env.PRESUBMIT_STARTED_AT = startedAt.format( "yyyy-MM-dd'T'HH:mm:ssXXX", TimeZone.getTimeZone('UTC') ) + env.PRESUBMIT_STARTED_AT_IST = startedAt.format( + "yyyy-MM-dd HH:mm:ss 'IST'", + TimeZone.getTimeZone('Asia/Kolkata') + ) recordStageEvent('Validate source request', 'STARTED') - if (!(params.SOURCE_MODE in ['pull_request', 'branch'])) { - error("Unsupported SOURCE_MODE '${params.SOURCE_MODE}'.") + if (!(sourceValue('SOURCE_MODE') in ['pull_request', 'branch'])) { + error("Unsupported SOURCE_MODE '${sourceValue('SOURCE_MODE')}'.") } - if (params.SOURCE_MODE == 'branch') { + if (sourceValue('SOURCE_MODE') == 'branch') { if (!isValidBranchName(params.SOURCE_BRANCH)) { error('SOURCE_BRANCH is not a valid Git branch name.') } @@ -679,16 +915,16 @@ void validateSourceRequest() { error('SOURCE_SHA must be a full 40-character Git SHA.') } } else { - if (!(params.PR_ID ==~ /^[0-9]+$/)) { + if (!(sourceValue('PR_ID') ==~ /^[0-9]+$/)) { error('PR_ID must be the numeric GitHub pull request number.') } - if (params.PR_REPOSITORY != params.EXPECTED_REPOSITORY) { - error("Repository '${params.PR_REPOSITORY}' is not '${params.EXPECTED_REPOSITORY}'.") + if (sourceValue('PR_REPOSITORY') != params.EXPECTED_REPOSITORY) { + error("Repository '${sourceValue('PR_REPOSITORY')}' is not '${params.EXPECTED_REPOSITORY}'.") } - if (!(params.PR_HEAD_BRANCH ==~ /^[A-Za-z0-9._\/-]+$/)) { + if (!(sourceValue('PR_HEAD_BRANCH') ==~ /^[A-Za-z0-9._\/-]+$/)) { error('PR_HEAD_BRANCH contains unsupported characters.') } - if (!(params.PR_HEAD_SHA ==~ /^[0-9a-fA-F]{40}$/) && params.PR_ACTION != 'closed') { + if (!(sourceValue('PR_HEAD_SHA') ==~ /^[0-9a-fA-F]{40}$/) && sourceValue('PR_ACTION') != 'closed') { error('PR_HEAD_SHA must be a full 40-character Git SHA.') } } @@ -718,15 +954,23 @@ void validateSourceRequest() { } } try { - def sourceKey = params.SOURCE_MODE == 'branch' ? - "branch:${params.SOURCE_BRANCH}" : - "pull_request:${params.PR_ID}" - env.PRESUBMIT_DIFF_NUMBER = diffNumberForSource( - env.JOB_NAME, - env.BUILD_NUMBER.toInteger(), - sourceKey, - env.RESOLVED_SOURCE_SHA - ).toString() + if (isMultibranchPullRequest()) { + env.PRESUBMIT_DIFF_NUMBER = diffNumberForResolvedSha( + env.JOB_NAME, + env.BUILD_NUMBER.toInteger(), + env.RESOLVED_SOURCE_SHA + ).toString() + } else { + def sourceKey = sourceValue('SOURCE_MODE') == 'branch' ? + "branch:${params.SOURCE_BRANCH}" : + "pull_request:${sourceValue('PR_ID')}" + env.PRESUBMIT_DIFF_NUMBER = diffNumberForSource( + env.JOB_NAME, + env.BUILD_NUMBER.toInteger(), + sourceKey, + env.RESOLVED_SOURCE_SHA + ).toString() + } } catch (Exception e) { env.PRESUBMIT_DIFF_NUMBER = '' echo "Unable to determine the diff number: ${e}" @@ -736,37 +980,51 @@ void validateSourceRequest() { // five-minute discovery runs. def diffLabel = env.PRESUBMIT_DIFF_NUMBER ? " diff #${env.PRESUBMIT_DIFF_NUMBER}" : '' - if (params.SOURCE_MODE == 'branch') { + if (sourceValue('SOURCE_MODE') == 'branch') { currentBuild.displayName = "#${env.BUILD_NUMBER} branch ${params.SOURCE_BRANCH}${diffLabel}" currentBuild.description = "Branch ${params.SOURCE_BRANCH} @ ${params.SOURCE_SHA.take(12)}" } else { currentBuild.displayName = - "#${env.BUILD_NUMBER} PR-${params.PR_ID}${diffLabel} ${params.PR_ACTION}" + "#${env.BUILD_NUMBER} PR-${sourceValue('PR_ID')}${diffLabel} ${sourceValue('PR_ACTION')}" currentBuild.description = - "${env.RESOLVED_SOURCE_SHA?.take(12)} ${params.PR_TITLE ?: params.PR_URL}" + "${env.RESOLVED_SOURCE_SHA?.take(12)} ${sourceValue('PR_TITLE') ?: sourceValue('PR_URL')}" } } void checkPresubmitEligibility() { recordStageEvent('Check eligibility', 'STARTED') - if (params.SOURCE_MODE == 'pull_request') { + if (sourceValue('SOURCE_MODE') == 'pull_request') { def eligibleActions = ['opened', 'synchronize', 'reopened', 'ready_for_review'] - if (params.PR_ACTION == 'closed' || - params.PR_DRAFT.toBoolean() || - params.PR_BASE_BRANCH != 'main' || - !(params.PR_ACTION in eligibleActions)) { + if (sourceValue('PR_ACTION') == 'closed' || + sourceValue('PR_DRAFT').toBoolean() || + sourceValue('PR_BASE_BRANCH') != 'main' || + !(sourceValue('PR_ACTION') in eligibleActions)) { currentBuild.result = 'NOT_BUILT' - echo "Ignoring PR-${params.PR_ID}: action=${params.PR_ACTION}, draft=${params.PR_DRAFT}, base=${params.PR_BASE_BRANCH}" + echo "Ignoring PR-${sourceValue('PR_ID')}: action=${sourceValue('PR_ACTION')}, draft=${sourceValue('PR_DRAFT')}, base=${sourceValue('PR_BASE_BRANCH')}" } } } void abortSupersededRun() { recordStageEvent('Abort superseded run', 'STARTED') - def sourceKey = params.SOURCE_MODE == 'branch' ? + if (isMultibranchPullRequest()) { + try { + supersedeMultibranchBuilds( + env.JOB_NAME, + env.BUILD_NUMBER.toInteger(), + env.RESOLVED_SOURCE_SHA + ).each { result -> + echo result + } + } catch (Exception e) { + echo "Unable to inspect older builds; no build was aborted: ${e}" + } + return + } + def sourceKey = sourceValue('SOURCE_MODE') == 'branch' ? "branch:${params.SOURCE_BRANCH}" : - "pull_request:${params.PR_ID}" + "pull_request:${sourceValue('PR_ID')}" try { abortOlderBuildsForSource( env.JOB_NAME, @@ -780,10 +1038,157 @@ void abortSupersededRun() { } } +// Branch name from a Git SCM branch spec such as '*/main' or 'refs/heads/main'. +// Returns '' for a wildcard or any name Git would not accept. +String branchFromSpec(String spec) { + def name = (spec ?: '').trim() + if (name.startsWith('*/')) { + name = name.substring(2) + } + if (name.startsWith('refs/heads/')) { + name = name.substring('refs/heads/'.length()) + } + return name.contains('*') || !isValidBranchName(name) ? '' : name +} + +// Branch that supplies the trusted CI scripts, in this order: +// 1. PRESUBMIT_TRUSTED_CI_BRANCH, a job or folder environment variable. Set it +// to the Remote Jenkinsfile Provider branch when that is not 'main', for +// example while a Jenkinsfile change is piloted from a feature branch. +// 2. For the manual job, the branch of the job's own Pipeline SCM. +// 3. 'main'. +// A multibranch pull request build never reads scm: there it is the pull +// request's own code, and the trusted scripts must not come from the PR. +String trustedCiBranch() { + def override = branchFromSpec(env.PRESUBMIT_TRUSTED_CI_BRANCH) + if (override) { + return override + } + if (!isMultibranchPullRequest()) { + try { + // A BranchSpec prints as its name; this avoids a new sandbox approval. + def fromJob = scm?.branches ? branchFromSpec("${scm.branches[0]}") : '' + if (fromJob) { + return fromJob + } + } catch (Exception e) { + echo "Could not read the job's SCM branch, using main: ${e}" + } + } + return 'main' +} + void checkoutCiScripts() { recordStageEvent('Checkout CI scripts', 'STARTED') + def ciBranch = trustedCiBranch() + if (env.PRESUBMIT_CI_SHA?.trim() && fileExists('private-cicd/scripts/resolve-pull-request.py')) { + echo "CI scripts already checked out from ${ciBranch} at ${env.PRESUBMIT_CI_SHA}." + return + } deleteDir() - checkout(scm) + def remote = [ + url: params.CLOUDSTACK_GIT_URL, + refspec: "+refs/heads/${ciBranch}:refs/remotes/origin/${ciBranch}" + ] + def credentialId = params.GIT_CREDENTIALS_ID?.trim() + if (!credentialId) { + credentialId = params.GITHUB_APP_CREDENTIALS_ID?.trim() + } + if (credentialId && !credentialId.startsWith('PLACEHOLDER-')) { + remote.credentialsId = credentialId + } + def cloneOptions = [[ + $class: 'CloneOption', + honorRefspec: true, + noTags: true, + shallow: true, + depth: 1 + ]] + try { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${ciBranch}"]], + doGenerateSubmoduleConfigurations: false, + extensions: cloneOptions + [[ + $class: 'SparseCheckoutPaths', + // run-phase2-remote.sh runs the trusted copy of the ONTAP + // prerequisite checker from this checkout, never the PR's copy. + sparseCheckoutPaths: [ + [path: 'private-cicd'], + [path: 'test/integration/plugins/ontap/check_ontap_prereqs.py'] + ] + ]], + userRemoteConfigs: [remote] + ]) + } catch (Exception e) { + echo "Sparse CI checkout unavailable, using a full ${ciBranch} checkout: ${e}" + deleteDir() + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${ciBranch}"]], + doGenerateSubmoduleConfigurations: false, + extensions: cloneOptions, + userRemoteConfigs: [remote] + ]) + } + env.PRESUBMIT_CI_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() + echo "Checked out CI scripts from ${ciBranch} at ${env.PRESUBMIT_CI_SHA}." +} + +void prepareMultibranchPullRequest() { + checkoutCiScripts() + def indexed = indexedHeadSha(env.JOB_NAME, env.BUILD_NUMBER.toInteger()) + if (!(indexed ==~ /^[0-9a-fA-F]{40}$/)) { + indexed = '' + echo "Jenkins did not expose an indexed head SHA for PR-${env.CHANGE_ID}. Using the GitHub pull request head." + } + def status = 1 + withCredentials([ + usernamePassword( + credentialsId: params.GITHUB_APP_CREDENTIALS_ID, + usernameVariable: 'GITHUB_APP', + passwordVariable: 'GITHUB_ACCESS_TOKEN' + ) + ]) { + withEnv([ + "PRESUBMIT_REPOSITORY=${params.EXPECTED_REPOSITORY}", + "PRESUBMIT_PR_ID=${env.CHANGE_ID}", + "PRESUBMIT_INDEXED_SHA=${indexed}", + "PRESUBMIT_CHANGE_TARGET=${env.CHANGE_TARGET ?: ''}" + ]) { + status = sh( + script: '''python3 private-cicd/scripts/resolve-pull-request.py \ + --repository "$PRESUBMIT_REPOSITORY" \ + --pr-id "$PRESUBMIT_PR_ID" \ + --indexed-sha "$PRESUBMIT_INDEXED_SHA" \ + --change-target "$PRESUBMIT_CHANGE_TARGET" \ + --output multibranch-source.json''', + returnStatus: true + ) + } + } + def decision = fileExists('multibranch-source.json') ? readJSON(file: 'multibranch-source.json') : [:] + if (status != 0 || !decision.eligible) { + currentBuild.result = 'NOT_BUILT' + echo "Not building PR-${env.CHANGE_ID}: ${decision.reason ?: 'pull request lookup failed.'}" + return + } + env.PRESUBMIT_MULTIBRANCH_READY = 'true' + env.EFFECTIVE_SOURCE_MODE = 'pull_request' + env.EFFECTIVE_PR_ID = decision.pr_id.toString() + env.EFFECTIVE_PR_ACTION = 'synchronize' + env.EFFECTIVE_PR_DRAFT = 'false' + env.EFFECTIVE_PR_REPOSITORY = params.EXPECTED_REPOSITORY + env.EFFECTIVE_PR_BASE_BRANCH = decision.pr_base_branch.toString() + env.EFFECTIVE_PR_HEAD_BRANCH = decision.pr_head_branch.toString() + env.EFFECTIVE_PR_HEAD_SHA = decision.pr_head_sha.toString() + env.EFFECTIVE_PR_AUTHOR_LOGIN = decision.pr_author_login?.toString() ?: '' + env.EFFECTIVE_PR_AUTHOR_EMAIL = decision.pr_author_email?.toString() ?: '' + env.EFFECTIVE_PR_URL = decision.pr_url?.toString() ?: '' + env.EFFECTIVE_PR_TITLE = decision.pr_title?.toString() ?: '' + env.RESOLVED_SOURCE_SHA = env.EFFECTIVE_PR_HEAD_SHA + env.RESOLVED_SOURCE_ID = "pr-${env.EFFECTIVE_PR_ID}" + echo "Accepted PR-${env.EFFECTIVE_PR_ID} at ${env.RESOLVED_SOURCE_SHA}." } // A full worker run takes over an hour, so reporting problems are diagnosed @@ -793,11 +1198,11 @@ void diagnoseGithubApp() { if (!hasGithubAppCredential()) { error('GITHUB_APP_CREDENTIALS_ID is unset or still a placeholder.') } - def headSha = params.PR_HEAD_SHA?.trim() ?: params.SOURCE_SHA?.trim() + def headSha = sourceValue('PR_HEAD_SHA')?.trim() ?: params.SOURCE_SHA?.trim() if (!(headSha ==~ /^[0-9a-f]{40}$/)) { error('Set PR_HEAD_SHA to a full 40-character commit SHA to diagnose.') } - def repository = params.PR_REPOSITORY?.trim() ?: params.EXPECTED_REPOSITORY + def repository = sourceValue('PR_REPOSITORY')?.trim() ?: params.EXPECTED_REPOSITORY withCredentials([ usernamePassword( credentialsId: params.GITHUB_APP_CREDENTIALS_ID, @@ -808,7 +1213,7 @@ void diagnoseGithubApp() { withEnv([ "GITHUB_REPOSITORY=${repository}", "GITHUB_HEAD_SHA=${headSha}", - "PR_ID=${params.PR_ID ?: ''}" + "PR_ID=${sourceValue('PR_ID') ?: ''}" ]) { sh 'private-cicd/scripts/github-check-run.sh diagnose' } @@ -863,18 +1268,18 @@ void checkoutSource() { recordStageEvent('Checkout source', 'STARTED') dir(env.SOURCE_DIR) { deleteDir() - def isBranch = params.SOURCE_MODE == 'branch' - def sourceBranch = isBranch ? params.SOURCE_BRANCH : params.PR_HEAD_BRANCH - def sourceSha = isBranch ? params.SOURCE_SHA : params.PR_HEAD_SHA - def sourceLabel = isBranch ? "branch:${sourceBranch}" : "pull_request:${params.PR_ID}" + def isBranch = sourceValue('SOURCE_MODE') == 'branch' + def sourceBranch = isBranch ? params.SOURCE_BRANCH : sourceValue('PR_HEAD_BRANCH') + def sourceSha = isBranch ? params.SOURCE_SHA : sourceValue('PR_HEAD_SHA') + def sourceLabel = isBranch ? "branch:${sourceBranch}" : "pull_request:${sourceValue('PR_ID')}" def remoteRef = isBranch ? "refs/remotes/origin/source/${sourceBranch}" : - "refs/remotes/origin/pr/${params.PR_ID}" + "refs/remotes/origin/pr/${sourceValue('PR_ID')}" def remote = [ url: params.CLOUDSTACK_GIT_URL, refspec: isBranch ? "+refs/heads/${sourceBranch}:${remoteRef}" : - "+refs/pull/${params.PR_ID}/head:${remoteRef}" + "+refs/pull/${sourceValue('PR_ID')}/head:${remoteRef}" ] if (params.GIT_CREDENTIALS_ID?.trim()) { remote.credentialsId = params.GIT_CREDENTIALS_ID @@ -932,6 +1337,8 @@ void runUnitTests() { SOURCE_SHA="$RESOLVED_SOURCE_SHA" \ ENABLE_SIMULATOR=false \ MAVEN_THREADS=1C \ + RERUN_FAILING_TESTS=2 \ + ONTAP_COVERAGE="${PRESUBMIT_COVERAGE:-true}" \ MAVEN_OPTS="-Xms1g -Xmx8g" \ private-cicd/scripts/mvn-full.sh ''' @@ -987,9 +1394,9 @@ void deployAndRunOntapIntegration() { env.VM_NAME = vm.vcenter_vm as String env.VM_SNAPSHOT = vm.snapshot as String env.VM_BRIDGE = vm.bridge as String - def sourceDescription = params.SOURCE_MODE == 'branch' ? + def sourceDescription = sourceValue('SOURCE_MODE') == 'branch' ? "branch=${params.SOURCE_BRANCH}" : - "PR-${params.PR_ID}" + "PR-${sourceValue('PR_ID')}" currentBuild.description = "${sourceDescription} VM=${env.VM_ID}" echo "Reserved compatible VM '${env.VM_ID}' via '${env.LOCKED_VM}'." @@ -1054,6 +1461,7 @@ void deployAndRunOntapIntegration() { SOURCE_ID="$RESOLVED_SOURCE_ID" \ SOURCE_SHA="$RESOLVED_SOURCE_SHA" \ BRIDGE="$VM_BRIDGE" \ + FUNCTIONAL_COVERAGE="${PRESUBMIT_COVERAGE:-true}" \ private-cicd/scripts/run-phase2-remote.sh ''' } @@ -1063,7 +1471,15 @@ void deployAndRunOntapIntegration() { } void publishPresubmitResults() { - if (params.SOURCE_MODE == 'discover') { + if (isMultibranchPullRequest() && currentBuild.currentResult == 'NOT_BUILT') { + echo "Multibranch PR-${env.CHANGE_ID} was not built." + // A check created before the build stopped must not stay in progress. + if (env.GITHUB_CHECK_RUN_ID?.trim()) { + completeGithubCheck('cancelled') + } + return + } + if (sourceValue('SOURCE_MODE') == 'discover') { echo 'Discovery build completed; worker reporting was skipped.' return } @@ -1084,19 +1500,20 @@ void publishPresubmitResults() { "yyyy-MM-dd'T'HH:mm:ssXXX", TimeZone.getTimeZone('UTC') ) - completeGithubCheck(conclusion) if (!env.PRESUBMIT_MAIL_TO?.trim()) { resolvePresubmitMailRecipient() } - def sourceName = params.SOURCE_MODE == 'branch' ? - "branch ${params.SOURCE_BRANCH}" : "PR-${params.PR_ID}" + def sourceName = sourceValue('SOURCE_MODE') == 'branch' ? + "branch ${params.SOURCE_BRANCH}" : "PR-${sourceValue('PR_ID')}" + // Before the report, which embeds the coverage summary. + def coverageFile = buildCoverageSummary() try { if (fileExists('private-cicd/scripts/generate-presubmit-report.py')) { withEnv([ "PRESUBMIT_REPORT_RESULT=${result}", "PRESUBMIT_REPORT_SOURCE=${sourceName}", - "PRESUBMIT_REPORT_SOURCE_URL=${params.PR_URL ?: ''}", - "PRESUBMIT_REPORT_TITLE=${params.PR_TITLE ?: ''}", + "PRESUBMIT_REPORT_SOURCE_URL=${sourceValue('PR_URL') ?: ''}", + "PRESUBMIT_REPORT_TITLE=${sourceValue('PR_TITLE') ?: ''}", "PRESUBMIT_REPORT_SHA=${env.RESOLVED_SOURCE_SHA}", "PRESUBMIT_REPORT_DIFF=${env.PRESUBMIT_DIFF_NUMBER ?: ''}", "PRESUBMIT_REPORT_VM=${env.VM_ID ?: ''}" @@ -1135,6 +1552,12 @@ void publishPresubmitResults() { } def failedStage = fileExists("${env.PRESUBMIT_RESULTS}/failed-stage.txt") ? readFile("${env.PRESUBMIT_RESULTS}/failed-stage.txt").trim() : '' + // Completed after the report and the archive exist, so the check can + // name the failed stage and link the report. + def reportUrl = fileExists("${env.PRESUBMIT_RESULTS}/report.html") ? + "${env.BUILD_URL}artifact/${env.PRESUBMIT_RESULTS}/report.html" : '' + completeGithubCheck(conclusion, failedStage, reportUrl, coverageFile) + postCoverageComment(coverageFile) def finalSubject = failedStage ? "${presubmitSubjectBase()}: ${result} in ${failedStage}" : "${presubmitSubjectBase()}: ${result}" @@ -1193,15 +1616,11 @@ spec: timeout(time: 8, unit: 'HOURS') } - triggers { - cron('H/5 * * * *') - } - parameters { choice( name: 'SOURCE_MODE', choices: ['discover', 'pull_request', 'branch', 'diagnose'], - description: 'Timer builds use discover. Self-queued workers use pull_request. Use branch for a manual exact-SHA build, or diagnose to probe GitHub App Checks access using PR_HEAD_SHA.' + description: 'Legacy discover mode is not scheduled. Multibranch pull requests ignore this default. Use pull_request for a manual PR build, branch for an exact SHA, or diagnose to probe GitHub App Checks access.' ) string(name: 'SOURCE_BRANCH', defaultValue: '', description: 'Manual branch mode: remote branch name, for example feature/CSTACKEX-223.') string(name: 'SOURCE_SHA', defaultValue: '', description: 'Manual branch mode: exact full 40-character commit SHA.') @@ -1249,18 +1668,17 @@ spec: environment { SOURCE_DIR = 'cloudstack-src' PRESUBMIT_RESULTS = 'presubmit-results' - // Branch mode leaves PR_ID and PR_HEAD_SHA empty, so scripts receive a - // mode-independent identity. The branch is sanitised because it reaches - // a filesystem path on the target VM. - RESOLVED_SOURCE_ID = "${params.SOURCE_MODE == 'branch' ? 'branch-' + params.SOURCE_BRANCH.replaceAll('[^A-Za-z0-9._-]', '-') : 'pr-' + params.PR_ID}" - RESOLVED_SOURCE_SHA = "${params.SOURCE_MODE == 'branch' ? params.SOURCE_SHA : params.PR_HEAD_SHA}" + // RESOLVED_SOURCE_ID and RESOLVED_SOURCE_SHA are deliberately not + // declared here. This block wraps every stage in withEnv, so a value + // declared here shadows the multibranch assignment made later and + // reads back empty. See resolveSourceIdentity(). } stages { stage('Discover PR revisions') { when { expression { - return params.SOURCE_MODE == 'discover' + return sourceValue('SOURCE_MODE') == 'discover' && !isMultibranchPullRequest() } } steps { @@ -1270,6 +1688,19 @@ spec: } } + stage('Prepare multibranch pull request') { + when { + expression { + return isMultibranchPullRequest() + } + } + steps { + script { + prepareMultibranchPullRequest() + } + } + } + stage('Validate source request') { when { expression { diff --git a/private-cicd/README.md b/private-cicd/README.md index ae48b35a9a75..df978fd69ddf 100644 --- a/private-cicd/README.md +++ b/private-cicd/README.md @@ -28,10 +28,11 @@ current implementation contract, see ## How it works -The production Jenkins job loads [`Jenkinsfile`](Jenkinsfile) and helper scripts -from the protected NetApp `main` branch. Every five minutes a short discovery -build polls GitHub and self-queues a worker for each unseen PR head SHA. Each -worker checks out the exact commit into a separate `cloudstack-src` directory, +The production job is the multibranch folder `cloudstack-ontap-presubmit-mb`. +Jenkins scans GitHub about once a minute. That scan is not a build. Each +non-draft origin pull request into `main` gets its own child job. The child +loads [`Jenkinsfile`](Jenkinsfile) and helper scripts from protected `main`, +then checks the exact pull-request head SHA out into `cloudstack-src`. A child then: 1. validates the request, builder, credentials, and VM inventory; @@ -48,10 +49,10 @@ replacing the underlying build or test result. ## Entry points -- **Pull request:** `SOURCE_MODE=discover` notices an open non-draft PR update - to `main` and self-queues at most five `SOURCE_MODE=pull_request` workers in - the same job. Extra SHAs wait for a later poll. Workers then wait on the - `cloudstack-presubmit-vm` lock for a lab VM. +- **Pull request:** a folder scan creates one child job per open non-draft + origin pull request into `main`. Drafts get no job. Compiles run together. + Integration waits on the `cloudstack-presubmit-vm` lock. There is no + five-worker cap. GitHub cannot call this Jenkins, so there is no webhook. - **Manual branch:** a separate triggerless job uses `SOURCE_MODE=branch` with a remote branch and exact 40-character commit SHA. - **Local validation:** `scripts/validate-local.sh` checks the CI files without diff --git a/private-cicd/docs/CREATE-PRESUBMIT-JOB.md b/private-cicd/docs/CREATE-PRESUBMIT-JOB.md index 18fc9053d38a..37291f682777 100644 --- a/private-cicd/docs/CREATE-PRESUBMIT-JOB.md +++ b/private-cicd/docs/CREATE-PRESUBMIT-JOB.md @@ -19,12 +19,73 @@ # Create the auto-triggered ONTAP presubmit job -This guide creates **one** Pipeline job, `cloudstack-ontap-presubmit`. Timer -builds poll GitHub every five minutes and self-queue one worker build for each -new eligible PR head SHA. Worker builds compile and package concurrently, then -wait for an available `cloudstack-presubmit-vm` Lockable Resource before -integration. Until `feature/CSTACKEX-223` is merged, the job loads trusted CI -from that branch. After merge, change only **Branches to build** to `*/main`. +The current job is the multibranch folder `cloudstack-ontap-presubmit-mb`. +It scans GitHub about once a minute and creates one child job for every open +non-draft origin pull request into `main`. Remote Jenkinsfile Provider loads +`private-cicd/Jenkinsfile` from the trusted CI branch. Match branches stays off. +Draft pull requests get no job. Do not add a webhook. + +The trusted CI branch is `main`. The Jenkinsfile and the CI scripts of every pull +request build come from the trusted branch, never from the pull request, so a PR +that changes `private-cicd/` is built with the unchanged trusted copy. Its +changes apply only after they are merged. To try a Jenkinsfile or script change +before the merge, run the manual job (`SOURCE_MODE=pull_request` or `branch`) +against the feature branch. + +The CI scripts branch is chosen by `trustedCiBranch()` in the Jenkinsfile: + +1. the `PRESUBMIT_TRUSTED_CI_BRANCH` environment variable, if the job or folder + defines it; +2. for the manual job, the branch of its own Pipeline SCM; +3. otherwise `main`. + +A multibranch pull request build cannot read its branch from `scm`, because +there `scm` is the pull request's own code. The value must therefore equal the +Remote Jenkinsfile Provider branch. When the provider branch is not `main`, set +`PRESUBMIT_TRUSTED_CI_BRANCH` to the same branch in the same step. A mismatch +shows as `NOT_BUILT` with "pull request lookup failed", because the scripts +are missing on the other branch. + +During the PR-119 pilot the provider branch was `feature/CSTACKEX-340`, the +folder include filter was `PR-119`, and `PRESUBMIT_TRUSTED_CI_BRANCH` had to name +that branch. Do not switch the provider branch to `main` until PR-119 is merged: +the previous `main` Jenkinsfile knows nothing about `CHANGE_ID` and defaults to +`SOURCE_MODE=discover`. After the merge, change the provider branch to `main`, +remove `PRESUBMIT_TRUSTED_CI_BRANCH` and change the filter to `PR-*`. Keep +`feature/CSTACKEX-340` until then. + +A pull request that is closed or merged is not built, even if someone selects +Rebuild on an orphaned item: the build ends `NOT_BUILT` with "pull request is not +open". + +The numbered sections below that create a single Pipeline job with a five-minute +`SOURCE_MODE=discover` timer (11 to 16) are retired. Sections on plugins, +credentials, the inventory, Lockable Resources, and the GitHub App still apply. +Keep `cloudstack-ontap-presubmit` disabled after the folder works. Do not delete +the GitHub App, credentials, or the VM lock until a multibranch pull request has +passed. + +## Multibranch folder configuration + +Open `cloudstack-ontap-presubmit-mb` > **Configure**: + +| Setting | Value | +| --- | --- | +| Branch Source | GitHub, credential `cloudstack-presubmit-github-app`, repository `NetApp/cloudstack` | +| Behaviours | Discover pull requests from origin only, strategy **The current pull request revision**; remove branch and fork discovery | +| Filter by name | `PR-*` (`PR-119` during the pilot) | +| Build Configuration | **by Remote Jenkinsfile Provider**, repository `https://github.com/NetApp/cloudstack.git`, branch `main` (`feature/CSTACKEX-340` during the pilot), file `private-cicd/Jenkinsfile`, **Match branches off** | +| Scan Repository Triggers | Periodically, 1 to 5 minutes | +| Orphaned items | Keep 14 days, 50 items | + +Run **Scan Repository Now** and expect one child for each open non-draft pull +request into `main` (only `PR-119` during the pilot). The first build may +stop on Script Security. Approve only signatures requested by this Jenkinsfile +and rebuild. + +The single-branch Pipeline job is now only the manual job for +`SOURCE_MODE=branch` and `diagnose`. Point it at the same trusted branch, add no +triggers, and never run `SOURCE_MODE=discover`. Do not create a GitHub repository webhook. OpenLab Jenkins is not reachable from GitHub.com, so inbound `pull_request` deliveries fail with @@ -36,8 +97,8 @@ item for this presubmit: that would run `Jenkinsfile` from the PR branch. This design loads trusted CI from a fixed branch and checks the PR SHA out separately. -Do not create a separate poller, Multibranch Pipeline, or second production -copy of this job. +The multibranch folder above is the production job. Do not create another +poller or a second production copy. This guide starts before the Jenkins item exists and ends after polling and required Check have been verified. Follow sections 1 through 21 in order. Do diff --git a/private-cicd/docs/PRIVATE-CICD-GUIDE.md b/private-cicd/docs/PRIVATE-CICD-GUIDE.md index 54d4dae56a6f..e7576b09241f 100644 --- a/private-cicd/docs/PRIVATE-CICD-GUIDE.md +++ b/private-cicd/docs/PRIVATE-CICD-GUIDE.md @@ -30,9 +30,9 @@ This is the consolidated operator and developer reference. The current [`Jenkins The guide covers: -- the trusted production Pipeline-from-SCM job with five-minute GitHub API - discovery, including first-PR cutover from `feature/CSTACKEX-223` to `main` - ([`CREATE-PRESUBMIT-JOB.md`](CREATE-PRESUBMIT-JOB.md)); +- the multibranch folder `cloudstack-ontap-presubmit-mb`, which scans about + once a minute and runs trusted CI from `main` for every non-draft origin + pull request into `main` ([`CREATE-PRESUBMIT-JOB.md`](CREATE-PRESUBMIT-JOB.md)); - the separate triggerless manual branch job; - local CI-file validation and direct disposable-VM validation; - Stage 1 build, unit-test, and Debian package handoff; @@ -68,12 +68,10 @@ VM lock. ## 3. Architecture and data flow ```text -Jenkins timer -> discover mode (every 5 minutes) - -> GitHub API -> unseen PR head SHAs - -> self-queue at most MAX_ACTIVE_WORKERS pull_request builds - -> extra SHAs deferred with watermark held - -> self-queued worker builds in the same job - -> trusted Jenkinsfile/scripts from protected main +Folder scan about once a minute (not a pipeline build) + -> one child job per open non-draft origin PR into main + -> trusted Jenkinsfile/scripts from protected main + -> exact indexed PR head SHA, confirmed with the pull request API -> request, eligibility, credential, inventory, and builder checks -> exact PR or branch SHA checkout into cloudstack-src -> Maven clean install and unit tests @@ -156,12 +154,10 @@ Lightweight checkout: off PR code cannot replace trusted CI helpers before credentials are used. The source under test is checked out separately. -Until `feature/CSTACKEX-223` is merged, that Jenkinsfile is not on `main`. Create one job named -`cloudstack-ontap-presubmit` that loads CI from `*/feature/CSTACKEX-223`, using -[`CREATE-PRESUBMIT-JOB.md`](CREATE-PRESUBMIT-JOB.md). Its timer builds discover work and self-queue workers in the -same item. After merge, change only **Branches to build** to `*/main`. Keep the same job, GitHub App, credentials, -resources, and Check. Do not create a GitHub repository webhook: OpenLab Jenkins is not reachable from GitHub.com. -Do not create a GitHub Organization folder for this presubmit. +Production is the multibranch folder `cloudstack-ontap-presubmit-mb`. Remote Jenkinsfile +Provider loads this Jenkinsfile from `main` with Match branches off. A folder scan, +not a webhook, finds pull requests. Keep the old `cloudstack-ontap-presubmit` job +disabled. Do not create a GitHub repository webhook or a GitHub Organization folder. ### Manual-job trust boundary @@ -550,6 +546,60 @@ cloudstack-ontap-presubmit Without protection it is informational. Verify pending/failed results block merge and each new SHA needs a new Check. +How the Check behaves once it is required: + +| Build result | Check conclusion | Merge | +| --- | --- | --- | +| `SUCCESS` | `success` | allowed | +| `FAILURE`, `UNSTABLE` | `failure` | blocked | +| `ABORTED`, superseded or timed out | `cancelled` | blocked until a new run passes | +| not built (draft, fork, closed, wrong base) | no Check, or `cancelled` if one was started | blocked until a run passes | + +- The Check is created `in_progress` at the start of the run and completed after the report is archived. The + completed Check names the failed stage and links the Jenkins build and the HTML report. +- If creating the Check failed at the start, the final step publishes an already completed Check instead, so the + result still reaches the pull request. If that also fails, the job description says + `GitHub Check result not published`, and the Check stays missing or in progress, which blocks the merge. + Fix the cause (run `SOURCE_MODE=diagnose`), then push a commit or rebuild the PR job. +- Completion is retried three times. A Jenkins crash or restart mid-run still leaves the Check in progress. Rebuild + the PR job, or push a commit, to replace it. A GitHub **Re-run** button does nothing, because Jenkins receives no + webhooks. +- In the branch rule, select the Check produced by the presubmit GitHub App as its source, not **Any source**. +- Do not require the `continuous-integration/jenkins/pr-head` status that GitHub Branch Source can also post. The + Check above is the only gate. +- A pull request from a fork never gets a Check, so it cannot merge while the Check is required. + +### ONTAP plugin test coverage + +Every run reports the coverage of the ONTAP storage plugin +(`plugins/storage/volume/ontap`) from two sources, in the mail, in `report.html`, in the summary of the +`cloudstack-ontap-presubmit` Check, and in one PR comment that later runs update in place. + +| Source | How it is produced | +| --- | --- | +| Unit tests | `mvn-full.sh` passes `-Dontap.coverage=true`, which activates the `ontap-coverage` profile in the plugin's `pom.xml`. The same Maven build that runs the tests writes `target/site/jacoco/jacoco.xml`. `mvn-full.sh` copies it to `presubmit-results/unit-tests/coverage/unit/`. | +| Functional (Marvin) tests | `run-phase2-remote.sh` fetches the JaCoCo agent and command line (`fetch-jacoco.sh`, version 0.8.11) and ships them to the VM. `configure-cloudstack-vm.sh` adds `-javaagent:...,output=tcpserver,address=127.0.0.1,port=36320` to the management server `JAVA_OPTS` before its first start. `marvin-run.sh` dumps and resets the data after the iSCSI suites and again after the NFS3 suites into `phase2/coverage/*.exec`. | +| Summary | `coverage-report.sh` merges the dumps and builds a report from the plugin jar inside the deployed management package, so the report matches the code that ran. `ontap-coverage.py` then writes `coverage/summary.{json,md,html}`. | + +Notes: + +- The **combined** figure is line coverage over the union of both runs, taken line by line from the two reports, so + it does not depend on the two reports agreeing on class IDs. Branch and method figures are shown per source only. +- Only the management server is instrumented, which is where the ONTAP plugin runs. The KVM agent is not. +- The coverage is informational. It never changes the build result or the Check conclusion. +- Every part degrades on its own. If the JaCoCo jars cannot be fetched, the agent cannot be attached, a dump fails or + the report cannot be built, the run goes on and the summary says which source is missing. +- A management server restart between two dumps loses the data collected since the last dump. +- The JaCoCo HTML under `coverage/functional/html` and `unit-tests/coverage/unit` is linked for detail, but Jenkins + blocks the scripts and styles of archived HTML, so those pages look plain. The tables in the mail and the PR come + from the summary instead. +- `PRESUBMIT_COVERAGE=false` (a job, folder or global environment variable) turns the whole feature off. + `PRESUBMIT_COVERAGE_COMMENT=false` keeps the summary in the mail and the Check but stops the PR comment. +- The PR comment needs **Pull requests: Read and write** (or **Issues: Read and write**) on the GitHub App. Without + it only the comment step fails; the log says so and the build is unchanged. The comment is found by the marker + `` among comments written by a bot, so it does not touch the `coverage-grade-bot` comment + that the repository's own workflow posts. + ## 13. Manual branch job and exact SHA Create a separate Pipeline-from-SCM job: diff --git a/private-cicd/scripts/configure-cloudstack-vm.sh b/private-cicd/scripts/configure-cloudstack-vm.sh index 24d9746568c8..7aeeee85881d 100755 --- a/private-cicd/scripts/configure-cloudstack-vm.sh +++ b/private-cicd/scripts/configure-cloudstack-vm.sh @@ -428,6 +428,25 @@ until systemctl is-active --quiet libvirtd; do sleep 1 libvirtd_wait=$((libvirtd_wait + 1)) done +# Functional-test coverage: the ONTAP plugin runs inside the management server +# JVM, so the JaCoCo agent is attached to it before its first start. A problem +# here only loses the coverage data, it never fails the integration run. +if [[ -n "${JACOCO_AGENT_JAR:-}" ]]; then + defaults_file=/etc/default/cloudstack-management + if [[ ! -f "$JACOCO_AGENT_JAR" ]]; then + echo "JACOCO_AGENT_JAR $JACOCO_AGENT_JAR does not exist; functional coverage is off." >&2 + elif ! grep -q '^JAVA_OPTS="' "$defaults_file"; then + echo "No JAVA_OPTS line in $defaults_file; functional coverage is off." >&2 + elif grep -q 'jacocoagent' "$defaults_file"; then + echo "The JaCoCo agent is already configured in $defaults_file." + else + install -d -m 755 /opt/jacoco + install -m 644 "$JACOCO_AGENT_JAR" /opt/jacoco/jacocoagent.jar + agent_option="-javaagent:/opt/jacoco/jacocoagent.jar=output=tcpserver,address=127.0.0.1,port=${COVERAGE_PORT:-36320},includes=org.apache.cloudstack.storage.*" + sed -i "s|^JAVA_OPTS=\"\(.*\)\"\$|JAVA_OPTS=\"\1 ${agent_option}\"|" "$defaults_file" + echo "JaCoCo agent added to the management server JAVA_OPTS." + fi +fi db_trace_state "before first start of cloudstack-management" systemctl start cloudstack-management systemctl restart cloudstack-agent diff --git a/private-cicd/scripts/coverage-report.sh b/private-cicd/scripts/coverage-report.sh new file mode 100755 index 000000000000..65757c3fdea3 --- /dev/null +++ b/private-cicd/scripts/coverage-report.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Purpose: +# Builds the ONTAP storage plugin coverage summary of one presubmit run. The +# unit-test JaCoCo report already exists, written by the Maven build. The +# functional report is made here from the management server dumps that +# marvin-run.sh collected on the lab VM, using the plugin jar from the +# management package that was actually deployed. ontap-coverage.py then +# writes summary.json, summary.md and summary.html. +# This script never fails the presubmit: every problem is reported and the +# summary says which part of the coverage is missing. +# Dependencies: +# RESULTS_DIR — the presubmit results directory (required). Reads +# unit-tests/coverage/unit/jacoco.xml and phase2/coverage/*.exec, writes +# coverage/. +# SOURCE_DIR — CloudStack source checkout (required): ONTAP sources. +# Optional: DEB_DIR (default $SOURCE_DIR/dist/deb-all) for the deployed jar, +# BUILD_URL to link the reports. +# Commands: java, python3, dpkg-deb; fetch-jacoco.sh for +# the JaCoCo command line. +# Calls: fetch-jacoco.sh, ontap-coverage.py. + +set -uo pipefail + +RESULTS_DIR="${RESULTS_DIR:?Set RESULTS_DIR}" +SOURCE_DIR="${SOURCE_DIR:?Set SOURCE_DIR}" +DEB_DIR="${DEB_DIR:-$SOURCE_DIR/dist/deb-all}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ontap_module="plugins/storage/volume/ontap" +out_dir="$RESULTS_DIR/coverage" +unit_xml="$RESULTS_DIR/unit-tests/coverage/unit/jacoco.xml" +exec_dir="$RESULTS_DIR/phase2/coverage" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT +mkdir -p "$out_dir" + +functional_xml="" +shopt -s nullglob +exec_files=("$exec_dir"/*.exec) +shopt -u nullglob + +if [[ "${#exec_files[@]}" -eq 0 ]]; then + echo "No functional coverage dumps under $exec_dir." +elif ! "$SCRIPT_DIR/fetch-jacoco.sh" "$work_dir/jacoco" >/dev/null 2>&1; then + echo "Could not fetch the JaCoCo command line; functional coverage is skipped." >&2 +else + cli="$work_dir/jacoco/jacococli.jar" + classfiles="" + # The jar inside the deployed management package is the code that ran. + management_deb="$(find "$DEB_DIR" -maxdepth 1 -name 'cloudstack-management_*.deb' -print -quit 2>/dev/null)" + if [[ -n "$management_deb" ]] && dpkg-deb -x "$management_deb" "$work_dir/deployed" 2>/dev/null; then + classfiles="$(find "$work_dir/deployed" -name 'cloud-plugin-storage-volume-ontap-*.jar' \ + ! -name '*-sources.jar' ! -name '*-tests.jar' -print -quit)" + fi + if [[ -z "$classfiles" && -d "$SOURCE_DIR/$ontap_module/target/classes" ]]; then + classfiles="$SOURCE_DIR/$ontap_module/target/classes" + echo "Deployed plugin jar not found; using $classfiles." + fi + if [[ -z "$classfiles" ]]; then + echo "No ONTAP plugin classes found; functional coverage is skipped." >&2 + elif mkdir -p "$out_dir/functional" \ + && java -jar "$cli" merge "${exec_files[@]}" --destfile "$work_dir/functional.exec" >/dev/null \ + && java -jar "$cli" report "$work_dir/functional.exec" \ + --classfiles "$classfiles" \ + --sourcefiles "$SOURCE_DIR/$ontap_module/src/main/java" \ + --name "ONTAP functional tests" \ + --xml "$out_dir/functional/jacoco.xml" \ + --csv "$out_dir/functional/jacoco.csv" \ + --html "$out_dir/functional/html" >/dev/null; then + functional_xml="$out_dir/functional/jacoco.xml" + echo "Functional coverage report: $functional_xml" + else + echo "Could not build the functional coverage report." >&2 + fi +fi + +summary_args=(--output-dir "$out_dir") +[[ -f "$unit_xml" ]] && summary_args+=(--unit "$unit_xml") +[[ -n "$functional_xml" ]] && summary_args+=(--functional "$functional_xml") +if [[ -n "${BUILD_URL:-}" ]]; then + [[ -f "$unit_xml" ]] && summary_args+=( + --unit-link "${BUILD_URL}artifact/presubmit-results/unit-tests/coverage/unit/index.html") + [[ -n "$functional_xml" ]] && summary_args+=( + --functional-link "${BUILD_URL}artifact/presubmit-results/coverage/functional/html/index.html") +fi +python3 "$SCRIPT_DIR/ontap-coverage.py" "${summary_args[@]}" \ + || echo "Could not write the coverage summary." >&2 +exit 0 diff --git a/private-cicd/scripts/fetch-jacoco.sh b/private-cicd/scripts/fetch-jacoco.sh new file mode 100755 index 000000000000..7f73d049ea91 --- /dev/null +++ b/private-cicd/scripts/fetch-jacoco.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Purpose: +# Copies the JaCoCo agent and command-line jars into a directory as +# jacocoagent.jar and jacococli.jar. The agent is attached to the management +# server on the lab VM, and the command line dumps and reports coverage. +# Jars already present in the directory are kept, so a second call is free. +# Dependencies: +# Usage: fetch-jacoco.sh DEST_DIR +# Optional: JACOCO_VERSION (default 0.8.11, the version pinned in the root pom). +# Commands: mvn with access to Maven Central or a mirror. +# Called by: run-phase2-remote.sh, coverage-report.sh. + +set -euo pipefail + +DEST_DIR="${1:?Usage: fetch-jacoco.sh DEST_DIR}" +JACOCO_VERSION="${JACOCO_VERSION:-0.8.11}" +DEPENDENCY_PLUGIN="org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy" + +mkdir -p "$DEST_DIR" +DEST_DIR="$(cd "$DEST_DIR" && pwd)" +if [[ -f "$DEST_DIR/jacocoagent.jar" && -f "$DEST_DIR/jacococli.jar" ]]; then + exit 0 +fi + +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +# The agent is the "runtime" classifier of org.jacoco.agent; the command line is +# the self-contained "nodeps" jar of org.jacoco.cli. +mvn -B -q "$DEPENDENCY_PLUGIN" \ + "-Dartifact=org.jacoco:org.jacoco.agent:${JACOCO_VERSION}:jar:runtime" \ + "-DoutputDirectory=${work_dir}/agent" -Dmdep.stripVersion=true +mvn -B -q "$DEPENDENCY_PLUGIN" \ + "-Dartifact=org.jacoco:org.jacoco.cli:${JACOCO_VERSION}:jar:nodeps" \ + "-DoutputDirectory=${work_dir}/cli" -Dmdep.stripVersion=true + +agent_jar="$(find "$work_dir/agent" -name '*.jar' -print -quit)" +cli_jar="$(find "$work_dir/cli" -name '*.jar' -print -quit)" +if [[ -z "$agent_jar" || -z "$cli_jar" ]]; then + echo "Could not fetch the JaCoCo ${JACOCO_VERSION} jars" >&2 + exit 1 +fi +cp "$agent_jar" "$DEST_DIR/jacocoagent.jar" +cp "$cli_jar" "$DEST_DIR/jacococli.jar" +echo "JaCoCo ${JACOCO_VERSION} jars are in $DEST_DIR" diff --git a/private-cicd/scripts/generate-presubmit-report.py b/private-cicd/scripts/generate-presubmit-report.py index 3067cfa26724..0799bb8ba41f 100644 --- a/private-cicd/scripts/generate-presubmit-report.py +++ b/private-cicd/scripts/generate-presubmit-report.py @@ -21,9 +21,11 @@ import argparse import html import json +import os import re import shutil -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from functools import lru_cache from pathlib import Path from urllib.parse import quote @@ -71,6 +73,16 @@ def parse_time(value): return datetime.fromisoformat(value.replace("Z", "+00:00")) +IST = timezone(timedelta(hours=5, minutes=30), "IST") + + +def format_ist(value): + """Show a parsed time in IST. Stored timestamps and durations stay UTC.""" + if value is None: + return "—" + return value.astimezone(IST).strftime("%Y-%m-%d %H:%M:%S IST") + + def format_duration(start, finish): if not start or not finish: return "—" @@ -121,7 +133,120 @@ def html_document(title, body): """ -def log_to_html(source, destination): +FAILURE_HEADER = re.compile(r"^(ERROR|FAIL):\s*(.*)$") +DESCRIBED_TEST = re.compile( + r"^(.*?)\s*\.\.\.\s*=== TestName:\s+(\S+)\s+\|\s+Status\s+:\s+(\S+)") +# The message ends at the quote that closes the errortext value: one followed by +# the closing brace or by the next "key :" pair. A quote inside the message, +# such as a quoted VM name, is kept. A message with no closing quote runs to the +# end of the line. +JOB_ERROR = re.compile( + r"errorcode\s*:\s*(\d+)\s*,\s*errortext\s*:\s*'" + r"(.*?)(?:'\s*(?:\}|,\s*\w+\s*:)|$)") +MAX_FAILURE_LINES = 80 +MAX_ERROR_CHARS = 400 + + +@lru_cache(maxsize=None) +def parse_suite_failures(results_txt): + """Return the ERROR and FAIL blocks of one Marvin suite results.txt. + + Marvin prints each block with the test's docstring as its title, while the + summary lines pair the same docstring with the test name, so the two are + joined on that text. A block that cannot be joined (for example a + setUpClass error) keeps an empty name list and is still shown. + """ + try: + text = ANSI_ESCAPE.sub( + "", results_txt.read_text(encoding="utf-8", errors="replace")) + except OSError: + return () + lines = text.splitlines() + names = {} + for line in lines: + described = DESCRIBED_TEST.match(line) + if described: + names.setdefault(described.group(1).strip(), []).append( + described.group(2)) + failures = [] + index = 0 + while index < len(lines): + header = FAILURE_HEADER.match(lines[index]) + if not (header and index > 0 and lines[index - 1].startswith("=====")): + index += 1 + continue + block = [] + index += 1 + while index < len(lines): + line = lines[index] + if (line.startswith("=====") or line.startswith("Ran ") + or ">> begin captured" in line): + break + block.append(line) + index += 1 + while block and (not block[0].strip() + or set(block[0].strip()) == {"-"}): + block.pop(0) + while block and (not block[-1].strip() + or set(block[-1].strip()) == {"-"}): + block.pop() + description = header.group(2).strip() + error = block[-1].strip() if block else "" + # A failed CloudStack async job prints a long object of account and + # domain IDs before the useful text, so lead with the job's own message. + job = JOB_ERROR.search(error) + if job: + error = f"Job failed (code {job.group(1)}): {job.group(2)}" + if len(error) > MAX_ERROR_CHARS: + error = error[:MAX_ERROR_CHARS] + "…" + failures.append({ + "kind": header.group(1), + "description": description, + "names": tuple(names.get(description, ())), + "lines": tuple(block[:MAX_FAILURE_LINES]), + "error": error, + }) + return tuple(failures) + + +def failure_anchor(test_name): + return f"fail-{slug(test_name)}" + + +def render_failures(failures, related=()): + if not failures: + return "" + sections = [] + if related: + links = " · ".join( + f'{html.escape(label)}' + for label, path in related) + sections.append( + "

The API error above is often only a symptom. The cause is " + f"usually in the CloudStack logs: {links}

") + for number, failure in enumerate(failures, 1): + names = failure["names"] or (f"unmapped-{number}",) + anchors = "".join( + f'' for name in names) + title = ", ".join(failure["names"]) or failure["description"] + sections.append( + f'{anchors}

' + f'{html.escape(failure["kind"])}: {html.escape(title)}

' + f'
'
+            f'{html.escape(chr(10).join(failure["lines"]))}
') + return "

Failure details

" + "".join(sections) + "

Suite log

" + + +def failure_for_test(summary_path, test): + suite_dir = summary_path.parent / "suites" / test.get("tag", "") + for failure in parse_suite_failures(suite_dir / "results.txt"): + if test.get("name") in failure["names"]: + return failure + return None + + +def log_to_html(source, destination, failures=(), related=()): lines = source.read_text(encoding="utf-8", errors="replace").splitlines() rendered = [] test_pattern = re.compile(r"TestName:\s+(\S+)\s+\|\s+Status\s+:\s+(\S+)") @@ -154,7 +279,8 @@ def log_to_html(source, destination): rendered.append(f"{anchor}{escaped}") body = ( f"

{html.escape(source.name)}

" - f'
'
         + "\n".join(rendered)
         + "
" @@ -175,6 +301,12 @@ def generate_log_artifacts(results, debian_log): results / "phase2" / "configure-cloudstack.log", results / "phase2" / "health-check.log", ] + runtime = results / "phase2" / "runtime-logs" + related_logs = ( + ("Management server log", + runtime / "var-log-cloudstack-management" / "management-server.log"), + ("Agent log", runtime / "var-log-cloudstack-agent" / "agent.log"), + ) marvin = results / "phase2" / "marvin" candidates.extend(marvin.glob("*.log")) candidates.extend( @@ -183,7 +315,15 @@ def generate_log_artifacts(results, debian_log): for source in sorted(set(candidates)): if not source.is_file(): continue - log_to_html(source, Path(f"{source}.html")) + failures = ( + parse_suite_failures(source.with_name("results.txt")) + if source.name == "stdout.log" else ()) + destination = Path(f"{source}.html") + related = tuple( + (label, os.path.relpath(path, destination.parent)) + for label, path in related_logs if path.is_file() + ) if failures else () + log_to_html(source, destination, failures, related) def parse_stage_events(stage_events, results): @@ -263,8 +403,7 @@ def stage_rows(events, results, build_url): log_link = ( f'' "Console") - started_text = ( - started.strftime("%Y-%m-%d %H:%M:%S UTC") if started else "—") + started_text = format_ist(started) rows.append( "" f"{html.escape(stage)}" @@ -339,13 +478,26 @@ def test_rows(tests, results, build_url): for test in tests: status = display_test_status(test.get("status")) log_path = test_log_for_summary(test["_summary"], test) + failure = None + if status not in ("PASS", "SKIP"): + failure = failure_for_test(test["_summary"], test) if log_path: relative = log_path.relative_to(results.parent) - link = artifact_url( - build_url, relative, f"test-{slug(test.get('name', 'test'))}") + name = test.get("name", "test") + # The failure section sits at the top of the suite page, so a + # failed test links straight to its exception. + fragment = ( + failure_anchor(name) + if failure and log_path.name == "stdout.log.html" + else f"test-{slug(name)}") + link = artifact_url(build_url, relative, fragment) log_cell = f'HTML log' else: log_cell = "—" + error_cell = ( + f'' + f'{html.escape(failure["error"])}' + if failure and failure["error"] else "") suite = str(test.get("label") or test.get("tag") or "") rows.append( "" @@ -353,6 +505,7 @@ def test_rows(tests, results, build_url): f"{html.escape(suite)}" f"{html.escape(str(test.get('name', '')))}" f'{html.escape(status)}' + f"{error_cell}" f"{log_cell}" "") return "\n".join(rows) @@ -374,6 +527,14 @@ def table(headers, rows): f"{cells}{styled_rows}") +def coverage_section(results): + """The ONTAP coverage summary written by coverage-report.sh, if any.""" + try: + return (results / "coverage" / "summary.html").read_text(encoding="utf-8") + except OSError: + return "" + + def main(): args = parse_args() args.results.mkdir(parents=True, exist_ok=True) @@ -415,9 +576,9 @@ def main(): VM {html.escape(args.vm or 'not assigned')} Started -{html.escape(args.started_at)} +{html.escape(format_ist(started))} Finished -{html.escape(args.finished_at)} +{html.escape(format_ist(finished))} Total duration {html.escape(format_duration(started, finished))} @@ -437,12 +598,13 @@ def main(): "

ONTAP tests

" "

Failures are listed first. Select a log link to jump to the " "test.

" - + table(["Protocol", "Suite", "Test", "Status", "Details"], tests_html) + + table(["Protocol", "Suite", "Test", "Status", "Error", "Details"], + tests_html) if tests else "

ONTAP tests

No Marvin test summary was available.

") body = ( - header + failure + "

Pipeline stages

" + - stage_table + test_section) + header + failure + coverage_section(args.results) + + "

Pipeline stages

" + stage_table + test_section) document = html_document("CloudStack ONTAP presubmit report", body) (args.results / "report.html").write_text(document, encoding="utf-8") (args.results / "email-body.html").write_text(body, encoding="utf-8") diff --git a/private-cicd/scripts/github-check-run.sh b/private-cicd/scripts/github-check-run.sh index e00821cede8c..b5161b0fc4cb 100755 --- a/private-cicd/scripts/github-check-run.sh +++ b/private-cicd/scripts/github-check-run.sh @@ -22,6 +22,8 @@ # that run with a conclusion, commit-email prints the head commit author # address used for presubmit mail, and diagnose probes the App's token type, # repository access and Checks permissions to explain a refused check run. +# coverage-comment creates or updates the single pull-request comment that +# shows the ONTAP plugin coverage (needs Pull requests or Issues write). # Dependencies: # Commands: curl, python3 (stdlib), mktemp. # GITHUB_ACCESS_TOKEN, GITHUB_REPOSITORY — required by every subcommand; the @@ -29,7 +31,14 @@ # process list. # GITHUB_HEAD_SHA — create, commit-email and diagnose. BUILD_URL — create and # complete. GITHUB_CHECK_RUN_ID, GITHUB_CHECK_CONCLUSION — complete. -# Optional: PR_ID and BUILD_TAG label the check run. +# Optional: PR_ID and BUILD_TAG label the check run. For create, setting +# GITHUB_CHECK_CONCLUSION publishes an already completed check run, which is +# how a result is still reported when the run could not be created at the +# start. GITHUB_CHECK_FAILED_STAGE and GITHUB_CHECK_REPORT_URL add the failed +# stage and the report link to the check's summary, and GITHUB_CHECK_COVERAGE_FILE +# appends the coverage summary (markdown) to it. coverage-comment needs PR_ID +# and GITHUB_COVERAGE_FILE, the markdown file whose first line is the marker +# that identifies the comment. # Called by the Jenkinsfile. Does not call other private-cicd scripts. set -euo pipefail @@ -129,35 +138,81 @@ with open(sys.argv[1], encoding="utf-8") as handle: PY } -create_check() { - require_env GITHUB_ACCESS_TOKEN GITHUB_REPOSITORY GITHUB_HEAD_SHA BUILD_URL - local payload response - payload="$(mktemp)" - response="$(mktemp)" - trap "rm -f '$payload' '$response'" EXIT - - python3 - "$payload" <<'PY' +# Builds the check-run payload for the "create" and "complete" subcommands from +# the environment. GITHUB_CHECK_CONCLUSION makes it a completed run. +write_payload() { + python3 - "$1" "$2" <<'PY' import json import os import sys +kind = sys.argv[2] +env = os.environ.get +conclusion = env("GITHUB_CHECK_CONCLUSION", "") +build_url = env("BUILD_URL", "") +sha = env("GITHUB_HEAD_SHA", "") +pr = env("PR_ID", "unknown") + payload = { "name": "cloudstack-ontap-presubmit", - "head_sha": os.environ["GITHUB_HEAD_SHA"], - "status": "in_progress", - "details_url": os.environ["BUILD_URL"], - "external_id": os.environ.get("BUILD_TAG", ""), - "output": { - "title": f"CloudStack ONTAP presubmit for PR-{os.environ.get('PR_ID', 'unknown')}", + "details_url": build_url, +} +if kind == "create": + payload["head_sha"] = sha + payload["external_id"] = env("BUILD_TAG", "") + +if kind == "create" and not conclusion: + payload["status"] = "in_progress" + payload["output"] = { + "title": f"CloudStack ONTAP presubmit for PR-{pr}", "summary": ( - f"Jenkins build started for {os.environ['GITHUB_HEAD_SHA'][:12]}. " - f"Details: {os.environ['BUILD_URL']}" + f"Jenkins build started for {sha[:12]}. " + f"Details: {build_url}" ), - }, -} + } +else: + verdict = { + "success": "passed", + "failure": "failed", + "cancelled": "was cancelled", + "timed_out": "timed out", + "neutral": "finished with a neutral result", + }.get(conclusion, conclusion) + lines = [f"The CloudStack ONTAP presubmit {verdict}."] + if env("GITHUB_CHECK_FAILED_STAGE"): + lines.append(f"Failed stage: {env('GITHUB_CHECK_FAILED_STAGE')}") + if sha: + lines.append(f"Commit: {sha[:12]}") + lines.append(f"Jenkins build and logs: {build_url}") + if env("GITHUB_CHECK_REPORT_URL"): + lines.append(f"Test report: {env('GITHUB_CHECK_REPORT_URL')}") + summary = "\n\n".join(lines) + coverage_file = env("GITHUB_CHECK_COVERAGE_FILE") + if coverage_file and os.path.isfile(coverage_file): + with open(coverage_file, encoding="utf-8") as handle: + coverage = handle.read().replace("", "").strip() + if coverage: + summary += "\n\n" + coverage + # GitHub rejects a summary above 65535 characters. + payload["status"] = "completed" + payload["conclusion"] = conclusion + payload["output"] = { + "title": f"CloudStack ONTAP presubmit {verdict}", + "summary": summary[:60000], + } with open(sys.argv[1], "w", encoding="utf-8") as output: json.dump(payload, output) PY +} + +create_check() { + require_env GITHUB_ACCESS_TOKEN GITHUB_REPOSITORY GITHUB_HEAD_SHA BUILD_URL + local payload response + payload="$(mktemp)" + response="$(mktemp)" + trap "rm -f '$payload' '$response'" EXIT + + write_payload "$payload" create github_api POST "/repos/${GITHUB_REPOSITORY}/check-runs" "$payload" > "$response" || { local status=$? @@ -178,41 +233,28 @@ PY complete_check() { require_env GITHUB_ACCESS_TOKEN GITHUB_REPOSITORY GITHUB_CHECK_RUN_ID \ GITHUB_CHECK_CONCLUSION BUILD_URL - local payload response + local payload response attempt payload="$(mktemp)" response="$(mktemp)" trap "rm -f '$payload' '$response'" EXIT - python3 - "$payload" <<'PY' -import json -import os -import sys + write_payload "$payload" complete -conclusion = os.environ["GITHUB_CHECK_CONCLUSION"] -payload = { - "name": "cloudstack-ontap-presubmit", - "status": "completed", - "conclusion": conclusion, - "details_url": os.environ["BUILD_URL"], - "output": { - "title": f"CloudStack ONTAP presubmit: {conclusion}", - "summary": ( - f"Jenkins build completed with conclusion {conclusion}. " - f"Details and logs: {os.environ['BUILD_URL']}" - ), - }, -} -with open(sys.argv[1], "w", encoding="utf-8") as output: - json.dump(payload, output) -PY - - if ! github_api PATCH \ - "/repos/${GITHUB_REPOSITORY}/check-runs/${GITHUB_CHECK_RUN_ID}" \ - "$payload" > "$response"; then - echo "GitHub refused to complete the check run:" >&2 + # A check left in progress blocks a pull request that requires it, so a + # transient GitHub error gets a few retries before the failure is reported. + for attempt in 1 2 3; do + if github_api PATCH \ + "/repos/${GITHUB_REPOSITORY}/check-runs/${GITHUB_CHECK_RUN_ID}" \ + "$payload" > "$response"; then + return 0 + fi + echo "GitHub refused to complete the check run (attempt ${attempt}/3):" >&2 print_github_error "$response" - return 1 - fi + if (( attempt < 3 )); then + sleep "${CHECK_RETRY_SECONDS:-10}" + fi + done + return 1 } # Read-only probes plus one write probe, ordered so that the first failure @@ -357,6 +399,78 @@ print(payload.get("commit", {}).get("author", {}).get("email") or "") PY } +# Creates the coverage comment on the pull request, or updates the one a +# previous run left, so a PR carries a single coverage comment. The comment is +# found by the marker on its first line, among comments written by a bot. +coverage_comment() { + require_env GITHUB_ACCESS_TOKEN GITHUB_REPOSITORY PR_ID GITHUB_COVERAGE_FILE + local comments payload response comment_id page + comments="$(mktemp)" + payload="$(mktemp)" + response="$(mktemp)" + trap "rm -f '$comments' '$payload' '$response'" EXIT + + python3 - "$GITHUB_COVERAGE_FILE" "$payload" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + body = source.read() +with open(sys.argv[2], "w", encoding="utf-8") as output: + json.dump({"body": body[:60000]}, output) +PY + + comment_id="" + for page in 1 2 3 4 5; do + github_api GET \ + "/repos/${GITHUB_REPOSITORY}/issues/${PR_ID}/comments?per_page=100&page=${page}" \ + > "$comments" || { + echo "GitHub refused to list the pull request comments:" >&2 + print_github_error "$comments" + return 1 + } + comment_id="$(python3 - "$comments" <<'PY' +import json +import sys + +marker = "" +with open(sys.argv[1], encoding="utf-8") as handle: + items = json.load(handle) +for item in items: + if (item.get("user") or {}).get("type") == "Bot" and marker in (item.get("body") or ""): + print(item["id"]) + break +PY +)" + if [[ -n "$comment_id" ]]; then + break + fi + # A full page means there may be more comments to search. + if [[ "$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$comments")" -lt 100 ]]; then + break + fi + done + + if [[ -n "$comment_id" ]]; then + github_api PATCH "/repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" \ + "$payload" > "$response" || { + echo "GitHub refused to update the coverage comment:" >&2 + print_github_error "$response" + return 1 + } + echo "Updated coverage comment ${comment_id} on PR-${PR_ID}." + else + github_api POST "/repos/${GITHUB_REPOSITORY}/issues/${PR_ID}/comments" \ + "$payload" > "$response" || { + echo "GitHub refused to create the coverage comment:" >&2 + print_github_error "$response" + echo " The App needs Pull requests or Issues: Read and write." >&2 + return 1 + } + echo "Created coverage comment on PR-${PR_ID}." + fi +} + case "${1:-}" in create) create_check @@ -370,8 +484,11 @@ case "${1:-}" in diagnose) diagnose_app ;; + coverage-comment) + coverage_comment + ;; *) - echo "Usage: $0 {create|complete|commit-email|diagnose}" >&2 + echo "Usage: $0 {create|complete|commit-email|diagnose|coverage-comment}" >&2 exit 2 ;; esac diff --git a/private-cicd/scripts/marvin-run.sh b/private-cicd/scripts/marvin-run.sh index 9e2996a9c74d..8c375ebdc5a5 100755 --- a/private-cicd/scripts/marvin-run.sh +++ b/private-cicd/scripts/marvin-run.sh @@ -58,6 +58,24 @@ run_suite() { return "$status" } +# Functional-test coverage: dump (and reset) the JaCoCo data of the management +# server after a suite, so a later restart cannot lose it. The data is only +# collected when the agent was attached; a failed dump never fails the run. +dump_coverage() { + local name="$1" + if [[ -z "${JACOCO_CLI:-}" || ! -f "$JACOCO_CLI" || -z "${COVERAGE_DIR:-}" ]]; then + return 0 + fi + mkdir -p "$COVERAGE_DIR" + if java -jar "$JACOCO_CLI" dump --address 127.0.0.1 \ + --port "${COVERAGE_PORT:-36320}" --reset \ + --destfile "$COVERAGE_DIR/${name}.exec"; then + echo "Functional coverage for ${name}: $COVERAGE_DIR/${name}.exec" + else + echo "Could not dump functional coverage for ${name}" >&2 + fi +} + echo "==> Creating and validating the CloudStack zone" if ! run_suite setup_zone; then echo "Zone setup failed; protocol suites cannot run" >&2 @@ -67,9 +85,11 @@ fi suite_status=0 echo "==> Running ONTAP iSCSI suites" run_suite iscsi || suite_status=1 +dump_coverage iscsi echo "==> Running ONTAP NFS3 suites" run_suite nfs3 || suite_status=1 +dump_coverage nfs3 ontap_results=test/integration/plugins/ontap/results if [[ -d "$ontap_results" ]]; then diff --git a/private-cicd/scripts/mvn-full.sh b/private-cicd/scripts/mvn-full.sh index 7aa9821a1d24..c9bb3add4715 100755 --- a/private-cicd/scripts/mvn-full.sh +++ b/private-cicd/scripts/mvn-full.sh @@ -25,7 +25,11 @@ # RESULT_DIR — if set, SOURCE_ID and SOURCE_SHA are required (Jenkins handoff); # PR_ID and PR_HEAD_SHA are accepted as fallbacks for pull-request builds. # Optional: SKIP_TESTS, ENABLE_NOREDIST, ENABLE_SIMULATOR (default true), -# MAVEN_THREADS (Jenkins uses 1C; empty disables -T; unset uses nproc). +# MAVEN_THREADS (Jenkins uses 1C; empty disables -T; unset uses nproc), +# ONTAP_COVERAGE=true also writes the ONTAP plugin JaCoCo report to +# $RESULT_DIR/coverage/unit, +# RERUN_FAILING_TESTS (default 0; Jenkins uses 2 so a timing-flaky upstream +# test is retried before it fails the run). # Commands: mvn, java. Does not call other private-cicd scripts. # When RESULT_DIR is set, apply debian/rules packaging flags so build-debs.sh # can reuse target/ without a second mvn clean. @@ -43,6 +47,8 @@ SKIP_TESTS="${SKIP_TESTS:-false}" ENABLE_NOREDIST="${ENABLE_NOREDIST:-false}" ENABLE_SIMULATOR="${ENABLE_SIMULATOR:-true}" THREADS="${MAVEN_THREADS-$(nproc)}" +RERUN_FAILING_TESTS="${RERUN_FAILING_TESTS:-0}" +ONTAP_COVERAGE="${ONTAP_COVERAGE:-false}" RESULT_DIR="${RESULT_DIR:-}" mvn_args=(-B -P developer,systemvm) @@ -59,6 +65,14 @@ fi if [[ -n "$THREADS" ]]; then mvn_args+=(-T"$THREADS") fi +if [[ "$RERUN_FAILING_TESTS" != "0" ]]; then + mvn_args+=("-Dsurefire.rerunFailingTestsCount=$RERUN_FAILING_TESTS") +fi +if [[ "$ONTAP_COVERAGE" == "true" ]]; then + # Activates the ontap-coverage profile of the ONTAP plugin module, so the + # same build that runs its unit tests also writes the JaCoCo report. + mvn_args+=(-Dontap.coverage=true) +fi echo "==> Full build in $SOURCE_DIR" @@ -112,6 +126,18 @@ if [[ "$report_count" -gt 0 ]]; then | tar --null -czf "$RESULT_DIR/surefire-reports.tar.gz" --files-from - fi +# JaCoCo report written by the ontap-coverage profile of the ONTAP plugin. +ontap_coverage_dir="plugins/storage/volume/ontap/target/site/jacoco" +if [[ "$ONTAP_COVERAGE" == "true" ]]; then + if [[ -f "$ontap_coverage_dir/jacoco.xml" ]]; then + mkdir -p "$RESULT_DIR/coverage" + cp -a "$ontap_coverage_dir" "$RESULT_DIR/coverage/unit" + echo "ONTAP unit-test coverage report: $RESULT_DIR/coverage/unit/jacoco.xml" + else + echo "ONTAP unit-test coverage was requested but $ontap_coverage_dir/jacoco.xml was not produced" >&2 + fi +fi + if [[ "$maven_status" -eq 0 ]]; then result="SUCCESS" else diff --git a/private-cicd/scripts/ontap-coverage.py b/private-cicd/scripts/ontap-coverage.py new file mode 100644 index 000000000000..1e711d3e52c6 --- /dev/null +++ b/private-cicd/scripts/ontap-coverage.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Purpose: +# Summarise ONTAP storage plugin coverage for one presubmit run. Reads the +# JaCoCo XML reports of the unit tests (produced by the Maven build) and of +# the functional Marvin tests (produced from the management server's JaCoCo +# dump), and writes summary.json, summary.md and summary.html to --output-dir. +# The combined figure is computed per source line, so it does not depend on +# the two reports agreeing on class IDs: a line counts as covered when either +# run executed it. +# Dependencies: +# Python 3 standard library only. +# Called by: coverage-report.sh. Read by generate-presubmit-report.py and the +# Jenkinsfile, which publish the summary in the mail, the report, the GitHub +# Check and a pull-request comment. + +import argparse +import html +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +# Marks the pull-request comment so a later run updates it in place. +COMMENT_MARKER = "" +TITLE = "ONTAP storage plugin test coverage" +COUNTERS = ("LINE", "BRANCH", "METHOD", "CLASS") +DOCTYPE = re.compile(r"]*>") + + +def parse_report(path): + """Return {"totals": {...}, "files": {...}} for one JaCoCo XML report. + + files maps "package/File.java" to {"lines": {nr: (missed, covered)}}. + Returns None when the file is missing or is not a JaCoCo report. + """ + try: + text = Path(path).read_text(encoding="utf-8", errors="replace") + root = ET.fromstring(DOCTYPE.sub("", text, count=1)) + except (OSError, ET.ParseError): + return None + if root.tag != "report": + return None + totals = {} + for counter in root.findall("counter"): + kind = counter.get("type") + if kind in COUNTERS: + totals[kind] = (int(counter.get("missed", 0)), int(counter.get("covered", 0))) + files = {} + for package in root.findall("package"): + package_name = package.get("name", "") + for source in package.findall("sourcefile"): + lines = {} + for line in source.findall("line"): + lines[int(line.get("nr"))] = ( + int(line.get("mi", 0)), int(line.get("ci", 0))) + files[f"{package_name}/{source.get('name')}"] = {"lines": lines} + return {"totals": totals, "files": files} + + +def percent(covered, missed): + total = covered + missed + return round(100.0 * covered / total, 1) if total else None + + +def counter_summary(totals, kind): + missed, covered = totals.get(kind, (0, 0)) + return {"covered": covered, "missed": missed, "percent": percent(covered, missed)} + + +def covered_line_numbers(file_entry): + return {nr for nr, (_, covered) in file_entry["lines"].items() if covered > 0} + + +def combine(unit, functional): + """Line coverage of the union of both runs, plus a row for every file.""" + names = set() + for report in (unit, functional): + if report: + names.update(report["files"]) + rows = [] + total_lines = total_covered = 0 + for name in sorted(names): + line_sets = [] + all_lines = set() + for report in (unit, functional): + entry = report["files"].get(name) if report else None + if entry: + all_lines.update(entry["lines"]) + line_sets.append(covered_line_numbers(entry)) + else: + line_sets.append(set()) + if not all_lines: + continue + unit_covered, functional_covered = line_sets + combined_covered = unit_covered | functional_covered + rows.append({ + "file": name, + "lines": len(all_lines), + "unit": percent(len(unit_covered), len(all_lines) - len(unit_covered)) + if unit else None, + "functional": percent(len(functional_covered), + len(all_lines) - len(functional_covered)) + if functional else None, + "combined": percent(len(combined_covered), + len(all_lines) - len(combined_covered)), + "missed": len(all_lines) - len(combined_covered), + }) + total_lines += len(all_lines) + total_covered += len(combined_covered) + return { + "covered": total_covered, + "missed": total_lines - total_covered, + "percent": percent(total_covered, total_lines - total_covered), + }, rows + + +def build_summary(unit, functional, top, notes): + combined, rows = combine(unit, functional) + worst = sorted(rows, key=lambda row: (-row["missed"], row["file"])) + return { + "title": TITLE, + "unit": ({kind.lower(): counter_summary(unit["totals"], kind) for kind in COUNTERS} + if unit else None), + "functional": ({kind.lower(): counter_summary(functional["totals"], kind) + for kind in COUNTERS} if functional else None), + "combined_line": combined if (unit or functional) else None, + "least_covered": [row for row in worst if row["missed"] > 0][:top], + "notes": notes, + } + + +def fmt(value): + return "n/a" if value is None else f"{value:.1f}%" + + +def cell(section, kind): + return fmt(section[kind]["percent"]) if section else "not available" + + +def render_markdown(summary, links): + unit, functional = summary["unit"], summary["functional"] + combined = summary["combined_line"] + lines = [COMMENT_MARKER, f"### {summary['title']}", ""] + lines += [ + "| | Line | Branch | Method |", + "| --- | --- | --- | --- |", + f"| Unit tests | {cell(unit, 'line')} | {cell(unit, 'branch')} | {cell(unit, 'method')} |", + f"| Functional (Marvin) tests | {cell(functional, 'line')} | " + f"{cell(functional, 'branch')} | {cell(functional, 'method')} |", + f"| **Combined** | **{fmt(combined['percent']) if combined else 'not available'}** | | |", + "", + ] + if summary["least_covered"]: + lines += ["Least covered files (by lines not covered by either run):", "", + "| File | Lines | Unit | Functional | Combined |", + "| --- | --- | --- | --- | --- |"] + for row in summary["least_covered"]: + name = row["file"].replace("org/apache/cloudstack/storage/", "") + lines.append(f"| `{name}` | {row['lines']} | {fmt(row['unit'])} | " + f"{fmt(row['functional'])} | {fmt(row['combined'])} |") + lines.append("") + for note in summary["notes"]: + lines += [f"> {note}", ""] + for label, url in links: + if url: + lines.append(f"[{label}]({url}) ") + return "\n".join(lines).rstrip() + "\n" + + +def render_html(summary, links): + unit, functional = summary["unit"], summary["functional"] + combined = summary["combined_line"] + th = "text-align:left;background:#f2f4f7;border:1px solid #d0d5dd;padding:6px 10px" + td = "border:1px solid #d0d5dd;padding:6px 10px" + + def row(label, section, bold=False): + cells = [cell(section, kind) for kind in ("line", "branch", "method")] + style = td + (";font-weight:bold" if bold else "") + return (f'{html.escape(label)}' + + "".join(f'{html.escape(value)}' for value in cells) + + "") + + parts = [f'

{html.escape(summary["title"])}

', + '', + f'' + f'', + row("Unit tests", unit), row("Functional (Marvin) tests", functional), + f'' + f'' + f'', + "
LineBranchMethod
Combined' + f'{html.escape(fmt(combined["percent"]) if combined else "not available")}
"] + if summary["least_covered"]: + parts += ['

Least covered files ' + '(lines not covered by either run):

', + '', + f'' + f'' + f''] + for item in summary["least_covered"]: + name = item["file"].replace("org/apache/cloudstack/storage/", "") + parts.append( + f'' + f'' + f'' + f'' + f'') + parts.append("
FileLinesUnitFunctionalCombined
{html.escape(name)}{item["lines"]}{html.escape(fmt(item["unit"]))}{html.escape(fmt(item["functional"]))}{html.escape(fmt(item["combined"]))}
") + for note in summary["notes"]: + parts.append(f'

{html.escape(note)}

') + link_html = " | ".join( + f'{html.escape(label)}' + for label, url in links if url) + if link_html: + parts.append(f'

{link_html}

') + return "\n".join(parts) + "\n" + + +def parse_args(argv): + parser = argparse.ArgumentParser( + description="Summarise ONTAP plugin unit and functional test coverage.") + parser.add_argument("--unit", help="JaCoCo XML report of the unit tests") + parser.add_argument("--functional", help="JaCoCo XML report of the functional tests") + parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--top", type=int, default=10, + help="number of least covered files to list") + parser.add_argument("--unit-link", default="", help="URL of the unit coverage report") + parser.add_argument("--functional-link", default="", + help="URL of the functional coverage report") + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + notes = [] + unit = parse_report(args.unit) if args.unit else None + functional = parse_report(args.functional) if args.functional else None + if unit is None: + notes.append("Unit-test coverage was not produced by this build.") + if functional is None: + notes.append("Functional-test coverage was not produced by this run.") + summary = build_summary(unit, functional, args.top, notes) + links = [("Unit coverage report", args.unit_link), + ("Functional coverage report", args.functional_link)] + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / "summary.json").write_text( + json.dumps(summary, indent=2), encoding="utf-8") + (args.output_dir / "summary.md").write_text( + render_markdown(summary, links), encoding="utf-8") + (args.output_dir / "summary.html").write_text( + render_html(summary, links), encoding="utf-8") + combined = summary["combined_line"] + print("ONTAP coverage: unit line %s, functional line %s, combined line %s" % ( + cell(summary["unit"], "line"), cell(summary["functional"], "line"), + fmt(combined["percent"]) if combined else "not available")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/private-cicd/scripts/resolve-pull-request.py b/private-cicd/scripts/resolve-pull-request.py new file mode 100644 index 000000000000..9f2200916958 --- /dev/null +++ b/private-cicd/scripts/resolve-pull-request.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Decide whether a multibranch build may test one origin pull request.""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +API_ROOT = "https://api.github.com" + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--pr-id", required=True) + parser.add_argument("--indexed-sha", required=True) + parser.add_argument("--change-target", required=True) + parser.add_argument("--output", required=True) + return parser.parse_args() + + +def github_get(url, token): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "cloudstack-ontap-presubmit", + }, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace") + raise SystemExit(f"GitHub API {error.code} for {url}: {detail}") from error + + +def is_main(ref): + return ref in ("main", "refs/heads/main") + + +def assess(pull, pr_id, indexed_sha, change_target, expected_repository): + head = (pull or {}).get("head") or {} + base = (pull or {}).get("base") or {} + user = (pull or {}).get("user") or {} + repository = head.get("repo") or {} + decision = { + "eligible": False, + "reason": "", + "pr_id": str((pull or {}).get("number") or ""), + "pr_head_sha": head.get("sha") or "", + "pr_head_branch": head.get("ref") or "", + "pr_base_branch": base.get("ref") or "", + "pr_state": (pull or {}).get("state") or "", + "pr_draft": bool((pull or {}).get("draft")), + "pr_title": (pull or {}).get("title") or "", + "pr_url": (pull or {}).get("html_url") or "", + "pr_author_login": user.get("login") or "", + "pr_author_email": user.get("email") or "", + "head_repository": repository.get("full_name") or "", + } + if not isinstance(pull, dict): + decision["reason"] = "GitHub pull request payload is missing." + elif decision["pr_id"] != str(pr_id): + decision["reason"] = "GitHub pull request number does not match the build." + elif decision["head_repository"] != expected_repository: + decision["reason"] = ( + f"head repository '{decision['head_repository']}' is not " + f"'{expected_repository}'." + ) + elif not is_main(decision["pr_base_branch"]) or not is_main(change_target): + decision["reason"] = ( + f"base branch is '{decision['pr_base_branch']}' and change target " + f"is '{change_target}'." + ) + elif decision["pr_state"] != "open": + decision["reason"] = ( + f"pull request is not open (state: {decision['pr_state'] or 'unknown'}).") + elif decision["pr_draft"]: + decision["reason"] = "pull request is a draft." + elif not decision["pr_head_sha"] or len(decision["pr_head_sha"]) != 40: + decision["reason"] = "GitHub pull request head SHA is missing." + elif (indexed_sha or "").strip() and decision["pr_head_sha"].lower() != indexed_sha.strip().lower(): + decision["reason"] = "indexed revision does not match the pull request head SHA." + else: + decision["eligible"] = True + return decision + + +def main(): + args = parse_args() + token = os.environ.get("GITHUB_ACCESS_TOKEN", "").strip() + if not token: + raise SystemExit("GITHUB_ACCESS_TOKEN is empty.") + if not args.pr_id.isdigit(): + raise SystemExit("PR id must be numeric.") + pull = github_get( + f"{API_ROOT}/repos/{args.repository}/pulls/{args.pr_id}", + token, + ) + decision = assess( + pull, + args.pr_id, + args.indexed_sha, + args.change_target, + args.repository, + ) + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(decision, handle) + handle.write("\n") + if not decision["eligible"]: + print(decision["reason"], file=sys.stderr) + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/private-cicd/scripts/run-phase2-remote.sh b/private-cicd/scripts/run-phase2-remote.sh index 1669cec166ac..24ca017a9ab0 100755 --- a/private-cicd/scripts/run-phase2-remote.sh +++ b/private-cicd/scripts/run-phase2-remote.sh @@ -105,6 +105,17 @@ fi mkdir -p "$assets_root/test/integration/plugins" "$assets_root/marvin" cp -a "$SOURCE_DIR/test/integration/plugins/ontap" "$assets_root/test/integration/plugins/" cp "${marvin_archives[0]}" "$assets_root/marvin/" + +# Functional-test coverage needs the JaCoCo agent and command line on the VM. +# If they cannot be fetched the run goes on without coverage. +functional_coverage="${FUNCTIONAL_COVERAGE:-false}" +if [[ "$functional_coverage" == "true" ]]; then + if ! "$SCRIPT_DIR/fetch-jacoco.sh" "$assets_root/jacoco"; then + echo "Could not fetch the JaCoCo jars; functional coverage is off." >&2 + rm -rf "$assets_root/jacoco" + functional_coverage=false + fi +fi tar -czf "$assets_archive" -C "$assets_root" . remote="${VM_SSH_USER}@${VM_HOST}" @@ -189,8 +200,8 @@ ssh "${ssh_options[@]}" "$remote" \ chmod +x '$remote_scripts/'*.sh" printf -v remote_command \ - 'PHASE2_ROOT=%q BRIDGE=%q %q/run-phase2-vm.sh' \ - "$remote_root" "$BRIDGE" "$remote_scripts" + 'PHASE2_ROOT=%q BRIDGE=%q FUNCTIONAL_COVERAGE=%q %q/run-phase2-vm.sh' \ + "$remote_root" "$BRIDGE" "$functional_coverage" "$remote_scripts" set +e ssh "${ssh_options[@]}" "$remote" "$remote_command" diff --git a/private-cicd/scripts/run-phase2-vm.sh b/private-cicd/scripts/run-phase2-vm.sh index e24624b59e67..47aee947e698 100755 --- a/private-cicd/scripts/run-phase2-vm.sh +++ b/private-cicd/scripts/run-phase2-vm.sh @@ -68,10 +68,22 @@ on_exit() { } trap on_exit EXIT +# Functional-test coverage is on only when it was requested and the pipeline +# shipped the JaCoCo jars. Empty values leave it off. +coverage_agent="" +coverage_cli="" +if [[ "${FUNCTIONAL_COVERAGE:-false}" == "true" \ + && -f "$source_dir/jacoco/jacocoagent.jar" \ + && -f "$source_dir/jacoco/jacococli.jar" ]]; then + coverage_agent="$source_dir/jacoco/jacocoagent.jar" + coverage_cli="$source_dir/jacoco/jacococli.jar" +fi + DEB_DIR="$deb_dir" \ SECRETS_FILE="$secrets_file" \ ONTAP_CONFIG="$ontap_config" \ BRIDGE="$BRIDGE" \ +JACOCO_AGENT_JAR="$coverage_agent" \ "$scripts_dir/configure-cloudstack-vm.sh" \ 2>&1 | tee "$result_dir/configure-cloudstack.log" @@ -84,4 +96,6 @@ BRIDGE="$BRIDGE" \ CLOUDSTACK_DIR="$source_dir" \ PHASE2_RESULT_DIR="$result_dir/marvin" \ MARVIN_ARCHIVE="$marvin_archive" \ +JACOCO_CLI="$coverage_cli" \ +COVERAGE_DIR="$result_dir/coverage" \ "$scripts_dir/marvin-run.sh" diff --git a/private-cicd/scripts/test_generate_presubmit_report.py b/private-cicd/scripts/test_generate_presubmit_report.py new file mode 100644 index 000000000000..b35eeac0dba4 --- /dev/null +++ b/private-cicd/scripts/test_generate_presubmit_report.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +SPEC = importlib.util.spec_from_file_location( + "generate_presubmit_report", + Path(__file__).with_name("generate-presubmit-report.py")) +report = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(report) + +SEP = "=" * 70 +DASH = "-" * 70 +RESULTS = f"""Start the VM. ... === TestName: test_06_start | Status : EXCEPTION === +ERROR +Check the pool. ... === TestName: test_07_pool | Status : FAIL === +FAIL +Detach it. ... === TestName: test_08_detach | Status : SUCCESS === +ok + +{SEP} +ERROR: Start the VM. +{DASH} +Traceback (most recent call last): + File "test.py", line 750, in test_06_start + self.apiClient.startVirtualMachine(cmd) +Exception: Job failed: {{errortext : 'Unable to orchestrate the start of VM'}} +-------------------- >> begin captured logging << -------------------- +captured line that must not be shown +--------------------- >> end captured logging << --------------------- + +{SEP} +FAIL: Check the pool. +{DASH} +Traceback (most recent call last): + File "test.py", line 90, in test_07_pool + self.assertEqual(a, b) +AssertionError: 1 != 2 +{DASH} +Ran 3 tests in 1.0s + +FAILED (errors=1, failures=1) +""" +SETUP_ERROR = f"""{SEP} +ERROR: setUpClass (suite.Class) +{DASH} +Traceback (most recent call last): +RuntimeError: pool create failed +{DASH} +Ran 0 tests in 0.1s +""" + + +class ParseSuiteFailuresTest(unittest.TestCase): + def parse(self, text): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "results.txt" + path.write_text(text, encoding="utf-8") + return report.parse_suite_failures(path) + + def test_error_and_fail_blocks_are_mapped_to_test_names(self): + failures = self.parse(RESULTS) + self.assertEqual([f["kind"] for f in failures], ["ERROR", "FAIL"]) + self.assertEqual(failures[0]["names"], ("test_06_start",)) + self.assertEqual(failures[1]["names"], ("test_07_pool",)) + + def test_error_is_the_exception_line_without_captured_logging(self): + failures = self.parse(RESULTS) + self.assertTrue(failures[0]["error"].startswith("Exception: Job failed")) + self.assertIn("Unable to orchestrate", failures[0]["error"]) + self.assertEqual(failures[1]["error"], "AssertionError: 1 != 2") + self.assertNotIn( + "must not be shown", "\n".join(failures[0]["lines"])) + + def test_cloudstack_job_failure_leads_with_its_message(self): + line = ("Exception: Job failed: {accountid : 'a', account : 'admin', " + "jobresult : {errorcode : 530, errortext : 'Unable to start " + "the VM'}}") + text = f"{SEP}\nERROR: d\n{DASH}\n{line}\n" + self.assertEqual( + self.parse(text)[0]["error"], + "Job failed (code 530): Unable to start the VM") + + def test_cloudstack_job_failure_keeps_quoted_names_in_the_message(self): + messages = [ + "Unable to start VM 'i-2-5-VM', host unreachable", + "Volume doesn't exist", + "Failed to detach 'vol-1' from 'VM-2'", + ] + for message in messages: + line = ("Exception: Job failed: {accountid : 'a', jobresult : " + "{errorcode : 530, errortext : '%s'}, jobinstancetype : " + "'Volume'}" % message) + text = f"{SEP}\nERROR: d\n{DASH}\n{line}\n" + self.assertEqual( + self.parse(text)[0]["error"], + "Job failed (code 530): " + message) + + def test_cloudstack_job_failure_without_closing_quote_shows_the_rest(self): + line = ("Exception: Job failed: {jobresult : {errorcode : 530, " + "errortext : 'cut off here") + text = f"{SEP}\nERROR: d\n{DASH}\n{line}\n" + self.assertEqual( + self.parse(text)[0]["error"], "Job failed (code 530): cut off here") + + def test_unmapped_block_is_kept_without_names(self): + failures = self.parse(SETUP_ERROR) + self.assertEqual(len(failures), 1) + self.assertEqual(failures[0]["names"], ()) + self.assertEqual(failures[0]["error"], "RuntimeError: pool create failed") + + def test_missing_file_or_clean_run_gives_no_failures(self): + self.assertEqual( + report.parse_suite_failures(Path("/nonexistent/results.txt")), ()) + self.assertEqual(self.parse("Detach. ... === TestName: t | Status : SUCCESS ===\nok\n"), ()) + + def test_long_error_is_truncated(self): + long_line = "Exception: " + "x" * 2000 + text = f"{SEP}\nERROR: d\n{DASH}\n{long_line}\n" + error = self.parse(text)[0]["error"] + self.assertEqual(len(error), report.MAX_ERROR_CHARS + 1) + + +class CoverageSectionTest(unittest.TestCase): + def test_summary_fragment_is_included_when_present(self): + with tempfile.TemporaryDirectory() as directory: + results = Path(directory) + (results / "coverage").mkdir() + (results / "coverage" / "summary.html").write_text( + "

ONTAP storage plugin test coverage

", encoding="utf-8") + + self.assertIn("ONTAP storage plugin test coverage", + report.coverage_section(results)) + + def test_no_summary_gives_an_empty_section(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual("", report.coverage_section(Path(directory))) + + +class IstTimeTest(unittest.TestCase): + def test_utc_time_is_shown_in_ist(self): + value = report.parse_time("2026-11-05T01:54:07Z") + self.assertEqual(report.format_ist(value), "2026-11-05 07:24:07 IST") + + def test_offset_time_and_date_rollover(self): + value = report.parse_time("2026-11-05T22:00:00+00:00") + self.assertEqual(report.format_ist(value), "2026-11-06 03:30:00 IST") + + def test_missing_time_is_a_dash(self): + self.assertEqual(report.format_ist(None), "—") + + def test_durations_are_unaffected(self): + start = report.parse_time("2026-11-05T01:00:00Z") + finish = report.parse_time("2026-11-05T02:30:00Z") + self.assertEqual(finish - start, report.timedelta(hours=1, minutes=30)) + + +class FailurePageTest(unittest.TestCase): + def test_suite_page_shows_failure_before_the_log(self): + with tempfile.TemporaryDirectory() as directory: + suite = (Path(directory) / "phase2" / "marvin" / "ontap-results" + / "run-iscsi" / "suites" / "vm") + suite.mkdir(parents=True) + (suite / "results.txt").write_text(RESULTS, encoding="utf-8") + log = suite / "stdout.log" + log.write_text( + "=== TestName: test_06_start | Status : EXCEPTION ===\n" + "=== TestName: test_08_detach | Status : SUCCESS ===\n", + encoding="utf-8") + management = (Path(directory) / "phase2" / "runtime-logs" + / "var-log-cloudstack-management") + management.mkdir(parents=True) + (management / "management-server.log").write_text("log\n") + report.generate_log_artifacts(Path(directory), None) + page = (suite / "stdout.log.html").read_text(encoding="utf-8") + self.assertIn('id="fail-test_06_start"', page) + self.assertIn("Unable to orchestrate", page) + self.assertIn('id="test-test_08_detach"', page) + self.assertIn("Management server log", page) + self.assertIn( + "../../../../../runtime-logs/var-log-cloudstack-management/" + "management-server.log", page) + self.assertNotIn("Agent log", page) + self.assertLess(page.index("Failure details"), page.index("Suite log")) + self.assertLess(page.index("Suite log"), page.index("TestName")) + + def test_clean_suite_page_has_no_failure_section(self): + with tempfile.TemporaryDirectory() as directory: + log = Path(directory) / "stdout.log" + log.write_text("=== TestName: t | Status : SUCCESS ===\n", + encoding="utf-8") + destination = Path(directory) / "stdout.log.html" + report.log_to_html(log, destination) + self.assertNotIn( + "Failure details", destination.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/private-cicd/scripts/test_ontap_coverage.py b/private-cicd/scripts/test_ontap_coverage.py new file mode 100644 index 000000000000..6b4e7d6fd6e2 --- /dev/null +++ b/private-cicd/scripts/test_ontap_coverage.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit tests for ontap-coverage.py.""" + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("ontap-coverage.py") +SPEC = importlib.util.spec_from_file_location("ontap_coverage", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +DOCTYPE = ('' + '') + + +def report(files, line_missed, line_covered): + """A JaCoCo report. files maps a name to {line number: covered?}.""" + sources = "" + for name, lines in files.items(): + package, _, source = name.rpartition("/") + entries = "".join( + f'' + for nr, hit in lines.items()) + sources += (f'' + f"{entries}") + return (f'{DOCTYPE}{sources}' + f'' + '' + '' + '') + + +A = "org/apache/cloudstack/storage/service/A.java" +B = "org/apache/cloudstack/storage/driver/B.java" + + +def write(directory, name, text): + path = Path(directory) / name + path.write_text(text, encoding="utf-8") + return path + + +class CoverageTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + + def reports(self): + unit = write(self.directory.name, "unit.xml", + report({A: {1: True, 2: True, 3: False, 4: False}, B: {1: False}}, 3, 2)) + functional = write(self.directory.name, "functional.xml", + report({A: {1: False, 2: False, 3: True, 4: False}, B: {1: True}}, 3, 2)) + return unit, functional + + def test_parse_reads_totals_and_lines(self): + unit, _ = self.reports() + parsed = MODULE.parse_report(unit) + + self.assertEqual((3, 2), parsed["totals"]["LINE"]) + self.assertEqual((3, 1), parsed["totals"]["BRANCH"]) + self.assertEqual((0, 1), parsed["files"][A]["lines"][1]) + self.assertEqual((1, 0), parsed["files"][A]["lines"][3]) + + def test_combined_coverage_is_the_union_of_covered_lines(self): + unit, functional = self.reports() + summary = MODULE.build_summary( + MODULE.parse_report(unit), MODULE.parse_report(functional), 10, []) + + # A: lines 1,2 (unit) + 3 (functional) of 4; B: 1 (functional) of 1. + self.assertEqual(4, summary["combined_line"]["covered"]) + self.assertEqual(1, summary["combined_line"]["missed"]) + self.assertEqual(80.0, summary["combined_line"]["percent"]) + self.assertEqual(40.0, summary["unit"]["line"]["percent"]) + rows = {row["file"]: row for row in summary["least_covered"]} + self.assertEqual(75.0, rows[A]["combined"]) + self.assertEqual(50.0, rows[A]["unit"]) + self.assertEqual(25.0, rows[A]["functional"]) + self.assertNotIn(B, rows) # fully covered files are not listed + + def test_one_missing_report_still_summarises_the_other(self): + unit, _ = self.reports() + summary = MODULE.build_summary(MODULE.parse_report(unit), None, 10, []) + + self.assertIsNone(summary["functional"]) + self.assertEqual(40.0, summary["combined_line"]["percent"]) + + def test_missing_or_broken_report_is_not_a_report(self): + broken = write(self.directory.name, "broken.xml", "") + other = write(self.directory.name, "other.xml", "") + + self.assertIsNone(MODULE.parse_report(Path(self.directory.name) / "none.xml")) + self.assertIsNone(MODULE.parse_report(broken)) + self.assertIsNone(MODULE.parse_report(other)) + + def test_main_writes_json_markdown_and_html(self): + unit, functional = self.reports() + out = Path(self.directory.name) / "out" + code = MODULE.main(["--unit", str(unit), "--functional", str(functional), + "--output-dir", str(out), "--unit-link", "https://j/u"]) + + self.assertEqual(0, code) + summary = json.loads((out / "summary.json").read_text(encoding="utf-8")) + self.assertEqual(80.0, summary["combined_line"]["percent"]) + markdown = (out / "summary.md").read_text(encoding="utf-8") + self.assertTrue(markdown.startswith(MODULE.COMMENT_MARKER)) + self.assertIn("| **Combined** | **80.0%** |", markdown) + self.assertIn("[Unit coverage report](https://j/u)", markdown) + page = (out / "summary.html").read_text(encoding="utf-8") + self.assertIn("Combined", page) + self.assertIn('href="https://j/u"', page) + + def test_main_with_no_reports_explains_why(self): + out = Path(self.directory.name) / "out" + MODULE.main(["--unit", "/nonexistent.xml", "--output-dir", str(out)]) + + summary = json.loads((out / "summary.json").read_text(encoding="utf-8")) + self.assertIsNone(summary["combined_line"]) + self.assertEqual(2, len(summary["notes"])) + self.assertIn("not available", (out / "summary.md").read_text(encoding="utf-8")) + + def test_file_names_are_escaped_in_html(self): + unit = write(self.directory.name, "unit.xml", + report({"p/<b>.java": {1: False}}, 1, 0)) # XML-escaped name + out = Path(self.directory.name) / "out" + MODULE.main(["--unit", str(unit), "--output-dir", str(out)]) + + page = (out / "summary.html").read_text(encoding="utf-8") + self.assertNotIn(".java", page) + self.assertIn("<b>.java", page) + + +if __name__ == "__main__": + unittest.main() diff --git a/private-cicd/scripts/test_resolve_pull_request.py b/private-cicd/scripts/test_resolve_pull_request.py new file mode 100644 index 000000000000..71c9320fa876 --- /dev/null +++ b/private-cicd/scripts/test_resolve_pull_request.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit tests for resolve-pull-request.py.""" + +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("resolve-pull-request.py") +SPEC = importlib.util.spec_from_file_location("resolve_pull_request", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +SHA = "a" * 40 + + +def pull(number="98", draft=False, base="main", repo="NetApp/cloudstack", sha=SHA, + state="open"): + return { + "number": int(number), + "title": "Test", + "html_url": f"https://github.com/NetApp/cloudstack/pull/{number}", + "state": state, + "draft": draft, + "base": {"ref": base}, + "head": { + "ref": "feature/test", + "sha": sha, + "repo": {"full_name": repo} if repo is not None else None, + }, + "user": {"login": "tester", "email": None}, + } + + +class AssessPullRequestTest(unittest.TestCase): + def test_accepts_matching_origin_pr(self): + decision = MODULE.assess(pull(), "98", SHA, "main", "NetApp/cloudstack") + + self.assertTrue(decision["eligible"]) + self.assertEqual("", decision["reason"]) + self.assertEqual("tester", decision["pr_author_login"]) + self.assertEqual("", decision["pr_author_email"]) + + def test_rejects_draft_fork_base_and_sha_mismatch(self): + cases = [ + pull(draft=True), + pull(repo="other/cloudstack"), + pull(repo=None), + pull(base="release"), + pull(sha="b" * 40), + ] + + for candidate in cases: + decision = MODULE.assess( + candidate, "98", SHA, "main", "NetApp/cloudstack") + self.assertFalse(decision["eligible"], candidate) + + def test_rejects_closed_or_merged_pr(self): + # A merged PR is also reported by GitHub as state "closed". + for state in ("closed", "", None): + decision = MODULE.assess( + pull(state=state), "98", SHA, "main", "NetApp/cloudstack") + + self.assertFalse(decision["eligible"], state) + self.assertIn("not open", decision["reason"]) + + def test_accepts_github_head_when_jenkins_sha_is_missing(self): + decision = MODULE.assess(pull(), "98", "", "main", "NetApp/cloudstack") + + self.assertTrue(decision["eligible"]) + self.assertEqual(SHA, decision["pr_head_sha"]) + + def test_rejects_change_target_other_than_main(self): + decision = MODULE.assess( + pull(), "98", SHA, "feature/other", "NetApp/cloudstack") + + self.assertFalse(decision["eligible"]) + self.assertIn("change target", decision["reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py b/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py index ab46b96e6a3b..706c272b71d7 100644 --- a/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py +++ b/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py @@ -125,7 +125,6 @@ listNetworks as listNetworksAPI, listVirtualMachines as listVirtualMachinesAPI, listVolumes as listVolumesAPI, - startVirtualMachine as startVirtualMachineAPI, stopVirtualMachine as stopVirtualMachineAPI, ) from marvin.lib.base import ServiceOffering, Snapshot, StoragePool, VmSnapshot, Volume @@ -618,6 +617,7 @@ def tearDown(self): VmSnapshot.deleteVMSnapshot(self.apiClient, snap_id) except Exception as exc: logger.warning("tearDown: VM snapshot %s: %s" % (snap_id, exc)) + self._delete_failed_vm_snapshots() for snap_id in self._volume_snapshot_ids: try: Snapshot({"id": snap_id}).delete(self.apiClient) @@ -798,14 +798,6 @@ def _stop_vm(self, vm_id): self.apiClient.stopVirtualMachine(cmd) self._poll_vm_state(vm_id, "Stopped", timeout=600) - def _start_vm(self, vm_id): - if (self._vm_state(vm_id) or "").lower() == "running": - return - cmd = startVirtualMachineAPI.startVirtualMachineCmd() - cmd.id = vm_id - self.apiClient.startVirtualMachine(cmd) - self._poll_vm_state(vm_id, "Running", timeout=900) - def _root_volume(self, vm_id): cmd = listVolumesAPI.listVolumesCmd() cmd.virtualmachineid = vm_id @@ -902,6 +894,30 @@ def _take_vm_snapshot(self, vm_id): def _vm_snapshots_of(self, vm_id): return VmSnapshot.list(self.apiClient, virtualmachineid=vm_id, listall=True) or [] + def _delete_failed_vm_snapshots(self): + """Delete VM snapshots left in Error by a rejected create. + + A rejected createVMSnapshot (ONTAP refusing while a revert's clones + exist) leaves its record behind with no id the test ever saw. Any + Instance Snapshot, even in Error, blocks detach, resize and destroy. + """ + for vm in (self.__class__.vm, self.__class__.vm2): + if vm is None: + continue + try: + stale = [s for s in self._vm_snapshots_of(vm.id) + if (getattr(s, "state", "") or "").lower() == "error"] + except Exception as exc: + logger.warning("tearDown: could not list VM snapshots of %s: %s" + % (vm.id, exc)) + continue + for snap in stale: + try: + VmSnapshot.deleteVMSnapshot(self.apiClient, snap.id) + except Exception as exc: + logger.warning("tearDown: failed VM snapshot %s: %s" + % (snap.id, exc)) + def _volume_snapshot_listed(self, snap_id): listed = Snapshot.list(self.apiClient, id=snap_id, listall=True) or [] return any(getattr(item, "id", None) == snap_id for item in listed) @@ -1043,7 +1059,7 @@ def test_03_deploy_vm(self): if self.__class__.network_id: cmd.networkids = self.__class__.network_id - vm = self.apiClient.deployVirtualMachine(cmd) + vm = self._deploy_vm(cmd) self.__class__.vm = vm result = self._poll_vm_state(vm.id, "Running") @@ -1094,7 +1110,7 @@ def test_03a_deploy_second_vm_reuses_template_cache(self): if self.__class__.network_id: cmd.networkids = self.__class__.network_id - vm2 = self.apiClient.deployVirtualMachine(cmd) + vm2 = self._deploy_vm(cmd) self.assertIsNotNone(vm2, "deployVirtualMachine returned None") self.__class__.vm2 = vm2 self._poll_vm_state(vm2.id, "Running") @@ -1336,9 +1352,7 @@ def test_08_start_vm_lun_remapped(self): if self.__class__.vm is None: self.skipTest("VM not deployed — test_03 was skipped") - cmd = startVirtualMachineAPI.startVirtualMachineCmd() - cmd.id = self.__class__.vm.id - self.apiClient.startVirtualMachine(cmd) + self._start_vm(self.__class__.vm.id) result = self._poll_vm_state(self.__class__.vm.id, "Running", timeout=300) diff --git a/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py b/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py index d3450949baf2..c3b7e36a7316 100644 --- a/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py +++ b/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py @@ -110,7 +110,6 @@ listNetworks as listNetworksAPI, listVirtualMachines as listVirtualMachinesAPI, listVolumes as listVolumesAPI, - startVirtualMachine as startVirtualMachineAPI, stopVirtualMachine as stopVirtualMachineAPI, updateStoragePool as updateStoragePoolAPI, ) @@ -450,6 +449,7 @@ def tearDown(self): VmSnapshot.deleteVMSnapshot(self.apiClient, snap_id) except Exception as exc: logger.warning("tearDown: VM snapshot %s: %s" % (snap_id, exc)) + self._delete_failed_vm_snapshots() for snap_id in self._volume_snapshot_ids: try: Snapshot({"id": snap_id}).delete(self.apiClient) @@ -630,14 +630,6 @@ def _stop_vm(self, vm_id): self.apiClient.stopVirtualMachine(cmd) self._poll_vm_state(vm_id, "Stopped", timeout=600) - def _start_vm(self, vm_id): - if (self._vm_state(vm_id) or "").lower() == "running": - return - cmd = startVirtualMachineAPI.startVirtualMachineCmd() - cmd.id = vm_id - self.apiClient.startVirtualMachine(cmd) - self._poll_vm_state(vm_id, "Running", timeout=900) - def _root_volume(self, vm_id): cmd = listVolumesAPI.listVolumesCmd() cmd.virtualmachineid = vm_id @@ -734,6 +726,30 @@ def _take_vm_snapshot(self, vm_id): def _vm_snapshots_of(self, vm_id): return VmSnapshot.list(self.apiClient, virtualmachineid=vm_id, listall=True) or [] + def _delete_failed_vm_snapshots(self): + """Delete VM snapshots left in Error by a rejected create. + + A rejected createVMSnapshot (ONTAP refusing while a revert's clones + exist) leaves its record behind with no id the test ever saw. Any + Instance Snapshot, even in Error, blocks detach, resize and destroy. + """ + for vm in (self.__class__.vm, self.__class__.vm2): + if vm is None: + continue + try: + stale = [s for s in self._vm_snapshots_of(vm.id) + if (getattr(s, "state", "") or "").lower() == "error"] + except Exception as exc: + logger.warning("tearDown: could not list VM snapshots of %s: %s" + % (vm.id, exc)) + continue + for snap in stale: + try: + VmSnapshot.deleteVMSnapshot(self.apiClient, snap.id) + except Exception as exc: + logger.warning("tearDown: failed VM snapshot %s: %s" + % (snap.id, exc)) + def _volume_snapshot_listed(self, snap_id): listed = Snapshot.list(self.apiClient, id=snap_id, listall=True) or [] return any(getattr(item, "id", None) == snap_id for item in listed) @@ -872,7 +888,7 @@ def test_03_deploy_vm(self): if self.__class__.network_id: cmd.networkids = self.__class__.network_id - vm = self.apiClient.deployVirtualMachine(cmd) + vm = self._deploy_vm(cmd) self.assertIsNotNone(vm, "deployVirtualMachine returned None") self.__class__.vm = vm @@ -912,7 +928,7 @@ def test_03a_deploy_second_vm_reuses_template_cache(self): if self.__class__.network_id: cmd.networkids = self.__class__.network_id - vm2 = self.apiClient.deployVirtualMachine(cmd) + vm2 = self._deploy_vm(cmd) self.assertIsNotNone(vm2, "deployVirtualMachine returned None") self.__class__.vm2 = vm2 self._poll_vm_state(vm2.id, "Running") @@ -1213,9 +1229,7 @@ def test_08_start_vm_volume_accessible(self): vm = self.__class__.vm vol = self.__class__.volume - cmd = startVirtualMachineAPI.startVirtualMachineCmd() - cmd.id = vm.id - self.apiClient.startVirtualMachine(cmd) + self._start_vm(vm.id) result = self._poll_vm_state(vm.id, "Running", timeout=300) self.assertEqual( diff --git a/test/integration/plugins/ontap/ontap_test_base.py b/test/integration/plugins/ontap/ontap_test_base.py index e528aba2b25f..eb36929651a8 100644 --- a/test/integration/plugins/ontap/ontap_test_base.py +++ b/test/integration/plugins/ontap/ontap_test_base.py @@ -46,6 +46,7 @@ deleteNetwork as deleteNetworkAPI, deleteStoragePool as deleteStoragePoolAPI, deleteVolume as deleteVolumeAPI, + deployVirtualMachine as deployVirtualMachineAPI, destroyVirtualMachine as destroyVirtualMachineAPI, destroyVolume as destroyVolumeAPI, detachVolume as detachVolumeAPI, @@ -53,10 +54,12 @@ listDiskOfferings as listDiskOfferingsAPI, listNetworkOfferings as listNetworkOfferingsAPI, listNetworks as listNetworksAPI, + listRouters as listRoutersAPI, listServiceOfferings as listServiceOfferingsAPI, listTemplates as listTemplatesAPI, listVirtualMachines as listVirtualMachinesAPI, listVolumes as listVolumesAPI, + startVirtualMachine as startVirtualMachineAPI, stopVirtualMachine as stopVirtualMachineAPI, resizeVolume as resizeVolumeAPI, updateStoragePool as updateStoragePoolAPI, @@ -897,6 +900,17 @@ class OntapTestBase(cloudstackTestCase): # VM states from which the VM will never reach any other target state. _VM_TERMINAL_STATES = ("error", "destroyed", "expunging") + # The guest network's virtual router is created by the first VM deploy + # and can be briefly unreachable afterwards, which makes a VM start fail + # with "Unable to apply dhcp entry on router". Every wait and retry below + # is bounded so a genuine failure is reported, never waited out forever. + ROUTER_HEALTHY_TIMEOUT = 180 # seconds, per wait + ROUTER_POLL_INTERVAL = 10 # seconds + VM_START_ATTEMPTS = 3 # total tries, including the first + VM_START_RETRY_DELAY = 15 # seconds between tries + # Set once a router wait times out; see _wait_for_routers_healthy(). + _router_wait_exhausted = False + # ---- zone guard ---------------------------------------------------- @classmethod @@ -1618,6 +1632,118 @@ def _other_ontap_pools_on_svm(self, current_pool_id): others.append(pool) return others + # ---- virtual router readiness and VM operations -------------------- + + def _unhealthy_routers(self): + """Describe the test account's routers that are not ready. + + A router is not ready when it is not Running or reports failed health + checks. Returns [] when every router is ready, or when none exists + yet (the first VM deploy creates it). + """ + cmd = listRoutersAPI.listRoutersCmd() + cmd.listall = True + if self.zone is not None: + cmd.zoneid = self.zone.id + if self.account is not None: + cmd.account = self.account.name + cmd.domainid = self.account.domainid + unready = [] + for router in self.apiClient.listRouters(cmd) or []: + state = getattr(router, "state", "") or "" + failed = bool(getattr(router, "healthchecksfailed", False)) + if state.lower() != "running" or failed: + unready.append( + "%s (state=%s, healthchecksfailed=%s)" + % (getattr(router, "name", "router"), state, failed)) + return unready + + def _wait_for_routers_healthy(self, timeout=None): + """Wait, for a bounded time, until the guest router is ready. + + Returns True when ready (or when readiness cannot be determined) and + False when it is still not ready after the timeout. It never raises: + a router that stays flagged is reported in the log, and the VM + operation that follows then decides the test. After one timeout the + class stops waiting, so a router with a permanent health warning + costs one wait, not one per VM operation. + """ + timeout = self.ROUTER_HEALTHY_TIMEOUT if timeout is None else timeout + if self.__class__._router_wait_exhausted: + timeout = 0 + deadline = time.time() + timeout + while True: + try: + unready = self._unhealthy_routers() + except Exception as error: + log_progress( + logger, "warning", + "Cannot read router health, continuing: %s", error) + return True + if not unready: + return True + if time.time() >= deadline: + break + log_progress( + logger, "info", "Waiting for router to be ready: %s", + "; ".join(unready)) + time.sleep(self.ROUTER_POLL_INTERVAL) + if timeout: + self.__class__._router_wait_exhausted = True + log_progress( + logger, "warning", + "Router still not ready after %ss: %s", timeout, + "; ".join(unready)) + return False + + def _deploy_vm(self, cmd): + """Deploy a VM once the router is ready, and let a new one settle.""" + self._wait_for_routers_healthy() + vm = self.apiClient.deployVirtualMachine(cmd) + # The first deploy creates the guest network's router. + self._wait_for_routers_healthy() + return vm + + def _start_vm(self, vm_id): + """Start a stopped VM, retrying a bounded number of times. + + A start can fail only because the router was briefly unreachable + ("Unable to apply dhcp entry on router"); CloudStack then rolls the + VM back to Stopped. That, and only that, is retried. A VM in any + other state, or the last attempt failing, raises with every attempt's + error so the cause is not hidden. Returns once the VM is Running; a + VM that is already Running is left alone. + """ + vms = self.apiClient.listVirtualMachines(_list_vms_cmd(vm_id)) or [] + if vms and (vms[0].state or "").lower() == "running": + return + cmd = startVirtualMachineAPI.startVirtualMachineCmd() + cmd.id = vm_id + attempts = self.VM_START_ATTEMPTS + errors = [] + for attempt in range(1, attempts + 1): + self._wait_for_routers_healthy() + try: + self.apiClient.startVirtualMachine(cmd) + self._poll_vm_state(vm_id, "Running", timeout=900) + return + except Exception as error: + errors.append("attempt %d/%d: %s" % (attempt, attempts, error)) + vms = self.apiClient.listVirtualMachines( + _list_vms_cmd(vm_id)) or [] + state = (vms[0].state if vms else "") or "" + if attempt == attempts or state.lower() != "stopped": + raise Exception( + "startVirtualMachine failed after %d attempt(s), " + "VM state '%s':\n%s" + % (attempt, state, "\n".join(errors))) from error + log_progress( + logger, "warning", + "startVirtualMachine attempt %d/%d failed, retrying in " + "%ss: %s", attempt, attempts, self.VM_START_RETRY_DELAY, + error) + time.sleep(self.VM_START_RETRY_DELAY) + def _poll_vm_state(self, vm_id, target_state, timeout=300, interval=10): """ Poll listVirtualMachines until the VM reaches target_state.