| // Copyright (C) 2017 The Bazel Authors |
| // |
| // Licensed 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. |
| |
| package build.bazel.ci |
| |
| import com.cloudbees.groovy.cps.NonCPS |
| |
| /** |
| * A set of utility methods to call Bazel inside Jenkins |
| */ |
| class BazelUtils implements Serializable { |
| private static final TEST_EVENTS_FILE = "bazel-events-test.json" |
| private static final BUILD_EVENTS_FILE = "bazel-events-build.json" |
| private String bazel; |
| private String ws; |
| private def script; |
| private boolean isWindows; |
| private def envs = []; |
| |
| // Accessors |
| def setBazel(value) { |
| bazel = value |
| } |
| |
| def getBazel() { |
| bazel |
| } |
| |
| def setScript(value) { |
| script = value |
| ws = script.pwd() |
| isWindows = !script.isUnix() |
| if (isWindows) { |
| def bazel_sh = script.sh(script: "cygpath --windows /bin/bash", |
| returnStdout: true).trim() |
| envs = ["BAZEL_SH=${bazel_sh}"] |
| } |
| } |
| |
| def getScript() { |
| script |
| } |
| |
| // Actual method |
| |
| private def execute(script, returnStatus = false, returnStdout = false) { |
| if (isWindows) { |
| if (returnStdout) { |
| // @ removes the command lines from the output |
| script = "@${script}" |
| } |
| // exit /b !ERRORLEVEL! actually returns the exit code |
| return this.script.bat(script: "${script}\r\n@exit /b %ERRORLEVEL%", |
| returnStatus: returnStatus, returnStdout: returnStdout) |
| } else { |
| return this.script.sh(script: script, returnStatus: returnStatus, returnStdout: returnStdout) |
| } |
| } |
| |
| def bazelCommand(String args, returnStatus = false, returnStdout = false) { |
| script.withEnv(envs + ["BAZEL=" + this.bazel]) { |
| owner.script.ansiColor("xterm") { |
| return execute("${this.bazel} --bazelrc=${this.ws}/bazel.bazelrc ${args}", |
| returnStatus, returnStdout) |
| } |
| } |
| } |
| |
| // Execute a shell/batch command with bazel as a command on the path |
| def commandWithBazelOnPath(script) { |
| def pathWithBazel = "" |
| if (isWindows) { |
| def bazelDir = bazel.substring(0, bazel.lastIndexOf("\\")) |
| pathWithBazel = "${bazelDir};${this.script.env.PATH}" |
| } else { |
| def bazelDir = bazel.substring(0, bazel.lastIndexOf("/")) |
| pathWithBazel = "${bazelDir}:${this.script.env.PATH}" |
| } |
| this.script.withEnv(["PATH=${pathWithBazel}", |
| "BAZEL=${this.bazel}"] + envs) { |
| if (isWindows) { |
| this.script.bat script |
| } else { |
| this.script.sh "#!/bin/sh -x\n${script}" |
| } |
| } |
| } |
| |
| // Write a RC file to consume by the other step |
| def writeRc(build_opts = [], |
| test_opts = [], |
| startup_opts = [], |
| extra_bazelrc = "") { |
| def rc_file_content = [ |
| "common --color=yes", |
| "test --test_output=errors", |
| "build --verbose_failures" |
| ] |
| |
| rc_file_content.addAll(build_opts.collect { "build ${it}" }) |
| rc_file_content.addAll(test_opts.collect { "test ${it}" }) |
| rc_file_content.addAll(startup_opts.collect { "startup ${it}" }) |
| // Store the BEP events on a json file. |
| // TODO(dmarting): We should archive it and generate a good HTML report instead of |
| // the hard to read jenkins dashboard. |
| rc_file_content.add("build --experimental_build_event_json_file=${BUILD_EVENTS_FILE}") |
| rc_file_content.add("test --experimental_build_event_json_file=${TEST_EVENTS_FILE}") |
| script.writeFile(file: "${ws}/bazel.bazelrc", |
| text: rc_file_content.join("\n") + "\n${extra_bazelrc}") |
| } |
| |
| def showFailedActions(events) { |
| def eventsstring = "" |
| for(event in events) { |
| if ("action" in event) { |
| eventsstring += event.toString() + "\n" |
| } |
| } |
| if (eventsstring == "") { |
| script.echo("No failed actions reported in the event stream") |
| } else { |
| script.echo("Failed actions:\n" + eventsstring) |
| } |
| } |
| |
| // Execute a bazel build |
| def build(targets = ["//..."]) { |
| if (!targets.isEmpty()) { |
| try { |
| bazelCommand("build ${targets.join ' '}") |
| } finally { |
| showFailedActions(buildEvents()) |
| } |
| } |
| } |
| |
| @NonCPS |
| private def makeTestQuery(tests) { |
| // Lambda are not working well with CPS, so NonCPS... |
| def quote = isWindows ? { s -> s.replace('"', '""') } : { s -> s.replace("'", "'\\''") } |
| def q = isWindows ? '"' : "'" |
| return "query ${q}${tests.collect(quote).join(' + ')}${q}" |
| } |
| |
| // Execute a bazel tests |
| def test(tests = ["//..."]) { |
| if (!tests.isEmpty()) { |
| def filteredTests = bazelCommand(makeTestQuery(tests), false, true) |
| if (filteredTests == null || filteredTests.isEmpty()) { |
| script.echo "Skipped tests (no tests found)" |
| } else { |
| def status = bazelCommand("test ${filteredTests.replaceAll("\n", " ")}", true) |
| showFailedActions(testEvents()) |
| if (status == 3) { |
| // Bazel returns 3 if there was a test failures but no breakage, that is unstable |
| throw new BazelTestFailure() |
| } else if (status != 0) { |
| // TODO(dmarting): capturing the output mark the wrong step at failure, there is |
| // no good way to do so, it would probably better to have better output in the failing |
| // step |
| throw new Exception("`bazel test` returned status ${status}") |
| } |
| } |
| } |
| } |
| |
| private def parseEventsFile(String fileName) { |
| if (script.fileExists(fileName)) { |
| return JsonUtils.parseJsonStream(script.readFile(fileName)) |
| } |
| // The file does not exists (probably because empty set of targets / tests), just return |
| // an empty list. |
| return [] |
| } |
| |
| def buildEvents() { |
| return parseEventsFile("${this.ws}/${BUILD_EVENTS_FILE}") |
| } |
| |
| def testEvents() { |
| return parseEventsFile("${this.ws}/${TEST_EVENTS_FILE}") |
| } |
| |
| @NonCPS |
| private def copyCommands(cp_lines, log, test_folder) { |
| if (log != null) { |
| def uri = URI.create(log.uri) |
| def path = uri.path |
| if (isWindows) { |
| // on windows the host is the drive letter, add it to the path. |
| path = "/${uri.host}${path}" |
| } |
| def relativePath = path.substring(path.indexOf("/testlogs/") + 10) |
| cp_lines.add("mkdir -p \$(dirname '${test_folder}/${relativePath}')") |
| cp_lines.add("cp -r '${path}' '${test_folder}/${relativePath}'") |
| } |
| } |
| |
| @NonCPS |
| def generateTestLogsCopy(events, test_folder) { |
| // To avoid looking at all the files, including the stalled output log, we parse the events |
| // from the build. |
| // This is NonCPS because lambdas |
| def cp_lines = [] |
| events.each { event -> |
| if("testResult" in event) { |
| copyCommands(cp_lines, |
| event.testResult.testActionOutput.find { it.name == "test.xml" }, |
| test_folder) |
| // Also copy the test log |
| copyCommands(cp_lines, |
| event.testResult.testActionOutput.find { it.name == "test.log" }, |
| test_folder) |
| } |
| } |
| return cp_lines.join('\n') |
| } |
| |
| // Archive test results |
| def testlogs(test_folder) { |
| // JUnit test result does not look at test result if they are "old", copying them to a new |
| // location, unique accross configurations. |
| def res = script.sh(script: """#!/bin/sh |
| echo 'Copying test outputs and events file for archiving' |
| rm -fr ${test_folder} |
| mkdir -p ${test_folder} |
| touch ${BUILD_EVENTS_FILE} ${TEST_EVENTS_FILE} |
| cp -f ${BUILD_EVENTS_FILE} ${TEST_EVENTS_FILE} ${test_folder} |
| """ + generateTestLogsCopy(testEvents(), test_folder), |
| returnStatus: true) |
| if (res == 0) { |
| // Archive the test logs and xml files |
| script.archiveArtifacts artifacts: "${test_folder}/**/test.log,${test_folder}/*.json" |
| script.junit testResults: "${test_folder}/**/test.xml", allowEmptyResults: true |
| } |
| } |
| } |