diff --git a/.build/build-artifacts.sh b/.build/build-artifacts.sh new file mode 100755 index 000000000000..3214f273f865 --- /dev/null +++ b/.build/build-artifacts.sh @@ -0,0 +1,28 @@ +#!/bin/sh -e +# 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. + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/..)" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } + +# execute +ant -f "${CASSANDRA_DIR}/build.xml" artifacts -Dant.gen-doc.skip=true -Dcheck.skip=true +exit $? diff --git a/.build/build-jars.sh b/.build/build-jars.sh new file mode 100755 index 000000000000..cf2b91b08201 --- /dev/null +++ b/.build/build-jars.sh @@ -0,0 +1,30 @@ +#!/bin/sh -e +# 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. + +# temporary between CASSANDRA-18133 and CASSANDRA-18594 + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/..)" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } + +# execute +ant -f "${CASSANDRA_DIR}/build.xml" jar +exit $? diff --git a/.build/ci/ci_parser.py b/.build/ci/ci_parser.py new file mode 100755 index 000000000000..c91b5f15b9c6 --- /dev/null +++ b/.build/ci/ci_parser.py @@ -0,0 +1,309 @@ +#!/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. + + +""" +Script to take an arbitrary root directory of subdirectories of junit output format and create a summary .html file +with their results. +""" + +import argparse +import cProfile +import pstats +import os +import shutil +import xml.etree.ElementTree as ET +from typing import Callable, Dict, Tuple, Type +from pathlib import Path + +from junit_helpers import JUnitResultBuilder, JUnitTestCase, JUnitTestSuite, JUnitTestStatus, LOG_FILE_NAME +from logging_helper import build_logger, mute_logging, CustomLogger + +try: + from bs4 import BeautifulSoup +except ImportError: + print('bs4 not installed; make sure you have bs4 in your active python env.') + exit(1) + + +parser = argparse.ArgumentParser(description=""" +Parses ci results provided ci output in input path and generates html +results in specified output file. Expects an existing .html file to insert +results into; this file will be backed up into a .bak file in its +local directory. +""") +parser.add_argument('--input', type=str, help='path to input files (recursive directory search for *.xml)') +# TODO: Change this paradigm to a full "input dir translates into output file", where output file includes some uuid +# We'll need full support for all job types, not just junit, which will also necessitate refactoring into some kind of +# TestResultParser, of which JUnit would be one type. But there's a clear pattern here we can extract. Thinking checkstyle. +parser.add_argument('--output', type=str, help='existing .html output file to append to') +parser.add_argument('--mute', action='store_true', help='mutes stdout and only logs to log file') +parser.add_argument('--profile', '-p', action='store_true', help='Enable perf profiling on operations') +parser.add_argument('-v', '-d', '--verbose', '--debug', dest='debug', action='store_true', help='verbose log output') +args = parser.parse_args() +if args.input is None or args.output is None: + parser.print_help() + exit(1) + +logger = build_logger(LOG_FILE_NAME, args.debug) # type: CustomLogger +if args.mute: + mute_logging(logger) + + +def main(): + check_file_condition(lambda: os.path.exists(args.input), f'Cannot find {args.input}. Aborting.') + + xml_files = [str(file) for file in Path(args.input).rglob('*.xml')] + check_file_condition(lambda: len(xml_files) != 0, f'Found 0 .xml files in path: {args.input}. Cannot proceed with .xml extraction.') + logger.info(f'Found {len(xml_files)} xml files under: {args.input}') + + test_suites = process_xml_files(xml_files) + + for suite in test_suites.values(): + if suite.is_empty() and suite.file_count() == 0: + logger.warning(f'Have an empty test_suite: {suite.name()} that had no .xml files associated with it. Did the jobs run correctly and produce junit files? Check {suite.get_archive()} for test run command result details.') + elif suite.is_empty(): + logger.warning(f'Got an unexpected empty test_suite: {suite.name()} with no .xml file parsing associated with it. Check {LOG_FILE_NAME}.log when run with -v for details.') + + create_summary_file(test_suites, xml_files, args.output) + + +def process_xml_files(xml_files: str) -> Dict[str, JUnitTestSuite]: + """ + For a given input input_dir, will find all .xml files in that tree, extract files from them preserving input_dir structure + and parse out all found junit test results into the global test result containers. + :param xml_files: all .xml files under args.input_dir + """ + + test_suites = dict() # type: Dict[str, JUnitTestSuite] + test_count = 0 + + for file in xml_files: + files, tests = process_xml_file(file, test_suites) + test_count += tests + + logger.progress(f'Total junit file count: {len(xml_files)}') + logger.progress(f'Total suite count: {len(test_suites.keys())}') + logger.progress(f'Total test count: {test_count}') + passed = 0 + failed = 0 + skipped = 0 + + for suite in test_suites.values(): + passed += suite.passed() + failed += suite.failed() + if suite.failed() != 0: + print_errors(suite) + skipped += suite.skipped() + + logger.progress(f'-- Passed: {passed}') + logger.progress(f'-- Failed: {failed}') + logger.progress(f'-- Skipped: {skipped}') + return test_suites + + +def process_xml_file(xml_file, test_suites: Dict[str, JUnitTestSuite]) -> Tuple[int, int]: + """ + Pretty straightforward here - walk through and look for tests, + parsing them out into our global JUnitTestCase Dicts as we find them + + No thread safety on target Dict -> relying on the "one .xml per suite" rule to keep things clean + + Can be called in context of executor thread. + :return: Tuple[file count, test count] + """ + + # TODO: In extreme cases (python upgrade dtests), this could theoretically be a HUGE file we're materializing in memory. Consider .iterparse or tag sanitization using sed first. + with open(xml_file, "rb") as xml_input: + try: + suite_name = "?" + root = ET.parse(xml_input).getroot() # type: ignore + suite_name = str(root.get('name')) + logger.progress(f'Processing archive: {xml_file} for test suite: {suite_name}') + + # And make sure we're not racing + if suite_name in test_suites: + logger.error(f'Got a duplicate suite_name - this will lead to race conditions. Suite: {suite_name}. xml file: {xml_file}. Skipping this file.') + return 0, 0 + else: + test_suites[suite_name] = JUnitTestSuite(suite_name) + + active_suite = test_suites[suite_name] + # Store this for later logging if we have a failed job; help the user know where to look next. + active_suite.set_archive(xml_file) + test_file_count = 0 + test_count = 0 + fc = process_test_cases(active_suite, xml_file, root) + if fc != 0: + test_file_count += 1 + test_count += fc + except (EOFError, ET.ParseError) as e: + logger.error(f'Error on {xml_file}: {e}. Skipping; will be missing results for {suite_name}') + return 0, 0 + except Exception as e: + logger.critical(f'Got unexpected error while parsing {xml_file}: {e}. Aborting.') + raise e + return test_file_count, test_count + + +def print_errors(suite: JUnitTestSuite) -> None: + logger.warning(f'\n[Printing {suite.failed()} tests from suite: {suite.name()}]') + for testcase in suite.get_tests(JUnitTestStatus.FAILURE): + logger.warning(f'{testcase}') + + +def process_test_cases(suite: JUnitTestSuite, file_name: str, root) -> int: + """ + For a given input .xml, will extract all JUnitTestCase matching objects and store them in the global registry keyed off + suite name. + + Can be called in context of executor thread. + :param suite: The JUnitTestSuite object we're currently working with + :param file_name: .xml file_name to check for tests. junit format. + :param root: etree root for file_name + :return : count of tests extracted from this file_name + """ + xml_exclusions = ['logback', 'checkstyle'] + if any(x in file_name for x in xml_exclusions): + return 0 + + # Search inside entire hierarchy since sometimes it's at the root and sometimes one level down. + test_count = len(root.findall('.//testcase')) + if test_count == 0: + logger.warning(f'Appear to be processing an .xml file without any junit tests in it: {file_name}. Update .xml exclusions to exclude this.') + if args.debug: + logger.info(ET.tostring(root)) + return 0 + + suite.add_file(file_name) + found = 0 + for testcase in root.iter('testcase'): + processed = JUnitTestCase(testcase) + suite.add_testcase(processed) + found = 1 + if found == 0: + logger.error(f'file: {file_name} has test_count: {test_count} but root.iter iterated across nothing!') + logger.error(ET.tostring(root)) + return test_count + + +def create_summary_file(test_suites: Dict[str, JUnitTestSuite], xml_files, output: str) -> None: + """ + Will create a table with all failed tests in it organized by sorted suite name. + :param test_suites: Collection of JUnitTestSuite's parsed out pass/fail data + :param output: Path to the .html we want to append to the of + """ + + # if needed create a blank ci_summary.html + if not os.path.exists(args.output): + with open(args.output, "w") as ci_summary_html: + ci_summary_html.write('

CI Summary

') + + with open(output, 'r') as file: + soup = BeautifulSoup(file, 'html.parser') + + failures_list_tag = soup.new_tag("div") + failures_list_tag.string = '

[Test Failures]

' + failures_tag = soup.new_tag("div") + failures_tag.string = '

[Test Failure Details]

' + suites_tag = soup.new_tag("div") + suites_tag.string = '


[Test Suite Details]

' + suites_builder = JUnitResultBuilder('Suites') + suites_builder.label_columns(['Suite', 'Passed', 'Failed', 'Skipped'], ["width: 70%; text-align: left;", "width: 10%; text-align: right", "width: 10%; text-align: right", "width: 10%; text-align: right"]) + + JUnitResultBuilder.add_style_tags(soup) + + # We cut off at 200 failures; if you have > than that chances are you have a bad run and there's no point in + # just continuing to pollute the summary file with it and blow past file size. Since the inlined failures are + # a tool to be used in the attaching / review process and not primarily workflow and fixing. + total_passed_count = 0 + total_skipped_count = 0 + total_failure_count = 0 + for suite_name in sorted(test_suites.keys()): + suite = test_suites[suite_name] + passed_count = suite.passed() + skipped_count = suite.skipped() + failure_count = suite.failed() + + suites_builder.add_row([suite_name, str(passed_count), str(failure_count), str(skipped_count)]) + + if failure_count == 0: + # Don't append anything to results in the happy path case. + logger.debug(f'No failed tests in suite: {suite_name}') + elif total_failure_count < 200: + # Else independent table per suite. + failures_builder = JUnitResultBuilder(suite_name) + failures_builder.label_columns(['Class', 'Method', 'Output', 'Duration'], ["width: 15%; text-align: left;", "width: 15%; text-align: left;", "width: 60%; text-align: left;", "width: 10%; text-align: right;"]) + for test in suite.get_tests(JUnitTestStatus.FAILURE): + failures_builder.add_row(test.row_data()) + failures_list_tag.append(BeautifulSoup(failures_builder.build_list(), 'html.parser')) + failures_tag.append(BeautifulSoup(failures_builder.build_table(), 'html.parser')) + total_failure_count += failure_count + if total_failure_count > 200: + logger.critical(f'Saw {total_failure_count} failures; greater than 200 threshold. Not appending further failure details to {output}.') + total_passed_count += passed_count + total_skipped_count += skipped_count + + # totals, manual html + totals_tag = soup.new_tag("div") + totals_tag.string = f"""[Totals]

+ + + + + +
Passed {total_passed_count}
Failed {total_failure_count}
Skipped {total_skipped_count}
Total    {total_passed_count + total_failure_count + total_skipped_count}
Files {len(xml_files)}
Suites {len(test_suites.keys())}

+ """ + + soup.body.append(totals_tag) + soup.body.append(failures_list_tag) + soup.body.append(failures_tag) + suites_tag.append(BeautifulSoup(suites_builder.build_table(), 'html.parser')) + soup.body.append(suites_tag) + + # Only backup the output file if we've gotten this far + shutil.copyfile(output, output + '.bak') + + # We write w/formatter set to None as invalid char above our insertion in the input file we're modifying (from other + # tests, test output, etc) can cause the parser to get very confused and do Bad Things. + with open(output, 'w') as file: + file.write(soup.prettify(formatter=None)) + logger.progress(f'Test failure details appended to file: {output}') + + +def check_file_condition(function: Callable[[], bool], msg: str) -> None: + """ + Specifically raises a FileNotFoundError if something's wrong with the Callable + """ + if not function(): + log_and_raise(msg, FileNotFoundError) + + +def log_and_raise(msg: str, error_type: Type[BaseException]) -> None: + logger.critical(msg) + raise error_type(msg) + + +if __name__ == "__main__" and args.profile: + profiler = cProfile.Profile() + profiler.enable() + main() + profiler.disable() + stats = pstats.Stats(profiler).sort_stats('cumulative') + stats.print_stats() +else: + main() diff --git a/.build/ci/generate-ci-summary.sh b/.build/ci/generate-ci-summary.sh new file mode 100755 index 000000000000..534b9d346238 --- /dev/null +++ b/.build/ci/generate-ci-summary.sh @@ -0,0 +1,74 @@ +#!/bin/sh -e +# 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. + +# +# Creates ci_summary.html +# This expects a folder hierarchy that separates test types/targets. +# For example: +# build/test/output/test +# build/test/output/jvm-dtest +# build/test/output/dtest +# +# The ci_summary.html file, along with the results_details.tar.xz, +# are the sharable artefacts used to satisfy pre-commit CI from a private CI. +# + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/../..)" +[ "x${DIST_DIR}" != "x" ] || DIST_DIR="${CASSANDRA_DIR}/build" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } +[ -d "${DIST_DIR}" ] || { mkdir -p "${DIST_DIR}" ; } + +# generate CI summary file +cd ${DIST_DIR}/ + +cat >${DIST_DIR}/ci_summary.html < + + +

CI Summary ${BUILD_TAG}

+

Build State

+ +

Build Parameters

+ + + +... +EOL + +${CASSANDRA_DIR}/.build/ci/ci_parser.py --mute --input ${DIST_DIR}/test/output/ --output ${DIST_DIR}/ci_summary.html + +exit $? + diff --git a/.build/ci/generate-test-report.sh b/.build/ci/generate-test-report.sh new file mode 100755 index 000000000000..81e229641935 --- /dev/null +++ b/.build/ci/generate-test-report.sh @@ -0,0 +1,39 @@ +#!/bin/sh -e +# 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. + +# +# Aggregates all test xml files into one and generates the junit html report. +# see the 'generate-test-report' target in build.xml for more. +# It is intended to be used to aggregate all splits on each test type/target, +# before calling generate-ci-summary.sh to create the overview summary of +# all test types and failures in a pipeline run. +# + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/../..)" +[ "x${DIST_DIR}" != "x" ] || DIST_DIR="${CASSANDRA_DIR}/build" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } +[ -d "${DIST_DIR}" ] || { mkdir -p "${DIST_DIR}" ; } + +# generate test xml summary file and html report directories +ant -f "${CASSANDRA_DIR}/build.xml" generate-test-report +exit $? + diff --git a/.build/ci/junit_helpers.py b/.build/ci/junit_helpers.py new file mode 100644 index 000000000000..75ffa0853445 --- /dev/null +++ b/.build/ci/junit_helpers.py @@ -0,0 +1,331 @@ +#!/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. + + +""" +In-memory representations of JUnit test results and some helper methods to construct .html output +based on those results +""" + +import logging +import xml.etree.ElementTree as ET + +from bs4 import BeautifulSoup +from enum import Enum +from jinja2 import Template +from typing import Any, Dict, Iterable, List, Set, Tuple + +LOG_FILE_NAME = 'junit_parsing' +logger = logging.getLogger(LOG_FILE_NAME) + + +class JUnitTestStatus(Enum): + label: str + + """ + Map to the string tag expected in the child element in junit output + """ + UNKNOWN = (0, 'unknown') + PASSED = (1, 'passed') + FAILURE = (2, 'failure') + SKIPPED = (3, 'skipped') + # Error and FAILURE are unfortunately used interchangeably by some of our suites, so we combine them on parsing + ERROR = (4, 'error') + + def __new__(cls, value, label) -> Any: + obj = object.__new__(cls) + obj._value_ = value + obj.label = label + return obj + + def __str__(self): + return self.label + + @staticmethod + def html_cell_order() -> Tuple: + """ + We won't have UNKNOWN, and ERROR is merged into FAILURE. This is our preferred order to represent things in html. + """ + return JUnitTestStatus.FAILURE, JUnitTestStatus.PASSED, JUnitTestStatus.SKIPPED + + +class JUnitResultBuilder: + """ + Wraps up jinja templating for our junit based results. Manually doing this stuff was proving to be a total headache. + That said, this didn't turn out to be a picnic on its own either. The particularity of .html parsing and this + templating combined with BeautifulSoup means things are... very very particular. Bad parsing on things from the .sh + or other sources can make bs4 replace things in weird ways. + """ + def __init__(self, name: str) -> None: + self._name = name + self._labels = [] # type: List[str] + self._column_styles = [] # type: List[str] + self._rows = [] # type: List[List[str]] + self._header = ['unknown', 'unknown', 'unknown', 'unknown'] + + # Have to have the 4 members since the stylesheet formats them based on position and it'll get all stupid + # otherwise. + self._template = Template(''' + + + + + + + {% for row in rows %} + + + + + + + {% endfor %} +
{{header}}
{{ labels[0] }} + {{ labels[1] }} + {{ labels[2] }} + {{ labels[3] }} +
{{ row[0] }}{{ row[1] }}{{ row[2] }}{{ row[3] }}
+ ''') + + self._list_template = Template(''' +
+ {% for row in rows %} +     {{ row[0] }} {{ row[1] }}
+ {% endfor %} +
+ ''') + + @staticmethod + def add_style_tags(soup: BeautifulSoup) -> None: + """ + We want to be opinionated about the width of our tables for our test suites; the messages dominate the output + so we want to dedicate the largest amount of space to them and limit word-wrapping + """ + style_tag = soup.new_tag("style") + style_tag.string = """ + table, tr { + border: 1px solid black; border-collapse: collapse; + } + .table-fixed { + table-layout: fixed; + width: 100%; + } + """ + soup.head.append(style_tag) + + def label_columns(self, cols: List[str], column_styles: List[str]) -> None: + if len(cols) != 4: + raise AssertionError(f'Got invalid number of columns on label_columns: {len(cols)}. Expected: 4.') + self._labels = cols + self._column_styles = column_styles + + def add_row(self, row: List[str]) -> None: + if len(row) != 4: + raise AssertionError(f'Got invalid number of columns on add_row: {len(row)}. Expected: 4.') + self._rows.append(row) + + def build_list(self) -> str: + return self._list_template.render(rows=self._rows) + + def build_table(self) -> str: + return self._template.render(header=f'{self._name}', labels=self._labels, column_styles=self._column_styles, rows=self._rows) + + +class JUnitTestCase: + """ + Pretty straightforward in-memory representation of the state of a jUnit test. Not the _most_ tolerant of bad input, + so don't test your luck. + """ + def __init__(self, testcase: ET.Element) -> None: + """ + From a given xml element, constructs a junit testcase. Doesn't do any sanity checking to make sure you gave + it something correct, so... don't screw up. + + Here's our general junit formatting: + + + + # The following is stored in failure.text: + DETAILED ERROR MESSAGE / STACK TRACE + DETAILED ERROR MESSAGE / STACK TRACE + ... + + + + And our skipped format: + + + + + Same for errors + + We conflate the 1 child tag indicating something went wrong. So we check to ensure + # that remains true and will assert out if we hit something unexpected. + saw_error = 0 + + def _check_for_child_element(failure_type: JUnitTestStatus) -> None: + """ + The presence of any failure, error, or skipped child elements indicated this test wasn't a normal 'pass'. + We want to extract the message from the child if it has one as well as update the status of this object, + including glomming together ERROR and FAILURE cases here. We combine those two as some legit test failures + are reported as in the pytest cases. + """ + nonlocal testcase + child = testcase.find(failure_type.label) + if child is None: + return + + nonlocal saw_error + if saw_error != 0: + raise AssertionError(f'Got a test with > 1 "bad" state (error, failed, skipped). classname: {self._class_name}. test: {self._test_name}.') + saw_error = 1 + + # We don't know if we're going to have message attribute data, text attribute, or text inside our tag. So + # we just connect all three + final_msg = '-'.join(filter(None, (child.get('message'), child.get('text'), child.text))) + + self._message = final_msg + if failure_type == JUnitTestStatus.ERROR or failure_type == JUnitTestStatus.FAILURE: + self._status = JUnitTestStatus.FAILURE + else: + self._status = failure_type + + _check_for_child_element(JUnitTestStatus.FAILURE) + _check_for_child_element(JUnitTestStatus.ERROR) + _check_for_child_element(JUnitTestStatus.SKIPPED) + + def row_data(self) -> List[str]: + return [self._class_name, self._test_name, f"
{self._message}
", str(self._time)] + + def status(self) -> JUnitTestStatus: + return self._status + + def message(self) -> str: + return self._message + + def __hash__(self) -> int: + """ + We want to allow overwriting of existing combinations of class + test names, since our tarballs of results will + have us doing potentially duplicate sequential processing of files and we just want to keep the most recent one. + Of note, sorting the tarball contents and trying to navigate to and find the oldest and only process that was + _significantly_ slower than just brute-force overwriting this way. Like... I gave up after 10 minutes vs. < 1 second. + """ + return hash((self._class_name, self._test_name)) + + def __eq__(self, other) -> bool: + if isinstance(other, JUnitTestCase): + return (self._class_name, self._test_name) == (other._class_name, other._test_name) + return NotImplemented + + def __str__(self) -> str: + """ + We slice the message here; don't rely on this for anything where you need full reporting + :return: + """ + clean_msg = self._message.replace('\n', ' ') + return (f"JUnitTestCase(class_name='{self._class_name}', " + f"name='{self._test_name}', msg='{clean_msg[:50]}', " + f"time={self._time}, status={self._status.name})") + + +class JUnitTestSuite: + """ + Straightforward container for a set of tests. + """ + + def __init__(self, name: str): + self._name = name # type: str + self._suites = dict() # type: Dict[JUnitTestStatus, Set[JUnitTestCase]] + self._files = set() # type: Set[str] + # We only allow one archive to be associated with each JUnitTestSuite + self._archive = 'unknown' # type: str + for status in JUnitTestStatus: + self._suites[status] = set() + + def add_testcase(self, newcase: JUnitTestCase) -> None: + """ + Replaces if existing is found. + """ + if newcase.status() == JUnitTestStatus.UNKNOWN: + raise AssertionError(f'Attempted to add a testcase with an unknown status: {newcase}. Aborting.') + self._suites[newcase.status()].discard(newcase) + self._suites[newcase.status()].add(newcase) + + def get_tests(self, status: JUnitTestStatus) -> Iterable[JUnitTestCase]: + """ + Returns sorted list of tests, class name first then test name + """ + return sorted(self._suites[status], key=lambda x: (x._class_name, x._test_name)) + + def passed(self) -> int: + return self.count(JUnitTestStatus.PASSED) + + def failed(self) -> int: + return self.count(JUnitTestStatus.FAILURE) + + def skipped(self) -> int: + return self.count(JUnitTestStatus.SKIPPED) + + def count(self, status: JUnitTestStatus) -> int: + return len(self._suites[status]) + + def name(self) -> str: + return self._name + + def is_empty(self) -> bool: + return self.passed() == 0 and self.failed() == 0 and self.skipped() == 0 + + def set_archive(self, name: str) -> None: + if self._archive != "unknown": + msg = f'Attempted to set archive for suite: {self._name} when archive already set: {self._archive}. This is a bug.' + logger.critical(msg) + raise AssertionError(msg) + self._archive = name + + def get_archive(self) -> str: + return self._archive + + def add_file(self, name: str) -> None: + # Just silently noop if we already have one since dupes in tarball indicate the same thing effectively. That we have it. + self._files.add(name) + + def file_count(self) -> int: + """ + Returns count of _unique_ files associated with this suite, not necessarily the _absolute_ count of files, since + we don't bother keeping count of multiple instances of a .xml file in the tarball. + :return: + """ + return len(self._files) + + @staticmethod + def headers() -> List[str]: + result = ['Suite'] + for status in JUnitTestStatus.html_cell_order(): + result.append(status.name) + return result diff --git a/.build/ci/logging.sh b/.build/ci/logging.sh new file mode 100644 index 000000000000..4fd12b3ec12a --- /dev/null +++ b/.build/ci/logging.sh @@ -0,0 +1,124 @@ +# 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. + +export TEXT_RED="0;31" +export TEXT_GREEN="0;32" +export TEXT_LIGHTGREEN="1;32" +export TEXT_BROWN="0;33" +export TEXT_YELLOW="1;33" +export TEXT_BLUE="0;34" +export TEXT_LIGHTBLUE="1;34" +export TEXT_PURPLE="0;35" +export TEXT_LIGHTPURPLE="1;35" +export TEXT_CYAN="0;36" +export TEXT_LIGHTCYAN="1;36" +export TEXT_LIGHTGRAY="0;37" +export TEXT_WHITE="1;37" +export TEXT_DARKGRAY="1;30" +export TEXT_LIGHTRED="1;31" + +export SILENCE_LOGGING=false +export LOG_TO_FILE="${LOG_TO_FILE:-false}" + +disable_logging() { + export SILENCE_LOGGING=true +} + +enable_logging() { + export SILENCE_LOGGING=false +} + +echo_color() { + if [[ $LOG_TO_FILE == "true" ]]; then + echo "$1" + elif [[ $SILENCE_LOGGING != "true" ]]; then + echo -e "\033[1;${2}m${1}\033[0m" + fi +} + +log_header() { + if [[ $SILENCE_LOGGING != "true" ]]; then + log_separator + echo_color "$1" $TEXT_GREEN + log_separator + fi +} + +log_progress() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "$1" $TEXT_LIGHTCYAN + fi +} + +log_info() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "$1" $TEXT_LIGHTGRAY + fi +} + +log_quiet() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "$1" $TEXT_DARKGRAY + fi +} + +# For transient always-on debugging +log_transient() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "[TRANSIENT]: $1" $TEXT_BROWN + fi +} + +# For durable user-selectable debugging +log_debug() { + if [[ "$SILENCE_LOGGING" = "true" ]]; then + return + fi + + if [[ "${DEBUG:-false}" == true || "${DEBUG_LOGGING:-false}" == true ]]; then + echo_color "[DEBUG] $1" $TEXT_PURPLE + fi +} + +log_quiet() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "$1" $TEXT_LIGHTGRAY + fi +} + +log_todo() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "TODO: $1" $TEXT_LIGHTPURPLE + fi +} + +log_warning() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "WARNING: $1" $TEXT_YELLOW + fi +} + +log_error() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "ERROR: $1" $TEXT_RED + fi +} + +log_separator() { + if [[ $SILENCE_LOGGING != "true" ]]; then + echo_color "--------------------------------------------" $TEXT_GREEN + fi +} \ No newline at end of file diff --git a/.build/ci/logging_helper.py b/.build/ci/logging_helper.py new file mode 100755 index 000000000000..90f4c9f5c017 --- /dev/null +++ b/.build/ci/logging_helper.py @@ -0,0 +1,136 @@ +#!/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. + + +""" +We want to add a little functionality on top of built-in logging; colorization, some new log levels, and logging +to a file built-in as well as some other conveniences. +""" + +import logging +import threading +from enum import Enum +from logging import Logger +from logging.handlers import RotatingFileHandler + + +PROGRESS_LEVEL_NUM = 25 +SECTION_LEVEL_NUM = 26 +logging.addLevelName(PROGRESS_LEVEL_NUM, 'PROGRESS') +logging.addLevelName(SECTION_LEVEL_NUM, 'SECTION') + + +class LogLevel(Enum): + """ + Matches logging. int levels; wrapped in enum here for convenience + """ + CRITICAL = 50 + FATAL = CRITICAL + ERROR = 40 + WARNING = 30 + WARN = WARNING + INFO = 20 + DEBUG = 10 + NOTSET = 0 + + +class CustomLogger(Logger): + # Some decorations to match the paradigm used in some other .sh files + def progress(self, message: str, *args, **kws) -> None: + if self.isEnabledFor(PROGRESS_LEVEL_NUM): + self._log(PROGRESS_LEVEL_NUM, message, args, **kws) + + def separator(self, *args, **kws) -> None: + if self.isEnabledFor(logging.DEBUG) and self.isEnabledFor(SECTION_LEVEL_NUM): + self._log(SECTION_LEVEL_NUM, '-----------------------------------------------------------------------------', args, **kws) + + def header(self, message: str, *args, **kws) -> None: + if self.isEnabledFor(logging.DEBUG) and self.isEnabledFor(SECTION_LEVEL_NUM): + self._log(SECTION_LEVEL_NUM, f'----[{message}]----', args, **kws) + + +logging.setLoggerClass(CustomLogger) +LOG_FORMAT_STRING = '%(asctime)s - [tid:%(threadid)s] - [%(levelname)s]::%(message)s' + + +def build_logger(name: str, verbose: bool) -> CustomLogger: + logger = CustomLogger(name) + logger.setLevel(logging.INFO) + logger.addFilter(ThreadContextFilter()) + + stdout_handler = logging.StreamHandler() + file_handler = RotatingFileHandler(f'{name}.log') + + formatter = CustomFormatter() + stdout_handler.setFormatter(formatter) + # Don't want color escape characters in our file logging, so we just use the string rather than the whole formatter + file_handler.setFormatter(logging.Formatter(LOG_FORMAT_STRING)) + + logger.addHandler(stdout_handler) + logger.addHandler(file_handler) + + if verbose: + logger.setLevel(logging.DEBUG) + + # Prevent root logger propagation from duplicating results + logger.propagate = False + return logger + + +def set_loglevel(logger: logging.Logger, level: LogLevel) -> None: + if logger.handlers: + for handler in logger.handlers: + handler.setLevel(level.value) + + +def mute_logging(logger: logging.Logger) -> None: + if logger.handlers: + for handler in logger.handlers: + handler.setLevel(logging.CRITICAL + 1) + + +# Since we'll thread, let's point out threadid in our format +class ThreadContextFilter(logging.Filter): + def filter(self, record): + record.threadid = threading.get_ident() + return True + + +class CustomFormatter(logging.Formatter): + grey = "\x1b[38;21m" + blue = "\x1b[34;21m" + green = "\x1b[32;21m" + yellow = "\x1b[33;21m" + red = "\x1b[31;21m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + purple = "\x1b[35m" + + FORMATS = { + PROGRESS_LEVEL_NUM: blue + LOG_FORMAT_STRING + reset, + SECTION_LEVEL_NUM: green + LOG_FORMAT_STRING + reset, + logging.DEBUG: purple + LOG_FORMAT_STRING + reset, + logging.INFO: grey + LOG_FORMAT_STRING + reset, + logging.WARNING: yellow + LOG_FORMAT_STRING + reset, + logging.ERROR: red + LOG_FORMAT_STRING + reset, + logging.CRITICAL: bold_red + LOG_FORMAT_STRING + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno, self.format) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) diff --git a/.build/ci/precommit_check.sh b/.build/ci/precommit_check.sh new file mode 100755 index 000000000000..160cca04c16d --- /dev/null +++ b/.build/ci/precommit_check.sh @@ -0,0 +1,49 @@ +#!/bin/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. + + +source "logging.sh" + +skip_mypy=( + "./logging_helper.py" +) + +failed=0 +log_progress "Linting ci_parser..." +for i in `find . -maxdepth 1 -name "*.py"`; do + log_progress "Checking $i..." + flake8 "$i" + if [[ $? != 0 ]]; then + failed=1 + fi + + if [[ ! " ${skip_mypy[*]} " =~ ${i} ]]; then + mypy --ignore-missing-imports "$i" + if [[ $? != 0 ]]; then + failed=1 + fi + fi +done + + +if [[ $failed -eq 1 ]]; then + log_error "Failed linting. See above errors; don't merge until clean." + exit 1 +else + log_progress "All scripts passed checks" + exit 0 +fi diff --git a/.build/ci/requirements.txt b/.build/ci/requirements.txt new file mode 100644 index 000000000000..d09eaedf63eb --- /dev/null +++ b/.build/ci/requirements.txt @@ -0,0 +1,21 @@ +# 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. + +# +# Changes to this file must also be put into + +beautifulsoup4==4.12.3 +jinja2==3.1.5 diff --git a/.build/docker/_build-debian.sh b/.build/docker/_build-debian.sh new file mode 100755 index 000000000000..a34c0062488f --- /dev/null +++ b/.build/docker/_build-debian.sh @@ -0,0 +1,135 @@ +#!/bin/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. + +################################ +# +# Prep +# +################################ + +[ $DEBUG ] && set -x + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/..)" +[ "x${DIST_DIR}" != "x" ] || DIST_DIR="${CASSANDRA_DIR}/build" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +command -v git >/dev/null 2>&1 || { echo >&2 "git needs to be installed"; exit 1; } +command -v dch >/dev/null 2>&1 || { echo >&2 "dch needs to be installed"; exit 1; } +command -v dpkg-parsechangelog >/dev/null 2>&1 || { echo >&2 "dpkg-parsechangelog needs to be installed"; exit 1; } +command -v dpkg-buildpackage >/dev/null 2>&1 || { echo >&2 "dpkg-buildpackage needs to be installed"; exit 1; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } +[ -d "${DIST_DIR}" ] || mkdir -p "${DIST_DIR}" + +################################ +# +# Main +# +################################ + +set -e + +# note, this edits files in your working cassandra directory +pushd $CASSANDRA_DIR >/dev/null +# Restore the changelog on both success and failure so a retried cell does not stack +# generated versions from its previous attempt. +trap 'git -C "${CASSANDRA_DIR}" restore debian/changelog || true' EXIT +export BUILD_DIR="$(realpath --relative-to=$CASSANDRA_DIR ${DIST_DIR})" +export DEBFULLNAME="${DEBFULLNAME:-Apache Cassandra build}" +export DEBEMAIL="${DEBEMAIL:-dev@cassandra.apache.org}" + +# Used version for build will always depend on the git referenced used for checkout above +# Branches will always be created as snapshots, while tags are releases +tag=`git describe --tags --exact-match 2>/dev/null || true` +branch=`git symbolic-ref -q --short HEAD 2>/dev/null || true` + +is_tag=false +git_version='' + +# Parse version from build.xml so we can verify version against release tags and use the build.xml version +# for any branches. Truncate from snapshot suffix if needed. +buildxml_version=`grep 'property\s*name="base.version"' build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` +regx_snapshot="([0-9.]+)-SNAPSHOT$" +if [[ $buildxml_version =~ $regx_snapshot ]]; then + buildxml_version=${BASH_REMATCH[1]} +fi + +if [ "$tag" ]; then + is_tag=true + # Official release + regx_tag="cassandra-(([0-9.]+)(-(alpha|beta|rc)[0-9]+)?)$" + # Tentative release + regx_tag_tentative="(([0-9.]+)(-(alpha|beta|rc)[0-9]+)?)-tentative$" + if [[ $tag =~ $regx_tag ]] || [[ $tag =~ $regx_tag_tentative ]]; then + git_version=${BASH_REMATCH[1]} + else + echo "Error: could not recognize version from tag $tag">&2 + exit 2 + fi + # if CASSANDRA_VERSION is -alphaN, -betaN, -rcN, it fails on the '-' char; replace with '~' + CASSANDRA_VERSION=${git_version/-/\~} + CASSANDRA_REVISION='1' +else + regx_branch="cassandra-([0-9.]+)$" + if [[ $branch =~ $regx_branch ]]; then + git_version=${BASH_REMATCH[1]} + else + # This could be either trunk or any dev branch or SHA, so we won't be able to get the version + # from the branch name. In this case, fall back to debian change log version. + git_version=$(dpkg-parsechangelog | sed -ne 's/^Version: \(.*\).*/\1/p' | sed 's/~/-/') + if [ -z $git_version ]; then + echo "Error: could not recognize version from branch $branch">&2 + exit 2 + else + echo "Warning: could not recognize version from branch. dpkg version is $git_version" + fi + fi + # if CASSANDRA_VERSION is -alphaN, -betaN, -rcN, it fails on the '-' char; replace with '~' + CASSANDRA_VERSION=${buildxml_version/-/\~} + dt=`date +"%Y%m%d"` + ref=`git rev-parse --short HEAD || ( grep -q GitSHA src/resources/org/apache/cassandra/config/version.properties && grep GitSHA src/resources/org/apache/cassandra/config/version.properties | cut -d"=" -f2 ) || echo unknown` + CASSANDRA_REVISION="${dt}git${ref}" + dch -D unstable -v "${CASSANDRA_VERSION}-${CASSANDRA_REVISION}" --package "cassandra" "building ${CASSANDRA_VERSION}-${CASSANDRA_REVISION}" +fi + +# The version used for the deb build process will the current version in the debian/changelog file. +# See debian/rules for how the value is read. The only thing left for us to do here is to check if +# the changes file contains the correct version for the checked out git revision. The version value +# has to be updated manually by a committer and we only warn and abort on mismatches here. +changelog_version=$(dpkg-parsechangelog | sed -ne 's/^Version: \(.*\).*/\1/p' | sed 's/~/-/') +chl_expected="${buildxml_version}" +if [[ ! $changelog_version =~ $chl_expected ]]; then + echo "Error: changelog version (${changelog_version}) doesn't match expected (${chl_expected})">&2 + exit 3 +fi + +# Version (base.version) in build.xml must be set manually as well. Let's validate the set value. +if [ $buildxml_version != $git_version ]; then + echo "Warning: build.xml version ($buildxml_version) not matching git/dpkg derived version ($git_version)">&2 +fi + +# build package +dpkg-buildpackage -rfakeroot -uc -us -tc --source-option=--tar-ignore=.git + +set +e +# Move created artifacts to dist dir mapped to docker host directory (must have proper permissions) +mv ../cassandra[-_]*${CASSANDRA_VERSION}* "${DIST_DIR}" +# clean build deps +rm -f cassandra-build-deps_* +popd >/dev/null diff --git a/.build/docker/_build-redhat.sh b/.build/docker/_build-redhat.sh new file mode 100755 index 000000000000..dce83135098c --- /dev/null +++ b/.build/docker/_build-redhat.sh @@ -0,0 +1,125 @@ +#!/bin/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. + +################################ +# +# Prep +# +################################ + +# variables, w/ defaults, w/ checks +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/..)" +[ "x${DIST_DIR}" != "x" ] || DIST_DIR="${CASSANDRA_DIR}/build" +[ "x${RPM_BUILD_DIR}" != "x" ] || RPM_BUILD_DIR="$(mktemp -d /tmp/rpmbuild.XXXXXX)" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +command -v git >/dev/null 2>&1 || { echo >&2 "git needs to be installed"; exit 1; } +command -v rpmbuild >/dev/null 2>&1 || { echo >&2 "rpm-build needs to be installed"; exit 1; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } +[ -d "${DIST_DIR}" ] || mkdir -p "${DIST_DIR}" +[ -d "${RPM_BUILD_DIR}/SOURCES" ] || mkdir -p ${RPM_BUILD_DIR}/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + + +if [ "$1" == "-h" ]; then + echo "$0 [-h] [dist_type]" + echo "dist types are [rpm, noboolean] and rpm is default" + exit 1 +fi + +RPM_DIST=$1 +[ "x${RPM_DIST}" != "x" ] || RPM_DIST="rpm" + +if [ "${RPM_DIST}" == "rpm" ]; then + RPM_SPEC="redhat/cassandra.spec" +elif [ "${RPM_DIST}" == "noboolean" ]; then # noboolean + RPM_SPEC="redhat/noboolean/cassandra.spec" +else + echo >&2 "Only rpm and noboolean are valid dist_type arguments. Got ${RPM_DIST}" + exit 1 +fi + +################################ +# +# Main +# +################################ + +set -e + +# note, this edits files in your working cassandra directory +pushd $CASSANDRA_DIR >/dev/null + +# Used version for build will always depend on the git referenced used for checkout above +# Branches will always be created as snapshots, while tags are releases +tag=`git describe --tags --exact-match 2> /dev/null || true` +branch=`git symbolic-ref -q --short HEAD 2> /dev/null || true` + +is_tag=false +git_version='' + +# Parse version from build.xml so we can verify version against release tags and use the build.xml version +# for any branches. Truncate from snapshot suffix if needed. +buildxml_version=`grep 'property\s*name="base.version"' build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` +regx_snapshot="([0-9.]+)-SNAPSHOT$" +if [[ $buildxml_version =~ $regx_snapshot ]]; then + buildxml_version=${BASH_REMATCH[1]} +fi + +if [ "$tag" ]; then + is_tag=true + # Official release + regx_tag="cassandra-(([0-9.]+)(-(alpha|beta|rc)[0-9]+)?)$" + # Tentative release + regx_tag_tentative="(([0-9.]+)(-(alpha|beta|rc)[0-9]+)?)-tentative$" + if [[ $tag =~ $regx_tag ]] || [[ $tag =~ $regx_tag_tentative ]]; then + git_version=${BASH_REMATCH[1]} + else + echo "Error: could not recognize version from tag $tag">&2 + exit 2 + fi + if [ $buildxml_version != $git_version ]; then + echo "Error: build.xml version ($buildxml_version) not matching git tag derived version ($git_version)">&2 + exit 4 + fi + CASSANDRA_VERSION=$git_version + CASSANDRA_REVISION='1' +else + # This could be either trunk or any dev branch or SHA, so we won't be able to get the version + # from the branch name. In this case, fall back to version specified in build.xml. + CASSANDRA_VERSION="${buildxml_version}" + dt=`date +"%Y%m%d"` + ref=`git rev-parse --short HEAD || grep -q GitSHA src/resources/org/apache/cassandra/config/version.properties && grep GitSHA src/resources/org/apache/cassandra/config/version.properties | cut -d"=" -f2 || echo unknown` + CASSANDRA_REVISION="${dt}git${ref}" +fi + +# Artifact will only be used internally for build process and won't be found with snapshot suffix. +# Keep this outer build in /dist, but do not put build.dir in ANT_OPTS: rpmbuild invokes Ant +# again inside its extracted source tree and the 4.x spec copies files from build/ there. +ant artifacts -Dbuild.dir=${DIST_DIR} -Drelease=true -Dant.gen-doc.skip=true -Djavadoc.skip=true -Dcheck.skip=true +cp ${DIST_DIR}/apache-cassandra-*-src.tar.gz ${RPM_BUILD_DIR}/SOURCES/ + +# if CASSANDRA_VERSION is -alphaN, -betaN, -rcN, then rpmbuild fails on the '-' char; replace with '~' +CASSANDRA_VERSION=${CASSANDRA_VERSION/-/\~} +CASSANDRA_REVISION=${CASSANDRA_REVISION/-/_} + +command -v python >/dev/null 2>&1 || alias python=/usr/bin/python3 +rpmbuild --define="version ${CASSANDRA_VERSION}" --define="revision ${CASSANDRA_REVISION}" --define="_topdir ${RPM_BUILD_DIR}" -ba ${RPM_SPEC} +cp ${RPM_BUILD_DIR}/SRPMS/*.rpm ${RPM_BUILD_DIR}/RPMS/noarch/*.rpm ${DIST_DIR} + +popd >/dev/null diff --git a/.build/docker/_copy_ccm_repositories.sh b/.build/docker/_copy_ccm_repositories.sh new file mode 100755 index 000000000000..7515211e232a --- /dev/null +++ b/.build/docker/_copy_ccm_repositories.sh @@ -0,0 +1,51 @@ +#!/bin/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. + +################################ +# +# Prep +# +################################ + +if [ "$1" == "-h" ]; then + echo "$0 [-h]" + echo " this script is used internally by other scripts in the same directory to copy the image's ccm repositories into the dtest tmpdir" + exit 1 +fi + +command -v rsync >/dev/null 2>&1 || { echo >&2 "rsync needs to be installed"; exit 1; } + +################################ +# +# Main +# +################################ + + +# prepopulate a tmp ccm repository directory from whats already in the image +# +# the image has all versions and branches in ~/.ccm/repository already +# the container doesn't re-use ~/.ccm/repository so to avoid any writes in the containerfs +# so we rsync what's important from ~/.ccm/repository to the configured ${CCM_CONFIG_DIR} +# this appears to take a long time, but should be faster than ccm downloading +# rsync is used as it's the friendlier approach against open file ulimits +echo -n "prepopulating ${CCM_CONFIG_DIR}/repository/ for upgrade tests…" +mkdir -p "${CCM_CONFIG_DIR}/repository/" +rsync -rptgoL --include '[1-9]*' ${HOME}/.ccm/repository ${CCM_CONFIG_DIR}/ +rsync -rptgoL --include '_git_cache_apache' ${HOME}/.ccm/repository ${CCM_CONFIG_DIR}/ +rsync -rptgoL --include 'gitCOLON*' ${HOME}/.ccm/repository ${CCM_CONFIG_DIR}/ +echo " complete" diff --git a/.build/docker/_create_user.sh b/.build/docker/_create_user.sh new file mode 100755 index 000000000000..7922e7a3565d --- /dev/null +++ b/.build/docker/_create_user.sh @@ -0,0 +1,70 @@ +#!/bin/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. + +################################ +# +# Prep +# +################################ + +if [ "$1" == "-h" ]; then + echo "$0 [-h] " + echo " this script is used internally by other scripts in the same directory to create a user with the running host user's same uid and gid" + exit 1 +fi + +# arguments +username=$1 +uid=$2 +gid=$3 + +################################ +# +# Main +# +################################ + +if grep "^ID=" /etc/os-release | grep -q 'debian\|ubuntu' ; then + adduser --quiet --disabled-login --no-create-home --uid $uid --gecos ${username} ${username} + groupmod --non-unique -g $gid $username + gpasswd -a ${username} sudo >/dev/null +else + adduser --no-create-home --uid $uid ${username} +fi + +# sudo priviledges +echo "${username} ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/${username} +chmod 0440 /etc/sudoers.d/${username} +mkdir -p ${BUILD_HOME}/docker ${DIST_DIR} ${BUILD_HOME}/.ssh + +# rsync in cached maven dependencies +echo "Syncing maven dependencies and gradle wrapper" +rsync -a /home/image-cache/.m2/repository/ ${BUILD_HOME}/.m2/repository/ +cp -a /home/image-cache/.gradle ${BUILD_HOME}/ +chown -R ${username}:${username} ${BUILD_HOME}/.gradle ${BUILD_HOME}/.m2 + +# we need to make SSH less strict to prevent various dtests from failing when they attempt to +# git clone a given commit/tag/etc +echo 'Host *\n UserKnownHostsFile /dev/null\n StrictHostKeyChecking no' > ${BUILD_HOME}/.ssh/config + +# proper permissions +chown ${username}:${username} ${BUILD_HOME} ${BUILD_HOME}/docker ${DIST_DIR} ${BUILD_HOME}/.ssh ${BUILD_HOME}/.ssh/config +chmod og+wx ${BUILD_HOME} ${DIST_DIR} +chmod 600 ${BUILD_HOME}/.ssh/config + +# disable git directory ownership checks +su ${username} -c "git config --global safe.directory '*'" diff --git a/.build/docker/_docker_init_tests.sh b/.build/docker/_docker_init_tests.sh new file mode 100755 index 000000000000..cd759c5cd9b0 --- /dev/null +++ b/.build/docker/_docker_init_tests.sh @@ -0,0 +1,48 @@ +#!/bin/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. + +# pre-conditions +[ "x" != "x${DIST_DIR}" ] || { echo "DIST_DIR must be defined"; exit 1 ; } +[ "x" != "x${TEST_SCRIPT}" ] || { echo "TEST_SCRIPT must be defined"; exit 1 ; } +[ "x" != "x${CASSANDRA_DIR}" ] || { echo "CASSANDRA_DIR must be defined"; exit 1 ; } + +# usage +if [ "$1" == "-h" ]; then + echo "$0 [-h] ..." + echo " this script is used by run-tests.sh (in the same directory) as a wrapper delegating the execution of the ${TEST_SCRIPT}. all arguments are passed through as-is to ${TEST_SCRIPT}" + exit 1 +fi + +pushd "${CASSANDRA_DIR}" >/dev/null + +echo "Running ${TEST_SCRIPT} $@" +.build/${TEST_SCRIPT} "$@" +status=$? +if [ -d "${DIST_DIR}/test/logs" ]; then + find "${DIST_DIR}/test/logs" -type f -name "*.log" | xargs xz -qq +fi +popd >/dev/null + +# check/clean containerfs (it can leak on host) +if [ -d /home/cassandra-tmp/.m2/repository ]; then + echo "WARN: /home/cassandra-tmp/.m2/repository exists" +fi +# these happen when the image hasn't pre-downloaded all the ccm versions used in tests +rm -rf /tmp/ccm-*.tar.gz + +set -x +exit ${status} \ No newline at end of file diff --git a/.build/docker/_docker_run.sh b/.build/docker/_docker_run.sh new file mode 100755 index 000000000000..038c4361cb94 --- /dev/null +++ b/.build/docker/_docker_run.sh @@ -0,0 +1,160 @@ +#!/bin/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. + +# +# Creates the artifacts, performing additional QA checks +# +# Usage: _docker_run.sh + +################################ +# +# Prep +# +################################ + +[ $DEBUG ] && set -x + +# variables, with defaults +[ "x${cassandra_dir}" != "x" ] || cassandra_dir="$(readlink -f $(dirname -- "$0")/../..)" +[ "x${build_dir}" != "x" ] || build_dir="${cassandra_dir}/build" +# parameterise the maven repository host directory, as it cannot be shared across containers +# m2_dir fails under /tmp on macos +[ "x${m2_dir}" != "x" ] || m2_dir="${HOME}/.m2/repository" +[ -d "${build_dir}" ] || { mkdir -p "${build_dir}" ; } +[ -d "${m2_dir}" ] || { mkdir -p "${m2_dir}" ; } + +java_version_default=`grep 'property\s*name="java.default"' ${cassandra_dir}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` +java_version_supported=`grep 'property\s*name="java.supported"' ${cassandra_dir}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` + +if [ "$1" == "-h" ]; then + echo "$0 [-h] []" + echo " this script is used by check|build*.sh scripts (in the same directory) as a wrapper delegating the container run of the and execution of the , and using [] is specified" + exit 1 +fi + +# arguments +dockerfile=$1 +run_script=$2 +java_version=$3 + +# pre-conditions +command -v docker >/dev/null 2>&1 || { echo >&2 "docker needs to be installed"; exit 1; } +command -v timeout >/dev/null 2>&1 || { echo >&2 "timeout needs to be installed"; exit 1; } +(docker info >/dev/null 2>&1) || { echo >&2 "docker needs to running"; exit 1; } +[ -f "${cassandra_dir}/build.xml" ] || { echo >&2 "${cassandra_dir}/build.xml must exist"; exit 1; } +[ -f "${cassandra_dir}/.build/docker/${dockerfile}" ] || { echo >&2 "${cassandra_dir}/.build/docker/${dockerfile} must exist"; exit 1; } +[ -f "${cassandra_dir}/.build/${run_script}" ] || { echo >&2 "${cassandra_dir}/.build/${run_script} must exist"; exit 1; } +[ "${build_dir:0:1}" == "/" ] || { echo >&2 "\$build_dir must be provided as an absolute path, was ${build_dir}"; exit 1; } + +if [ "x${java_version}" == "x" ] ; then + echo "Defaulting to java ${java_version_default}" + java_version="${java_version_default}" +fi + +regx_java_version="(${java_version_supported//,/|})" +if [[ ! "${java_version}" =~ $regx_java_version ]]; then + echo "Error: Java version is not in ${java_version_supported}, it is set to ${java_version}" + exit 1 +fi + +# print debug information on versions +docker --version + +# make sure build_dir is good +chmod -R ag+rwx ${build_dir} + + +################################ +# +# Main +# +################################ + +# git worktrees need their original working directory (in its original path) +if [ -f ${cassandra_dir}/.git ] ; then + git_location="$(cat ${cassandra_dir}/.git | awk -F".git" '{print $1}' | awk '{print $2}')" + docker_volume_opt="${docker_volume_opt} -v${git_location}:${git_location}" +fi + +pushd ${cassandra_dir}/.build >/dev/null + +image_tag="$(md5sum docker/${dockerfile} | cut -d' ' -f1)" +image_name="apache/cassandra-${dockerfile/.docker/}:${image_tag}" + +# Look for existing docker image, otherwise build +if ! ( [[ "$(docker images -q ${image_name} 2>/dev/null)" != "" ]] ) ; then + echo "Build image not found locally, pulling image ${image_name}..." + if ! ( docker pull -q ${image_name} >/dev/null 2>/dev/null ) ; then + # Create build images containing the build tool-chain, Java and an Apache Cassandra git working directory, with retry + echo "Building docker image..." + until docker build -t ${image_name} -f docker/${dockerfile} --load . ; do + echo "docker build failed… trying again in 10s… " + sleep 10 + done + echo "Docker image ${image_name} has been built" + else + echo "Successfully pulled build image." + fi +else + echo "Found build image locally." +fi + +# Run build script through docker +random_string="$(LC_ALL=C tr -dc A-Za-z0-9 /dev/null 2>/dev/null & ) +popd >/dev/null +[ $RETURN -eq 0 ] && echo "Build directory found at ${build_dir}" +exit $RETURN diff --git a/.build/docker/_ensure_jdk8.sh b/.build/docker/_ensure_jdk8.sh new file mode 100755 index 000000000000..37466d943767 --- /dev/null +++ b/.build/docker/_ensure_jdk8.sh @@ -0,0 +1,69 @@ +#!/bin/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. + +# Download a pinned JDK 8 into the Maven-backed build cache and print its directory. +# This runs inside build containers after they start, avoiding changes to the shared +# 5.0+ images while retaining the 4.x JDK 8/11 matrix. + +set -euo pipefail + +cache_root=${1:?cache directory is required} +version=8u502b07 +tag=jdk8u502-b07 +jdk_dir="${cache_root}/temurin-${version}" + +case "$(uname -m)" in + x86_64|amd64) + arch=x64 + sha256=b8f5440f64f50193c01f67dacba55c9660caffe13b908baf6bd1955f4dd4c3ea + ;; + aarch64|arm64) + arch=aarch64 + sha256=34912db17786f7144dab274f040a42028e25da6e7a6a09780d7013339a56bdb2 + ;; + *) + echo "Unsupported architecture for JDK 8: $(uname -m)" >&2 + exit 1 + ;; +esac + +mkdir -p "${cache_root}" +command -v flock >/dev/null 2>&1 || { echo "flock is required to cache JDK 8" >&2; exit 1; } +command -v curl >/dev/null 2>&1 || { echo "curl is required to download JDK 8" >&2; exit 1; } +command -v sha256sum >/dev/null 2>&1 || { echo "sha256sum is required to verify JDK 8" >&2; exit 1; } + +# Multiple matrix cells can start together against the same Maven/cache mount. +exec 9>"${cache_root}/.temurin8.lock" +flock 9 + +if [ ! -x "${jdk_dir}/bin/javac" ]; then + archive=$(mktemp "${cache_root}/temurin8.XXXXXX.tar.gz") + extracted=$(mktemp -d "${cache_root}/temurin8.XXXXXX") + trap 'rm -rf "${archive:-}" "${extracted:-}"' EXIT + + url="https://github.com/adoptium/temurin8-binaries/releases/download/${tag}/OpenJDK8U-jdk_${arch}_linux_hotspot_${version}.tar.gz" + echo "Downloading Temurin JDK 8 for ${arch}…" >&2 + curl -fL --retry 9 --retry-connrefused --retry-delay 1 "${url}" -o "${archive}" + echo "${sha256} ${archive}" | sha256sum -c - >&2 + tar -xzf "${archive}" --strip-components=1 -C "${extracted}" + rm -rf "${jdk_dir}" + mv "${extracted}" "${jdk_dir}" + rm -f "${archive}" + trap - EXIT +fi + +printf '%s\n' "${jdk_dir}" diff --git a/.build/docker/_prepopulate_maven_deps.sh b/.build/docker/_prepopulate_maven_deps.sh new file mode 100755 index 000000000000..34f2ccec2305 --- /dev/null +++ b/.build/docker/_prepopulate_maven_deps.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# +# 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. + +set -e + +# Script to prepopulate Maven repository with dependencies from multiple Cassandra branches +# This will download all dependencies to a custom Maven repository directory + +# pre-conditions +command -v ant >/dev/null 2>&1 || { error 1 "ant needs to be installed"; } +command -v git >/dev/null 2>&1 || { error 1 "git needs to be installed"; } + + +error() { + echo >&2 $2; + set -x + exit $1 +} + +# Function to download dependencies for a branch +download_deps_for_branch() { + local branch=$1 + local branch_name=$(echo "$branch" | sed 's|origin/||') + + # Check if branch exists + if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then + echo "WARNING: Branch $branch does not exist, skipping..." + return + fi + + git checkout "$branch" + + echo "" + echo "Downloading dependencies for $branch to $CUSTOM_M2_REPO..." + echo "" + + # ensure git modules are initialised + ant init + # download all dependencies + ant -Dmaven.repo.local="$CUSTOM_M2_REPO" -Dlocal.repository="$CUSTOM_M2_REPO" resolver-dist-lib +} + +CUSTOM_M2_REPO="${1:-$HOME/.m2/repository}" +TMP_DIR=${TMP_DIR:-/tmp} + +cd $TMP_DIR +git clone https://github.com/apache/cassandra.git +cd cassandra +git config advice.detachedHead false + +# Automatically detect branches from cassandra-5.0 onwards to trunk +echo "Detecting branches..." +BRANCHES=() + +# Get all origin branches matching cassandra-5.x+, cassandra-6.x+, etc., and trunk +# Pattern matches: cassandra-5.0, cassandra-5.0.0, cassandra-10.0, cassandra-10.0.1, trunk +while IFS= read -r branch; do + BRANCHES+=("$branch") +done < <(git branch -r | grep -E "^\s*origin/(cassandra-[5-9][0-9]*\.[0-9]+(\.[0-9]+)?|trunk)$" | sed 's/^[[:space:]]*//' | sort -V) + +# If no branches found, fail +if [ ${#BRANCHES[@]} -eq 0 ]; then + echo "ERROR: No branches auto-detected matching pattern origin/cassandra-[5+].x or origin/trunk" + echo "Please ensure you have fetched remote branches: git fetch origin" + exit 1 +fi + +echo "Branches to process:" +for branch in "${BRANCHES[@]}"; do + echo " - $branch" +done +echo "==========================================" +echo "" + +# Create custom Maven repository directory +mkdir -p "$CUSTOM_M2_REPO" + +# Process each branch +for branch in "${BRANCHES[@]}"; do + download_deps_for_branch "$branch" +done + +cd - +rm -rf $TMP_DIR/cassandra \ No newline at end of file diff --git a/.build/docker/_set_java.sh b/.build/docker/_set_java.sh new file mode 100755 index 000000000000..cce5e3836a1f --- /dev/null +++ b/.build/docker/_set_java.sh @@ -0,0 +1,75 @@ +#!/bin/bash -e +# 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. + +################################ +# +# Prep +# +################################ + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || { CASSANDRA_DIR="$(pwd)"; } +[ -d "${CASSANDRA_DIR}" ] || { echo >&2 "Directory ${CASSANDRA_DIR} must exist"; exit 1; } + +# pre-conditions +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } + +java_version_default=`grep 'property\s*name="java.default"' ${CASSANDRA_DIR}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` +java_version_supported=`grep 'property\s*name="java.supported"' ${CASSANDRA_DIR}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` + +if [ "$1" == "-h" ]; then + echo "$0 [-h] []" + echo " if Java version is not set, it is set to ${java_version_default} by default, valid ${java_version_supported}" + echo + echo " this script is used internally by other scripts in the same directory to ensure the correct java version is used inside the docker container" + exit 1 +fi + +# arguments +java_version=$1 + +[ "x${java_version}" != "x" ] || java_version="${java_version_default}" +regx_java_version="(${java_version_supported//,/|})" +if [[ ! "$java_version" =~ $regx_java_version ]]; then + echo "Error: Java version is not in ${java_version_supported}, it is set to $java_version" + exit 1 +fi + +################################ +# +# Main +# +################################ + +if [ "${java_version}" = "8" ] && [ -x /opt/java/openjdk8/bin/javac ]; then + # Also accept a site-provided JDK without downloading another copy. + export JAVA_HOME=/opt/java/openjdk8 + export PATH="${JAVA_HOME}/bin:${PATH}" +elif [ "${java_version}" = "8" ] && ! compgen -G '/usr/lib/jvm/java-8-openjdk-*/bin/javac' >/dev/null; then + # The shared build images do not carry JDK 8. Fetch it after container startup into + # the Maven-backed cache, which is reused by every matrix cell on this workspace. + export JAVA_HOME="$("${CASSANDRA_DIR}/.build/docker/_ensure_jdk8.sh" "${HOME}/.m2/repository/.ci-jdks")" + export PATH="${JAVA_HOME}/bin:${PATH}" +elif grep "^ID=" /etc/os-release | grep -q 'debian\|ubuntu' ; then + sudo update-java-alternatives --set java-1.${java_version}.0-openjdk-$(dpkg --print-architecture) + export JAVA_HOME=$(readlink -f /usr/bin/javac | sed "s:/bin/javac::") +else + sudo alternatives --set java $(alternatives --display java | grep "family java-${java_version}-openjdk" | cut -d' ' -f1) + sudo alternatives --set javac $(alternatives --display javac | grep "family java-${java_version}-openjdk" | cut -d' ' -f1) + export JAVA_HOME=$(readlink -f /usr/bin/javac | sed "s:/bin/javac::") +fi +echo "Using Java ${java_version} from ${JAVA_HOME}" diff --git a/.build/docker/almalinux-build.docker b/.build/docker/almalinux-build.docker new file mode 100644 index 000000000000..9be81bf0408e --- /dev/null +++ b/.build/docker/almalinux-build.docker @@ -0,0 +1,68 @@ +# 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. + +FROM almalinux:8 +LABEL org.opencontainers.image.authors="Apache Cassandra " + +# CONTEXT is expected to be cassandra/.build + +ENV BUILD_HOME=/home/build +ENV RPM_BUILD_DIR=$BUILD_HOME/rpmbuild +ENV DIST_DIR=/dist +ENV CASSANDRA_DIR=$BUILD_HOME/cassandra +ARG UID_ARG=1000 +ARG GID_ARG=1000 + +LABEL org.cassandra.buildenv=almalinux_build + +RUN echo "Building with arguments:" \ + && echo " - DIST_DIR=${DIST_DIR}" \ + && echo " - BUILD_HOME=${BUILD_HOME}" \ + && echo " - RPM_BUILD_DIR=${RPM_BUILD_DIR}" \ + && echo " - CASSANDRA_DIR=${CASSANDRA_DIR}" \ + && echo " - UID_ARG=${UID_ARG}" \ + && echo " - GID_ARG=${GID_ARG}" + +# install deps +RUN yum -y install \ + ant \ + git \ + java-11-openjdk-devel \ + java-17-openjdk-devel \ + make \ + rpm-build \ + sudo \ + python3-pip \ + procps \ + rsync \ + && yum clean all && rm -rf /var/cache/yum /var/cache/dnf + +# download, install and then remove the ant-junit rpm in a single layer so the downloaded package does not linger in the image +RUN until curl -f -S -s --retry 9 --retry-connrefused --retry-delay 1 https://vault.centos.org/7.9.2009/os/x86_64/Packages/ant-junit-1.9.4-2.el7.noarch.rpm -o ant-junit-1.9.4-2.el7.noarch.rpm ; do echo "curl failed… trying again in 10s… " ; sleep 10 ; done \ + && rpm -i --nodeps ant-junit-1.9.4-2.el7.noarch.rpm \ + && rm -f ant-junit-1.9.4-2.el7.noarch.rpm + +# python3 is needed for the gen-doc target +RUN pip3 install --upgrade pip + +# Prepopulate Maven repository with dependencies from all branches. see _create_user.sh +COPY docker/_prepopulate_maven_deps.sh /tmp/_prepopulate_maven_deps.sh +RUN alternatives --set java $(alternatives --display java | grep "family java-11-openjdk" | cut -d' ' -f1) +RUN alternatives --set javac $(alternatives --display javac | grep "family java-11-openjdk" | cut -d' ' -f1) +RUN mkdir -p /home/image-cache && chmod -R a+rwx /home/image-cache +RUN JAVA_HOME=$(readlink -f /usr/bin/javac | sed "s:/bin/javac::") \ + bash /tmp/_prepopulate_maven_deps.sh /home/image-cache/.m2/repository && rm /tmp/_prepopulate_maven_deps.sh +RUN cp -a /root/.gradle /home/image-cache/.gradle diff --git a/.build/docker/build-artifacts.sh b/.build/docker/build-artifacts.sh new file mode 100755 index 000000000000..65eb51397bc8 --- /dev/null +++ b/.build/docker/build-artifacts.sh @@ -0,0 +1,22 @@ +#!/bin/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. + +# +# Creates the tarball artifact + +$(dirname -- "$0")/_docker_run.sh bullseye-build.docker build-artifacts.sh $1 +exit $? diff --git a/.build/docker/build-debian.sh b/.build/docker/build-debian.sh new file mode 100755 index 000000000000..974cd46e00bf --- /dev/null +++ b/.build/docker/build-debian.sh @@ -0,0 +1,54 @@ +#!/bin/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. + +[ $DEBUG ] && set -x + +if [ "$1" == "-h" ]; then + echo "$0 [-h] []" + echo " build debian packages" + exit 1 +fi + +echo +echo "===" +echo "WARNING: this script modifies local versioned files" +echo "===" +echo + +# +# Creates the debian package + +# debian/rules runs `ant realclean`, which must be able to remove build/ itself. +# Use the checkout's normal build/ directory for Ant and a separate bind mount for +# finished packages; mounting build/ itself at /dist would make realclean fail with EBUSY. +[ "x${cassandra_dir}" != "x" ] || cassandra_dir="$(readlink -f $(dirname -- "$0")/../..)" +[ "x${build_dir}" != "x" ] || build_dir="${cassandra_dir}/build" +package_dir="${build_dir}.debian-packages" +rm -rf "${package_dir}" +mkdir -p "${package_dir}" + +build_dir="${package_dir}" CASSANDRA_DOCKER_USE_DEFAULT_BUILD_DIR=true \ + $(dirname -- "$0")/_docker_run.sh bullseye-build.docker docker/_build-debian.sh "$1" +status=$? +if [ ${status} -eq 0 ]; then + mkdir -p "${build_dir}" + for artifact in "${package_dir}"/*; do + [ -e "${artifact}" ] && mv "${artifact}" "${build_dir}/" + done + rm -rf "${package_dir}" +fi +exit ${status} diff --git a/.build/docker/build-jars.sh b/.build/docker/build-jars.sh new file mode 100755 index 000000000000..a23f2e6d5969 --- /dev/null +++ b/.build/docker/build-jars.sh @@ -0,0 +1,22 @@ +#!/bin/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. + +# +# Build the jars + +$(dirname -- "$0")/_docker_run.sh bullseye-build.docker build-jars.sh $1 +exit $? diff --git a/.build/docker/build-redhat.sh b/.build/docker/build-redhat.sh new file mode 100755 index 000000000000..0ed9d266e2c5 --- /dev/null +++ b/.build/docker/build-redhat.sh @@ -0,0 +1,44 @@ +#!/bin/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. + + +if [ "$1" == "-h" ]; then + echo "$0 [-h] [rpm|noboolean] []" + echo " build redhat packages, specify noboolean for legacy (centos7) compatibility" + exit 1 +fi + +# arguments +rpm_dist=$1 +java_version=$2 + + +echo +echo "===" +echo "WARNING: this script modifies local versioned files" +echo "===" +echo + +# +# Creates the redhat package + +# rpmbuild extracts the source tarball and its spec expects Ant output under that +# source tree's build/. Do not globally redirect every nested Ant invocation to /dist; +# _build-redhat.sh redirects only the initial artifacts build explicitly. +CASSANDRA_DOCKER_USE_DEFAULT_BUILD_DIR=true \ + $(dirname -- "$0")/_docker_run.sh almalinux-build.docker docker/_build-redhat.sh "${java_version}" "${rpm_dist}" +exit $? diff --git a/.build/docker/bullseye-build.docker b/.build/docker/bullseye-build.docker new file mode 100644 index 000000000000..b852aa19e500 --- /dev/null +++ b/.build/docker/bullseye-build.docker @@ -0,0 +1,62 @@ +# 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. + +FROM debian:bullseye +LABEL org.opencontainers.image.authors="Apache Cassandra " + +# CONTEXT is expected to be cassandra/.build + +ENV DIST_DIR=/dist +ENV BUILD_HOME=/home/build +ENV CASSANDRA_DIR=$BUILD_HOME/cassandra + +LABEL org.cassandra.buildenv=debian_build + +RUN echo "Building with arguments:" \ + && echo " - DIST_DIR=${DIST_DIR}" \ + && echo " - BUILD_HOME=${BUILD_HOME}" \ + && echo " - CASSANDRA_DIR=${CASSANDRA_DIR}" + +# configure apt to retry downloads +RUN echo 'APT::Acquire::Retries "99";' > /etc/apt/apt.conf.d/80-retries +RUN echo 'Acquire::http::Timeout "60";' > /etc/apt/apt.conf.d/80proxy.conf +RUN echo 'Acquire::ftp::Timeout "60";' >> /etc/apt/apt.conf.d/80proxy.conf + +# install deps +RUN until apt-get update \ + && apt-get -y install ant build-essential curl devscripts ed git sudo \ + python3-pip rsync procps dh-python quilt bash-completion \ + && apt-get clean && rm -rf /var/lib/apt/lists/* ; \ + do echo "apt failed… trying again in 10s… " ; sleep 10 ; done + +RUN until apt-get update \ + && apt-get install -y --no-install-recommends openjdk-11-jdk openjdk-17-jdk \ + && apt-get clean && rm -rf /var/lib/apt/lists/* ; \ + do echo "apt failed… trying again in 10s… " ; sleep 10 ; done + +RUN update-java-alternatives --set java-1.11.0-openjdk-$(dpkg --print-architecture) + +# python3 is needed for the gen-doc target +RUN pip install --upgrade pip + +# dependencies for .build/ci/ci_parser.py +RUN pip install beautifulsoup4==4.12.3 jinja2==3.1.3 + +# Prepopulate Maven repository with dependencies from all branches. see _create_user.sh +COPY docker/_prepopulate_maven_deps.sh /tmp/_prepopulate_maven_deps.sh +RUN mkdir -p /home/image-cache && chmod -R a+rwx /home/image-cache +RUN bash /tmp/_prepopulate_maven_deps.sh /home/image-cache/.m2/repository && rm /tmp/_prepopulate_maven_deps.sh +RUN cp -a /root/.gradle /home/image-cache/.gradle \ No newline at end of file diff --git a/.build/docker/run-tests.sh b/.build/docker/run-tests.sh new file mode 100755 index 000000000000..c9104025837d --- /dev/null +++ b/.build/docker/run-tests.sh @@ -0,0 +1,344 @@ +#!/bin/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. + +# +# A wrapper script to run-tests.sh (or dtest-python.sh) in docker. +# + +[ $DEBUG ] && set -x + +# variables, with defaults +[ "x${cassandra_dir}" != "x" ] || cassandra_dir="$(readlink -f $(dirname -- "$0")/../..)" +[ "x${cassandra_dtest_dir}" != "x" ] || cassandra_dtest_dir="${cassandra_dir}/../cassandra-dtest" +[ "x${build_dir}" != "x" ] || build_dir="${cassandra_dir}/build" +# parameterise the maven repository host directory, as it cannot be shared across containers. +# m2_dir fails under /tmp on macos +[ "x${m2_dir}" != "x" ] || m2_dir="${HOME}/.m2/repository" +[ "x${docker_timeout_hours}" != "x" ] || docker_timeout_hours="1" +[ -d "${build_dir}" ] || { mkdir -p "${build_dir}" ; } +[ -d "${m2_dir}" ] || { mkdir -p "${m2_dir}" ; } + +# source TARGET_TYPES from the non-docker scripts +_target_test_types="$(grep '^TARGET_TYPES=' "${cassandra_dir}/.build/run-tests.sh" | cut -d'"' -f2)" +_target_dtest_types="$(cd ${cassandra_dir}; bash <(sed -n "/^TARGET_TYPES=/,/^done$/p" .build/run-python-dtests.sh; echo 'echo ${TARGET_TYPES}'))" +TARGET_TYPES="${_target_test_types} ${_target_dtest_types}" + +print_help() { + echo "" + echo "Usage: $0 [-a|-t|-c|-j|-h] [extra arguments]" + echo " -a Test target type: ${TARGET_TYPES}" + echo " -t Test name regexp to run." + echo " -c Chunk to run in the form X/Y: Run chunk X from a total of Y chunks." + echo " -j Java version. Default java_version is what 'java.default' specifies in build.xml." + echo " [extra arguments] will be passed to downstream scripts." + exit 1 +} + +error() { + echo >&2 $2; + set -x + exit $1 +} + +# legacy argument handling +if [[ " ${TARGET_TYPES} " =~ " ${1} " ]]; then + test_type="-a ${1}" + if [[ -z ${2} ]]; then + test_list="" + elif [[ -n ${2} && "${2}" =~ ^[0-9]+/[0-9]+$ ]]; then + test_list="-c ${2}"; + else + test_list="-t ${2}"; + fi + if [[ -n ${3} ]]; then java_version="-j ${3}"; else java_version=""; fi + echo "Using deprecated legacy arguments. Please update to new parameter format: ${test_type} ${test_list} ${java_version}" + $0 ${test_type} ${test_list} ${java_version} + exit $? +fi + +env_vars="" +while getopts ":a:t:c:e:hj:" opt; do + # shellcheck disable=SC2220 + # Invalid flags check disabled as we'll pass them to other scripts + case $opt in + a ) test_target="$OPTARG" + [[ " ${TARGET_TYPES} " =~ " ${test_target/-repeat/} " ]] || error 1 "Invalid test target type '${test_target}'. Valid types: ${TARGET_TYPES}" + ;; + t ) test_name_regexp="$OPTARG" + ;; + c ) chunk="$OPTARG" + ;; + j ) java_version="$OPTARG" + ;; + e ) env_vars="${env_vars} -e $OPTARG" + ;; + h ) print_help + exit 0 + ;; + e) ;; # Repeat vars are just passed to downstream run-tests-enhaced.sh + \?) die "Invalid option: -$OPTARG" + ;; + esac +done + +# pre-conditions +command -v docker >/dev/null 2>&1 || { error 1 "docker needs to be installed"; } +command -v bc >/dev/null 2>&1 || { error 1 "bc needs to be installed"; } +command -v timeout >/dev/null 2>&1 || { error 1 "timeout needs to be installed"; } +(docker info >/dev/null 2>&1) || { error 1 "docker needs to running"; } +[ -f "${cassandra_dir}/build.xml" ] || { error 1 "${cassandra_dir}/build.xml must exist"; } +[ -f "${cassandra_dir}/.build/run-tests.sh" ] || { error 1 "${cassandra_dir}/.build/run-tests.sh must exist"; } + +# arguments +target=${test_target} +split_chunk="1/1" +split_chunk=${chunk-'1/1'} +test_name_regexp=${test_name_regexp} +java_version=${java_version} + +test_script="run-tests.sh" +java_version_default=`grep 'property\s*name="java.default"' ${cassandra_dir}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` +java_version_supported=`grep 'property\s*name="java.supported"' ${cassandra_dir}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` + +if [ "x${java_version}" == "x" ] ; then + echo "Defaulting to java ${java_version_default}" + java_version="${java_version_default}" +fi + +regx_java_version="(${java_version_supported//,/|})" +if [[ ! "${java_version}" =~ $regx_java_version ]]; then + error 1 "Error: Java version is not in ${java_version_supported}, it is set to ${java_version}" +fi + +# allow python version override, otherwise default to current python version or 3.8 +if [ "x" == "x${python_version}" ] ; then + command -v python >/dev/null 2>&1 && python_version="$(python -V 2>&1 | awk '{print $2}' | awk -F'.' '{print $1"."$2}')" + python_version="${python_version:-3.8}" +fi + +# print debug information on versions +docker --version + +pushd ${cassandra_dir}/.build >/dev/null + +# build test image +dockerfile="ubuntu-test.docker" +image_tag="$(md5sum docker/${dockerfile} | cut -d' ' -f1)" +image_name="apache/cassandra-${dockerfile/.docker/}:${image_tag}" +docker_mounts="-v ${cassandra_dir}:/home/cassandra/cassandra -v "${build_dir}":/home/cassandra/cassandra/build -v ${m2_dir}:/home/cassandra/.m2/repository" +# HACK hardlinks in overlay are buggy, the following mount prevents hardlinks from being used. ref $TMP_DIR in .build/run-tests.sh +docker_mounts="${docker_mounts} -v "${build_dir}/tmp":/home/cassandra/cassandra/build/tmp" + +# Look for existing docker image, otherwise build +if ! ( [[ "$(docker images -q ${image_name} 2>/dev/null)" != "" ]] ) ; then + echo "Build image not found locally, pulling image ${image_name}..." + if ! ( docker pull -q ${image_name} >/dev/null 2>/dev/null ) ; then + # Create build images containing the build tool-chain, Java and an Apache Cassandra git working directory, with retry + echo "Building docker image..." + until docker build -t ${image_name} -f docker/${dockerfile} --load . ; do + echo "docker build failed… trying again in 10s… " + sleep 10 + done + echo "Docker image ${image_name} has been built" + else + echo "Successfully pulled build image." + fi +else + echo "Found build image locally." +fi + +pushd ${cassandra_dir} >/dev/null + +# Optional lookup of Jenkins environment to see how many executors on this machine. `jenkins_executors=1` is used for anything non-jenkins. +jenkins_executors=1 +if [[ ! -z ${JENKINS_URL+x} ]] && [[ ! -z ${NODE_NAME+x} ]] ; then + fetched_jenkins_executors=$(curl -s --retry 9 --retry-connrefused --retry-delay 1 "${JENKINS_URL}/computer/${NODE_NAME}/api/json?pretty=true" | grep 'numExecutors' | awk -F' : ' '{print $2}' | cut -d',' -f1) + # use it if we got a valid number (despite retry settings the curl above can still fail + [[ ${fetched_jenkins_executors} =~ '^[0-9]+$' ]] && jenkins_executors=${fetched_jenkins_executors} +fi + +# find host's available cores and mem +cores=$(docker run --rm alpine:3.19.1 nproc --all) || { error 1 "Unable to check available CPU cores"; } + +case $(uname) in + "Linux") + mem=$(docker run --rm alpine:3.19.1 free -b | grep Mem: | awk '{print $2}') || { error 1 "Unable to check available memory"; } + ;; + "Darwin") + mem=$(sysctl -n hw.memsize) || { error 1 "Unable to check available memory"; } + ;; + *) + error 1 "Unsupported operating system, expected Linux or Darwin" +esac + +# figure out resource limits, scripts, and mounts for the test type +docker_flags="-m 5g --memory-swap 5g" +case ${test_target/-repeat/} in + "build_dtest_jars") + ;; + "stress-test" | "fqltool-test" ) + [[ ${mem} -gt $((1 * 1024 * 1024 * 1024 * ${jenkins_executors})) ]] || { error 1 "${target} require minimum docker memory 1g (per jenkins executor (${jenkins_executors})), found ${mem}"; } + ;; + # test-burn doesn't have enough tests in it to split beyond 8, and burn and long we want a bit more resources anyway + "test-burn" | "long-test" | "cqlsh-test" ) + [[ ${mem} -gt $((5 * 1024 * 1024 * 1024 * ${jenkins_executors})) ]] || { error 1 "${target} require minimum docker memory 6g (per jenkins executor (${jenkins_executors})), found ${mem}"; } + ;; + "microbench" | "microbench-test" | "simulator-dtest") + [[ ${mem} -gt $((15 * 1024 * 1024 * 1024 * ${jenkins_executors})) ]] || { error 1 "${target} require minimum docker memory 16g (per jenkins executor (${jenkins_executors})), found ${mem}"; } + docker_flags="-m 15g --memory-swap 15g" + ;; + "dtest" | "dtest-novnode" | "dtest-latest" | "dtest-large" | "dtest-large-novnode" | "dtest-large-latest" | "dtest-upgrade" | "dtest-upgrade-novnode"| "dtest-upgrade-large" | "dtest-upgrade-large-novnode" | "dtest-large-novnode-latest") + [ -f "${cassandra_dtest_dir}/dtest.py" ] || { error 1 "${cassandra_dtest_dir}/dtest.py not found. please specify 'cassandra_dtest_dir' to point to the local cassandra-dtest source"; } + test_script="run-python-dtests.sh" + docker_mounts="${docker_mounts} -v ${cassandra_dtest_dir}:/home/cassandra/cassandra-dtest" + [[ ${mem} -gt $((15 * 1024 * 1024 * 1024 * ${jenkins_executors})) ]] || { error 1 "${target} require minimum docker memory 16g (per jenkins executor (${jenkins_executors})), found ${mem}"; } + docker_flags="-m 15g --memory-swap 15g" + ;; + "test" | "test-cdc" | "test-compression" | "test-oa" | "test-system-keyspace-directory" | "test-latest" | "jvm-dtest" | "jvm-dtest-upgrade" | "jvm-dtest-novnode" | "jvm-dtest-upgrade-novnode") + [[ ${mem} -gt $((5 * 1024 * 1024 * 1024 * ${jenkins_executors})) ]] || { error 1 "${target} require minimum docker memory 6g (per jenkins executor (${jenkins_executors})), found ${mem}"; } + ;; + *) + error 1 "docker resource limits unconfigured for test type \"${target}\"" + ;; +esac + +docker_cpus=$(echo "scale=2; ${cores} / ( ${jenkins_executors} )" | bc) +docker_cpus_limit=$(docker info | grep CPUs | cut -d" " -f3) +if (( $(echo "${docker_cpus} > ${docker_cpus_limit}" |bc -l) )) ; then + echo "WARNING: requested more cpus (${docker_cpus}) than docker cpu limit (${docker_cpus_limit}), reducing cpus…" + docker_cpus=${docker_cpus_limit} +fi + +# hack: long-test does not handle limited CPUs +if [ "${target}" != "long-test" ] ; then + docker_flags="--cpus=${docker_cpus} ${docker_flags}" +fi + +docker_flags="${docker_flags} -d --rm" + +# make sure build_dir is good +mkdir -p "${build_dir}/tmp" || true +mkdir -p "${build_dir}/test/logs" || true +mkdir -p "${build_dir}/test/output" || true +mkdir -p "${build_dir}/test/reports" || true +chmod -R ag+rwx "${build_dir}" + +# define testtag.extra so tests can be aggregated together. (jdk is already appended in build.xml) +case "${target}" in + "cqlsh-test" | "dtest" | "dtest-novnode" | "dtest-latest" | "dtest-large" | "dtest-large-novnode" | "dtest-upgrade" | "dtest-upgrade-large" | "dtest-upgrade-novnode" | "dtest-upgrade-large-novnode" ) + ANT_OPTS="-Dtesttag.extra=_$(arch)_python${python_version/./-}" + # intentionally not TMP_DIR + DTEST_TMPDIR_LOCAL="$(mktemp -d ${build_dir}/run-python-dtest.XXXXXX)" + ;; + "jvm-dtest-novnode" | "jvm-dtest-upgrade-novnode" ) + ANT_OPTS="-Dtesttag.extra=_$(arch)_novnode" + ;; + *) + ANT_OPTS="-Dtesttag.extra=_$(arch)" + ;; +esac + +# cython can be used for cqlsh-test +if [ "$cython" == "yes" ]; then + [ "${target}" == "cqlsh-test" ] || { error 1 "cython is only supported for cqlsh-test"; } + ANT_OPTS="${ANT_OPTS}_cython" +else + cython="no" +fi + +# the docker container's env +docker_envs="--env TEST_SCRIPT=${test_script} --env JAVA_VERSION=${java_version} --env PYTHON_VERSION=${python_version} --env cython=${cython} --env ANT_OPTS=\"${ANT_OPTS}\"" +# cassandra-4.x build.xml requires CASSANDRA_USE_JDK11 whenever ant runs under jdk 11 +[ "${java_version}" == "11" ] && docker_envs="${docker_envs} --env CASSANDRA_USE_JDK11=true" +if [ -n "${DTEST_TMPDIR_LOCAL}" ] ; then + DTEST_TMPDIR_REMOTE="$(sed "s:${build_dir}:/home/cassandra/cassandra/build:" <<< ${DTEST_TMPDIR_LOCAL})" + docker_envs="${docker_envs} --env TMPDIR=${DTEST_TMPDIR_REMOTE} --env CCM_CONFIG_DIR=${DTEST_TMPDIR_REMOTE}/.ccm" +fi +[ $DEBUG ] && docker_envs="${docker_envs} --env DEBUG=1" + +split_str="0_0" +if [[ "${split_chunk}" =~ ^[0-9]+/[0-9]+$ ]]; then + split_str="${split_chunk/\//_}" +fi + +# git worktrees need their original working directory (in its original path) +if [ -f ${cassandra_dir}/.git ] ; then + git_location="$(cat ${cassandra_dir}/.git | awk -F".git" '{print $1}' | awk '{print $2}')" + docker_volume_opt="${docker_volume_opt} -v${git_location}:${git_location}" +fi + +random_string="$(LC_ALL=C tr -dc A-Za-z0-9 /dev/null 2>&1 &' EXIT +fi + +# capture logs and status +set -o pipefail +docker exec --user cassandra ${container_name} bash -c "${docker_command}" | tee -a ${logfile} +status=$? +set +o pipefail + +if [ "$status" -ne 0 ] && [ -z $SKIP_DOCKER_DEBUG_ON_FAIL ] ; then + echo "${docker_id} failed (${status}), debug… (set SKIP_DOCKER_DEBUG_ON_FAIL to quiet)" + docker inspect ${docker_id} + echo "–––" + docker logs ${docker_id} + echo "–––" + docker ps -a + echo "–––" + docker info + echo "–––" + echo "Failure." +fi +# docker stop in background, ignore errors +( nohup docker stop ${docker_id} >/dev/null 2>/dev/null & ) + +xz -f ${logfile} 2>/dev/null + +popd >/dev/null +popd >/dev/null +echo "+ exit ${status}" +exit ${status} diff --git a/.build/docker/ubuntu-test.docker b/.build/docker/ubuntu-test.docker new file mode 100644 index 000000000000..31f6d5b275a7 --- /dev/null +++ b/.build/docker/ubuntu-test.docker @@ -0,0 +1,266 @@ +# 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. + +# +# This is a multi-stage build. The `base` stage holds all the system setup +# (apt packages, JDKs, python, locales, alternatives and the build user) and is +# shared by both other stages. The `builder` stage does the heavy, throw-away +# work: it prepopulates the Maven cache and builds the five python virtualenvs +# and the ccm repository. The `final` stage re-derives from `base` (so it gets a +# clean system with no build cruft, apt caches, git caches or chmod copy-up +# duplication) and only COPYies the generated caches across from `builder`. This +# keeps the shipped image close to (system packages + generated caches) rather +# than accumulating every intermediate layer. +# + +#################################################################### +# base: system packages, JDKs, python, locales and the build user +#################################################################### + +FROM ubuntu:22.04 AS base +LABEL org.opencontainers.image.authors="Apache Cassandra " + +# CONTEXT is expected to be cassandra/.build + +ENV BUILD_HOME=/home/cassandra +ENV CASSANDRA_DIR=$BUILD_HOME/cassandra +ENV DIST_DIR=$CASSANDRA_DIR/build +ENV LANG=en_US.UTF-8 +ENV LC_CTYPE=en_US.UTF-8 +ENV PYTHONIOENCODING=utf-8 +ENV PYTHONUNBUFFERED=true + +LABEL org.cassandra.buildenv=ubuntu_test + +RUN echo "Building with arguments:" \ + && echo " - DIST_DIR=${DIST_DIR}" \ + && echo " - BUILD_HOME=${BUILD_HOME}" \ + && echo " - CASSANDRA_DIR=${CASSANDRA_DIR}" \ + && echo " - UID_ARG=${UID_ARG}" \ + && echo " - GID_ARG=${GID_ARG}" + +# configure apt to retry downloads +RUN echo 'APT::Acquire::Retries "99";' > /etc/apt/apt.conf.d/80-retries +RUN echo 'Acquire::http::Timeout "60";' > /etc/apt/apt.conf.d/80proxy.conf +RUN echo 'Acquire::ftp::Timeout "60";' >> /etc/apt/apt.conf.d/80proxy.conf + +# install our python dependencies and some other stuff we need +# libev4 libev-dev are for the python driver + +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -y --no-install-recommends software-properties-common apt-utils gnupg && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN export DEBIAN_FRONTEND=noninteractive && \ + add-apt-repository -y ppa:deadsnakes/ppa && \ + apt-get update && \ + apt-get install -y curl git-core python3-pip \ + python3.8 python3.8-venv python3.8-dev \ + python3.10 python3.10-venv python3.10-dev \ + python3.11 python3.11-venv python3.11-dev \ + python3.12 python3.12-venv python3.12-dev \ + python3.13 python3.13-venv python3.13-dev \ + virtualenv net-tools libev4 libev-dev wget gcc libxml2 libxslt1-dev \ + vim lsof sudo libjemalloc2 dumb-init locales rsync \ + openjdk-8-jdk openjdk-11-jdk openjdk-17-jdk ant ant-optional && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN update-alternatives --remove java /usr/lib/jvm/java-8-openjdk-$(dpkg --print-architecture)/jre/bin/java +RUN update-alternatives --install /usr/bin/java java /usr/lib/jvm/java-8-openjdk-$(dpkg --print-architecture)/bin/java 1081 +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.8 1 +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.10 1 +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 +RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.13 1 +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1 +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.10 1 +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 +RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.13 1 +RUN python3.8 -m pip install --upgrade pip + +# generate locales for the standard en_US.UTF8 value we use for testing +RUN locale-gen en_US.UTF-8 + +# as we only need the requirements.txt file from the dtest repo, let's just get it from GitHub as a raw asset +# so we can avoid needing to clone the entire repo just to get this file +RUN curl https://raw.githubusercontent.com/apache/cassandra-dtest/trunk/requirements.txt --output /opt/requirements.txt +RUN chmod 0644 /opt/requirements.txt + +# now setup python via virtualenv with all of the python dependencies we need according to requirements.txt +RUN pip3 install virtualenv virtualenv-clone +RUN pip3 install --upgrade wheel + +# make Java 8 the default executable (we use to run all tests against Java 8) +RUN update-java-alternatives --set java-1.8.0-openjdk-$(dpkg --print-architecture) + +# enable legacy TLSv1 and TLSv1.1 (CASSANDRA-16848) +RUN find /etc -type f -name java.security -exec sed -i 's/TLSv1, TLSv1.1//' {} \; +RUN find /etc -type f -name java.security -exec sed -i 's/3DES_EDE_CBC$/3DES_EDE_CBC, TLSv1, TLSv1.1/' {} \; + +# create and change to cassandra-tmp user, use an rare uid to avoid collision later on +RUN mkdir -p /home/image-cache && chmod -R a+rwx /home/image-cache +RUN adduser --disabled-login --uid 901743 --lastuid 901743 --gecos cassandra cassandra-tmp +RUN gpasswd -a cassandra-tmp sudo +RUN echo "cassandra-tmp ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/build +RUN chmod 0440 /etc/sudoers.d/build + +# switch to the cassandra user +RUN mkdir -p ${BUILD_HOME} && chmod a+rwx ${BUILD_HOME} +USER cassandra-tmp +ENV HOME=${BUILD_HOME} +WORKDIR ${BUILD_HOME} + +ENV ANT_HOME=/usr/share/ant + +########################################################################################## +# builder: prepopulate local maven repo, build the python virtualenvs and ccm repository +########################################################################################## + +FROM base AS builder + +# Prepopulate Maven repository with dependencies from all branches. see _create_user.sh +COPY docker/_prepopulate_maven_deps.sh /tmp/_prepopulate_maven_deps.sh +RUN bash /tmp/_prepopulate_maven_deps.sh /home/image-cache/.m2/repository +RUN cp -a /home/cassandra-tmp/.gradle /home/image-cache/.gradle + +# run pip commands and setup virtualenv (note we do this after we switch to cassandra user so we +# setup the virtualenv for the cassandra user, not root) for Python 3.8-3.13 +# Don't build cython extensions when installing cassandra-driver. During test execution the driver +# dependency is refreshed via pip install --upgrade, so that driver changes can be pulled in without +# requiring the image to be rebuilt. Rebuilding compiled extensions is costly and is disabled by +# default in test jobs using the CASS_DRIVER_X env vars below. However, if the extensions are +# included in the base image, the compiled objects are not updated by pip at run time, which can +# cause errors if the tests rely on new driver functionality or bug fixes. + +# Build the virtualenv, remove the cassandra-driver .git (breaks virtualenv-clone) and fix +# permissions for the runtime user (which has a different uid/gid), all in the same layer. +RUN virtualenv --python=python3.8 ${BUILD_HOME}/env3.8 \ + && chmod +x ${BUILD_HOME}/env3.8/bin/activate \ + && /bin/bash -c "export CASS_DRIVER_NO_CYTHON=1 CASS_DRIVER_NO_EXTENSIONS=1 \ + && source ${BUILD_HOME}/env3.8/bin/activate \ + && pip3 install --upgrade pip \ + && pip3 install -r /opt/requirements.txt \ + && pip3 freeze --user" \ + && rm -rf ${BUILD_HOME}/env3.8/src/cassandra-driver/.git \ + && chmod -R og+wx ${BUILD_HOME}/env3.8 + +RUN virtualenv --python=python3.10 ${BUILD_HOME}/env3.10 \ + && chmod +x ${BUILD_HOME}/env3.10/bin/activate \ + && /bin/bash -c "export CASS_DRIVER_NO_CYTHON=1 CASS_DRIVER_NO_EXTENSIONS=1 \ + && source ${BUILD_HOME}/env3.10/bin/activate \ + && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10 \ + && pip3 install --upgrade \"pip<25.0\" \"setuptools==60.8.2\" wheel \ + && pip3 install --no-build-isolation -r /opt/requirements.txt \ + && pip3 freeze --user" \ + && rm -rf ${BUILD_HOME}/env3.10/src/cassandra-driver/.git \ + && chmod -R og+wx ${BUILD_HOME}/env3.10 + +RUN python3.11 -m venv ${BUILD_HOME}/env3.11 \ + && chmod +x ${BUILD_HOME}/env3.11/bin/activate \ + && /bin/bash -c "export CASS_DRIVER_NO_CYTHON=1 CASS_DRIVER_NO_EXTENSIONS=1 \ + && source ${BUILD_HOME}/env3.11/bin/activate \ + && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 \ + && pip3 install --upgrade \"pip<25.0\" \"setuptools==60.8.2\" wheel \ + && pip3 install --no-build-isolation -r /opt/requirements.txt \ + && pip3 freeze --user" \ + && rm -rf ${BUILD_HOME}/env3.11/src/cassandra-driver/.git \ + && chmod -R og+wx ${BUILD_HOME}/env3.11 + +RUN virtualenv --python=python3.12 ${BUILD_HOME}/env3.12 \ + && chmod +x ${BUILD_HOME}/env3.12/bin/activate \ + && /bin/bash -c "export CASS_DRIVER_NO_CYTHON=1 CASS_DRIVER_NO_EXTENSIONS=1 \ + && source ${BUILD_HOME}/env3.12/bin/activate \ + && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12 \ + && pip3 install --upgrade \"pip<25.0\" \"setuptools>=65.5.0,<70.0.0\" wheel \ + && sed -i 's/pkgutil.ImpImporter/type(\"ImpImporter\", (object,), {})/g' ${BUILD_HOME}/env3.12/lib/python3.12/site-packages/pkg_resources/__init__.py \ + && pip3 install --no-build-isolation -r /opt/requirements.txt \ + && pip3 freeze --user" \ + && rm -rf ${BUILD_HOME}/env3.12/src/cassandra-driver/.git \ + && chmod -R og+wx ${BUILD_HOME}/env3.12 + +RUN virtualenv --python=python3.13 ${BUILD_HOME}/env3.13 \ + && chmod +x ${BUILD_HOME}/env3.13/bin/activate \ + && /bin/bash -c "export CASS_DRIVER_NO_CYTHON=1 CASS_DRIVER_NO_EXTENSIONS=1 \ + && source ${BUILD_HOME}/env3.13/bin/activate \ + && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.13 \ + && pip3 install --upgrade \"pip<25.0\" \"setuptools>=65.5.0,<70.0.0\" wheel \ + && sed -i 's/pkgutil.ImpImporter/type(\"ImpImporter\", (object,), {})/g' ${BUILD_HOME}/env3.13/lib/python3.13/site-packages/pkg_resources/__init__.py \ + && pip3 install --no-build-isolation -r /opt/requirements.txt \ + && pip3 freeze --user" \ + && rm -rf ${BUILD_HOME}/env3.13/src/cassandra-driver/.git \ + && chmod -R og+wx ${BUILD_HOME}/env3.13 + +# 4* requires java8, sudo doesn't work on cross-platform builds +USER root +RUN update-alternatives --set java /usr/lib/jvm/java-8-openjdk-$(dpkg --print-architecture)/bin/java +RUN update-alternatives --set javac /usr/lib/jvm/java-8-openjdk-$(dpkg --print-architecture)/bin/javac +USER cassandra-tmp + +# Create ccm's git cache, using the same 'git clone --bare' ccm itself runs to build the cache +RUN mkdir -p ${BUILD_HOME}/.ccm/repository \ + && until git clone --bare https://github.com/apache/cassandra.git ${BUILD_HOME}/.ccm/repository/_git_cache_apache ; \ + do echo "git clone failed… trying again in 10s… " ; sleep 10 ; done + +# Initialize ccm versions: the last two versions based off the latest version found on downloads.apache.org/cassandra +RUN bash -c 'source ${BUILD_HOME}/env3.8/bin/activate && \ + latest_4_0=$(curl -s https://downloads.apache.org/cassandra/ | grep -oP "(?<=href=\")4\.0\.[0-9]+" | sort -V | tail -1 | cut -d"." -f3) && \ + for i in $(seq $((latest_4_0 -1)) $latest_4_0); do echo $i ; ccm create --quiet -n 1 -v binary:4.0.$i test && ccm remove test ; done && \ + latest_4_1=$(curl -s https://downloads.apache.org/cassandra/ | grep -oP "(?<=href=\")4\.1\.[0-9]+" | sort -V | tail -1 | cut -d"." -f3) && \ + for i in $(seq $((latest_4_1 -1)) $latest_4_1); do echo $i ; ccm create --quiet -n 1 -v binary:4.1.$i test && ccm remove test ; done' + +# 5+ requires java11, sudo doesn't work on cross-platform builds +USER root +RUN update-alternatives --set java /usr/lib/jvm/java-11-openjdk-$(dpkg --print-architecture)/bin/java +RUN update-alternatives --set javac /usr/lib/jvm/java-11-openjdk-$(dpkg --print-architecture)/bin/javac +USER cassandra-tmp + +# Initialize ccm versions: the last two versions based off the latest version found on downloads.apache.org/cassandra +RUN /bin/bash -c 'source ${BUILD_HOME}/env3.8/bin/activate && \ + latest_5_0=$(curl -s https://downloads.apache.org/cassandra/ | grep -oP "(?<=href=\")5\.0\.[0-9]+" | sort -V | tail -1 | cut -d"." -f3) && \ + for i in $(seq $((latest_5_0 -1)) $latest_5_0); do echo $i ; ccm create --quiet -n 1 -v binary:5.0.$i test && ccm remove test ; done' + # TODO uncomment when 6.0.0 is released + #latest_6_0=$(curl -s https://downloads.apache.org/cassandra/ | grep -oP "(?<=href=\")6\.0\.[0-9]+" | sort -V | tail -1 | cut -d"." -f3) && \ + #for i in $(seq $((latest_6_0 -1)) $latest_6_0); do echo $i ; ccm create --quiet -n 1 -v binary:6.0.$i test && ccm remove test ; done' + +# other directories we don't need in image (the cassandra-driver .git dirs, which break +# virtualenv-clone, are removed per-env in the same layer that creates each env above) +RUN rm -rf /home/cassandra-tmp/.m2 /tmp/ccm-*.tar.gz + +# fix permissions, runtime user has different uid/gid (env* permissions are set per-env above) +RUN chmod -R og+wx ${BUILD_HOME}/.ccm ${BUILD_HOME}/.cache + +##################################################################################### +# final: a clean base to avoid duplicating layer sizes above. copy in what's needed +##################################################################################### + +FROM base AS final + +# the ccm repository, python virtualenvs and pip cache used directly by the runtime user +COPY --from=builder --chown=cassandra-tmp:cassandra-tmp ${BUILD_HOME} ${BUILD_HOME} + +# the prepopulated maven repository and gradle wrapper, rsync'd/copied into the runtime user's home by _create_user.sh +COPY --from=builder /home/image-cache /home/image-cache + +# make Java 11 the default, matching the state the single-stage image shipped with +# (tests still select the java version explicitly at runtime via _set_java.sh) +USER root +RUN update-alternatives --set java /usr/lib/jvm/java-11-openjdk-$(dpkg --print-architecture)/bin/java +RUN update-alternatives --set javac /usr/lib/jvm/java-11-openjdk-$(dpkg --print-architecture)/bin/javac +USER cassandra-tmp + +# mark "/tmp" as a volume so it will get mounted as an ext4 mount and not +# the stupid aufs/CoW stuff that the actual docker container mounts will have. +# we've been seeing 3+ minute hangs when calling sync on an aufs backed mount +# so it greatly makes tests flaky as things can hang basically anywhere +VOLUME ["/tmp"] diff --git a/.build/run-ci b/.build/run-ci new file mode 100755 index 000000000000..0f471553b92d --- /dev/null +++ b/.build/run-ci @@ -0,0 +1,1205 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# 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. +""" +CI Pipeline Script + +This script can initialize a Jenkins operator in a Kubernetes cluster, +start ci job builds, and retrieve results in the project standard format. +Python dependencies are found in .build/run-ci.d/requirements.txt +Custom environment variables can be set in .build/.run-ci.env + +lint with: + `pylint --disable=C0301,C0302,W0511,C0103,W0702,C0415,C0116,C0115,R0914,W0603,R0915,R0913,R0917,R0911,W0212,W0621 run-ci` + +test with: + `python run-ci.d/run-ci-test.py` +""" + +import argparse +import fcntl +import getpass +import gzip +import itertools +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import tarfile +import threading +import time +from contextlib import contextmanager +from enum import Enum +from pathlib import Path +from urllib.request import urlretrieve +from typing import Optional, Tuple + +# External Libraries (`pip install -r .build/run-ci.d/requirements.txt`) +import requests +import yaml +from bs4 import BeautifulSoup +from kubernetes import client, config, stream + +try: + import jenkins +except OSError as import_jenkins_error: + if 'lookup3.so' in str(import_jenkins_error): + print("Error: The required shared library 'lookup3.so' is missing.") + print("Please ensure it is installed and accessible in your environment.") + sys.exit(1) + else: + raise + +def base_job_name(args) -> str: + """ + Determines the default Jenkins job name based on the Cassandra version. + Separate jobs are required because Jenkinsfiles are baked into the job configuration. + ref: .jenkins/k8s/jenkins-deployment.yaml JCasC.configScripts.test-job + """ + if not hasattr(base_job_name, "_cached_result"): + raw_url = args.repository.replace("https://github.com/", "https://raw.githubusercontent.com/").removesuffix(".git") + f"/{args.branch}/build.xml" + if 200 != requests.head(raw_url, timeout=30).status_code: + raise ValueError(f"GitHub unavailable, or this branch has not been pushed yet: {args.repository} @ {args.branch} (or remote tracking not setup up: `git config --get branch.{args.branch}.remote` and `git config --get branch.{args.branch}.merge`)") + response = requests.get(raw_url, timeout=30) + response.raise_for_status() + for line in response.text.splitlines(): + if 'property' in line and 'name="base.version"' in line: + version = line.split('value="')[1].split('"')[0] + # TODO: add new version each release branching + if version.startswith("4.1."): + base_job_name._cached_result = "cassandra-4.1" + elif version.startswith("5.0."): + base_job_name._cached_result = "cassandra-5.0" + else: + base_job_name._cached_result = "cassandra" + break + return base_job_name._cached_result + +def get_current_branch() -> str: + """Returns the current branch.""" + return subprocess.run(["git", "-C", str(CASSANDRA_DIR), "branch", "--show-current"], + capture_output=True, text=True, check=True).stdout.strip() + +def require_tracking_remote(branch: str): + """ Exits when the branch tracks no remote, since nothing can then be inferred about what to build. """ + if 0 == subprocess.run(["git", "-C", str(CASSANDRA_DIR), "rev-parse", "--abbrev-ref", f"{branch}@{{u}}"], + capture_output=True, text=True, check=False).returncode: + return + print(f"Branch {branch} tracks no remote, so the fork and branch to build cannot be detected.") + print("\nEither set the tracking up, which git will do on the first push of every new branch:") + print(" git config --global push.autoSetupRemote true") + print(f" or for this branch alone: `git push --set-upstream {branch}`") + print("\nOr name what to build explicitly, with -r/--repository and -b/--branch.") + sys.exit(1) + +def is_local_git_dirty(args) -> bool: + """Returns True if there are uncommitted/unpushed changes in the local git repository.""" + # use base_job_name to verify the remote branch exists + base_job_name(args) + # check if the working directory is clean + clean = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "diff-index", "--quiet", "HEAD", "--"], check=False).returncode + # `@{u}` resolves to nothing without tracking, which would read as nothing unpushed. Callers reach here + # only past require_tracking_remote, but report dirty on failure rather than depend on that invariant + unpushed = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "log", "@{u}..HEAD", "--name-only"], + capture_output=True, text=True, check=False) + return 0 != clean or 0 != unpushed.returncode or bool(unpushed.stdout.strip()) + +def get_tracking_remote_url() -> Optional[str]: + """ Returns the tracking remote URL of the current branch, or None when the branch tracks nothing. """ + remote = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "config", "--get", f"branch.{DEFAULT_REPO_BRANCH}.remote"], + capture_output=True, text=True, check=False) + + if 0 != remote.returncode: + return None + + repo_url = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "remote", "get-url", remote.stdout.strip()], + capture_output=True, text=True, check=True).stdout.strip() + if repo_url.startswith("git@github.com:"): + repo_url = repo_url.replace("git@github.com:", "https://github.com/") + + # and change gitbox to github + return repo_url.replace("https://gitbox.apache.org/repos/asf/cassandra.git", "https://github.com/apache/cassandra.git") + +# Constants +DEFAULT_KUBE_NS = "default" +CASSANDRA_DIR = Path(__file__).resolve().parent.parent +DEPLOY_YAML = str(CASSANDRA_DIR / ".jenkins/k8s/jenkins-deployment.yaml") +DEFAULT_REPO_BRANCH = get_current_branch() +DEFAULT_REPO_URL = get_tracking_remote_url() +DEFAULT_DTEST_REPO_URL = "https://github.com/apache/cassandra-dtest.git" +DEFAULT_DTEST_REPO_BRANCH = "trunk" +DEFAULT_PROFILE = "skinny" +DEFAULT_POD_NAME = "cassius-jenkins-0" +DEFAULT_CONTAINER_NAME = "jenkins" +LOCAL_RESULTS_BASEDIR = CASSANDRA_DIR / "build/ci/" +# AWS/GCloud specifics for node_cleaner function, needed for node_cleaner +AWS_REGION = os.environ.get("AWS_REGION") +GCP_PROJECT_ID = os.environ.get("GCP_PROJECT_ID") +GCP_ZONE = os.environ.get("GCP_ZONE") + +IS_RUNNING = True + + +def debug(message: str): + """Helper function to print debug messages.""" + if os.environ.get("DEBUG"): + print(message) + + +def load_environment_file(): + """ Load environment variables from a .build/.run-ci.env file. """ + try: + from dotenv import load_dotenv + load_dotenv(dotenv_path=CASSANDRA_DIR / ".build" / ".run-ci.env") + except: + print("Warning: .build/run-ci.env file not found, or dotenv module not installed.") + +def setup_environment(kubeconfig, kubecontext) -> client.CoreV1Api: + """Ensures necessary tools are installed and sets up Kubernetes configuration.""" + # Check Python version + required_version = (3, 7) + if sys.version_info < required_version: + raise EnvironmentError(f"Python {required_version[0]}.{required_version[1]} or higher is required. " + f"Current version is {sys.version_info.major}.{sys.version_info.minor}.") + # check command line dependencies + dependencies = ["helm", "kubectl"] + for cmd in dependencies: + if not shutil.which(cmd): + raise EnvironmentError(f"{cmd} must be installed and available in the PATH.") + + # Initialize Kubernetes client and API instance + config.load_kube_config(config_file=kubeconfig if kubeconfig else None, context=kubecontext or None) + return client.CoreV1Api() + +def argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run CI pipeline for Cassandra on K8s using Jenkins.") + parser.add_argument("-c", "--kubeconfig", help="Path to a different kubeconfig.") + parser.add_argument("-x", "--kubecontext", help="Use a different Kubernetes context.") + parser.add_argument("-i", "--url", help="Jenkins url. Suitable when kubectl access in not available. Can also be specified via the JENKINS_URL environment variable (and in .build/.run-ci.env)") + parser.add_argument("-u", "--user", help="Jenkins user. Can also be specified via the JENKINS_USER environment variable (and in .build/.run-ci.env)") + parser.add_argument("-r", "--repository", default=DEFAULT_REPO_URL, help="Repository URL. Defaults to current tracking remote.") + parser.add_argument("-b", "--branch", default=DEFAULT_REPO_BRANCH, help="Repository branch. Defaults to current branch.") + parser.add_argument("-p", "--profile", choices=['packaging','skinny','pre-commit','pre-commit w/ upgrades','post-commit','custom'], default=DEFAULT_PROFILE, help="CI pipeline profile. Defaults to skinny.") + parser.add_argument("-e", "--profile-custom-regexp", help="Regexp for stages when using custom profile. See `testSteps` in Jenkinsfile for list of stages. Example: 'stress.*|jvm-dtest.'") + parser.add_argument("-j", "--jdk", help="Specify JDK version. Defaults to all JDKs the current branch supports.") + parser.add_argument("-t", "--repeat-test-regex", help="Test name regexp (csv list) to run repeatedly via the *-repeat stages. Requires -p custom and -e selecting a *-repeat stage. Example: 'HostReplacementTest'") + parser.add_argument("-n", "--repeat-count", help="How many times to run the *-repeat stages. Example: 200") + parser.add_argument("--repeat-stop-on-failure", action="store_true", help="Stop a *-repeat stage on the first failed run (default: run all iterations and report the failure rate)") + parser.add_argument("-m", "--repeat-machines", default="1", help="Number of machines that each run the full set of repeated test iterations in parallel (default 1). Example: 4") + parser.add_argument("-d", "--dtest-repository", default=DEFAULT_DTEST_REPO_URL, help="DTest repository URL.") + parser.add_argument("-k", "--dtest-branch", default=DEFAULT_DTEST_REPO_BRANCH, help="DTest repository branch.") + parser.add_argument("-s", "--setup", action="store_true", help="Set up Jenkins before the build.") + parser.add_argument("--only-setup", action="store_true", help="Only install Jenkins into the k8s cluster.") + parser.add_argument("-f", "--values-override", help="Path to an additional helm values file, applied over .jenkins/k8s/jenkins-deployment.yaml. Required when the target cluster carries site customisations, see .jenkins/k8s/README.md") + parser.add_argument("--tear-down", action="store_true", help="Tear down Jenkins after the build.") + parser.add_argument("--only-tear-down", action="store_true", help="Only tear down Jenkins.") + parser.add_argument("--only-node-cleaner", action="store_true", help="Only run the node cleaner. The node cleaner scans the k8s nodes, eagerly terminating those unused.") + parser.add_argument("-o", "--download-results", help="Just download the results for the specificed build number. Naming of local artefacts assumes current tracking remote and branch, use -r and -b otherwise.") + return parser + +def parse_arguments() -> argparse.Namespace: + """ + Parses command-line arguments and sets environment variables based on inputs. + If you update this please also update `.build/run-ci.d/README.md` + """ + args = argument_parser().parse_args() + + # -r defaults to the branch's tracking remote, which is absent when the branch tracks nothing. Only the + # flows that build, or that name artefacts after a build, need it: installing and tearing down do not, + # so an untracked branch can still deploy the cluster. + if not args.repository and not (args.only_setup or args.only_tear_down or args.only_node_cleaner): + require_tracking_remote(args.branch) + + assert not args.repository or (args.repository.startswith("https://github.com/") + and args.repository.removesuffix(".git").endswith("cassandra")),\ + f"Only github apache/cassandra (forked) repository supported, got: {args.repository}" + assert args.dtest_repository.startswith("https://github.com/") and args.dtest_repository.removesuffix(".git").endswith("cassandra-dtest"),\ + f"Only github apache/cassandra-dtest (forked) repository supported, got: {args.dtest_repository}" + assert not (args.setup and args.only_setup), "Both --setup or --only-setup cannot be specified." + assert not (args.tear_down and args.only_tear_down), "Both --tear-down or --only-tear-down cannot be specified." + assert not ("custom" == args.profile and not args.profile_custom_regexp), "Custom profile requires --profile-custom-regexp." + assert re.fullmatch(r"[1-9][0-9]*", args.repeat_machines or ""), "--repeat-machines must be a positive integer." + repeat_options_used = args.repeat_test_regex or args.repeat_count or args.repeat_stop_on_failure or args.repeat_machines != "1" + repeat_stages_selected = args.profile == "custom" and any(re.fullmatch(args.profile_custom_regexp, stage) + for stage in ("test-repeat", "jvm-dtest-repeat")) + repeating_tests = repeat_options_used or repeat_stages_selected + assert not (repeating_tests and args.profile != "custom"), "Repeating tests requires --profile custom." + assert not (repeating_tests and not repeat_stages_selected), "Repeating tests requires --profile-custom-regexp selecting a *-repeat stage (see `repeatTestSteps()` in .jenkins/Jenkinsfile)." + assert not (repeating_tests and not (args.repeat_test_regex and args.repeat_count)), "Repeating tests requires both --repeat-test-regex and --repeat-count." + assert not args.repeat_count or re.fullmatch(r"[1-9][0-9]*", args.repeat_count), "--repeat-count must be a positive integer." + assert not (args.values_override and not (args.setup or args.only_setup)), "--values-override requires --setup or --only-setup." + assert not (args.values_override and not Path(args.values_override).is_file()), f"No such values override file: {args.values_override}" + + if not args.url and os.environ.get("JENKINS_URL"): + args.url = os.environ.get("JENKINS_URL") + if not args.user and os.environ.get("JENKINS_USER"): + args.user = os.environ.get("JENKINS_USER") + + assert not (args.url and (args.kubeconfig or args.kubecontext or args.setup or args.only_setup or args.tear_down or args.only_tear_down or args.only_node_cleaner)),\ + "Cannot specify both --url and any of --kubeconfig/--kubecontext/--setup/--only-setup/--tear-down/--only-tear-down/--only-node-cleaner. Setting the jenkins url implies no kubectl actions." + assert not (args.url and not args.user), "When specifying --url, --user is required." + + return args + + +def init_k8s_namespace(k8s_client, namespace: str): + """Ensures the specified namespace exists in the Kubernetes cluster.""" + try: + k8s_client.read_namespace(namespace) + debug(f"Namespace '{namespace}' already exists.") + except client.exceptions.ApiException as e: + if e.status == 404: + debug(f"Creating namespace '{namespace}'...") + ns = client.V1Namespace(metadata=client.V1ObjectMeta(name=namespace)) + k8s_client.create_namespace(ns) + print(f"Namespace '{namespace}' created.") + else: + raise + +def run_kubectl_command(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, command: list) -> str: + """ + Runs a kubectl command with the specified kubeconfig and context. + Used when functionality is not available in k8s_client. + """ + cmd = ["kubectl"] + if kubeconfig: + cmd += ["--kubeconfig", kubeconfig] + if kubecontext: + cmd += ["--context", kubecontext] + cmd += ["--namespace", kube_ns] + cmd += command + return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip() + +def run_helm_command(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, command: list, + capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess: + """Runs a helm command with the specified kubeconfig, context and namespace.""" + cmd = ["helm"] + if kubeconfig: + cmd += ["--kubeconfig", kubeconfig] + if kubecontext: + cmd += ["--kube-context", kubecontext] + cmd += ["--namespace", kube_ns] + cmd += command + return subprocess.run(cmd, capture_output=capture_output, text=True, check=check) + +def check_agent_capacity(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, values: dict): + """ + Refuses to deploy podTemplates that ask for more agents than the cluster can ever schedule. + + An instanceCap above the nodes its pool can hold does not merely idle: the podAntiAffinity in each + template puts one agent on a node, so the surplus pods can never be scheduled, they drive the + autoscaler to maxSize, expire after waitForPodSec and are requested again in a loop. That churn is + what preceded the 2026-08-11 controller stall, both large node groups pinned at their maximum while + fifteen agents did the work. + Only what is established is enforced: a ceiling that could not be read is left unchecked, since + blocking a valid deploy on a check that cannot see the answer is worse than no check. + """ + + def agent_templates(values: dict) -> list: + """ + The agent podTemplates as [{name, size, selector, instance_cap, instance_cap_str}]. + + The templates are opaque strings to the helm chart, parsed only by the kubernetes plugin, so they + are loaded here as the yaml they are. `size` is the suffix of the `cassandra.jenkins.agent.` + nodeSelector, which is what ties a template to a node pool. + """ + templates = [] + for name, raw in (values.get("agent", {}).get("podTemplates") or {}).items(): + try: + template = yaml.safe_load(raw)[0] + except (yaml.YAMLError, IndexError, TypeError): + debug(f"Could not parse podTemplate {name}, skipping it") + continue + selector = dict(pair.split("=", 1) for pair in str(template.get("nodeSelector", "")).split(",") + if "=" in pair) + size = next((key.rsplit(".", 1)[1] for key in selector if key.startswith("cassandra.jenkins.agent.")), None) + templates.append({"name": name, "size": size, "selector": selector, + "instance_cap": template.get("instanceCap"), + "instance_cap_str": template.get("instanceCapStr")}) + return templates + + def node_pool_sizes(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str) -> Tuple[dict, dict]: + """ + What the live nodes reveal, as ({node pool name: size}, {label key: set of values seen}). + + Live nodes are the authoritative way to tie a node pool to an agent size, and the only way to + confirm a nodeSelector is spelt the way the nodes actually are. Pools scaled to zero contribute + nothing, which is the normal resting state, so neither result can be treated as exhaustive. + """ + pool_label = ("eks.amazonaws.com/nodegroup", "cloud.google.com/gke-nodepool") + pools, seen = {}, {} + try: + nodes = json.loads(run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["get", "nodes", "-o", "json"])) + except (subprocess.CalledProcessError, json.JSONDecodeError, TypeError) as e: + debug(f"Could not read nodes, agent sizes will not be attributed from them: {e}") + return pools, seen + for node in nodes.get("items", []): + labels = node.get("metadata", {}).get("labels", {}) + for key, value in labels.items(): + seen.setdefault(key, set()).add(value) + pool = next((labels[key] for key in pool_label if key in labels), None) + size = next((key.rsplit(".", 1)[1] for key, value in labels.items() + if key.startswith("cassandra.jenkins.agent.") and value == "true" + and key != "cassandra.jenkins.agent"), None) + if pool and size: + pools[pool] = size + return pools, seen + + def pool_ceilings(kubeconfig: Optional[str], kubecontext: Optional[str], pools: dict, sizes: set) -> Tuple[dict, list]: + """ + The most agents each size can ever run, as ({size: max nodes}, [(unattributed pool, maxSize)]). + + kubectl cannot see a pool's maximum directly: a pool at zero nodes has no nodes to count, so the + only in-cluster record is the cluster-autoscaler's status configmap. A managed autoscaler (GKE) + does not publish it, in which case nothing is established and the caller must not treat that as a + pass. The autoscaler names a pool for its underlying group (`eks--`), so a live + node's pool label is matched into it as a substring, falling back to the size word in the name for + pools that are scaled to zero. Anything still unmatched is returned for reporting, never ignored. + """ + + def nodegroup_max_size(group: dict) -> Optional[int]: + """ + A group's maximum, from its health condition where the autoscaler publishes it, or from the + group itself. + """ + if not isinstance(group, dict): + return None + for value in ((group.get("health") or {}).get("maxSize"), group.get("maxSize")): + if isinstance(value, str) and value.strip().isdigit(): + return int(value) + if isinstance(value, int) and not isinstance(value, bool): + return value + return None + + try: + status = yaml.safe_load(run_kubectl_command(kubeconfig, kubecontext, "kube-system", + ["get", "configmap", "cluster-autoscaler-status", + "-o", "jsonpath={.data.status}"])) + except (subprocess.CalledProcessError, yaml.YAMLError) as e: + debug(f"Could not read the cluster-autoscaler status configmap: {e}") + return {}, [] + ceilings, unattributed = {}, [] + for group in (status or {}).get("nodeGroups", []) if isinstance(status, dict) else []: + maximum = nodegroup_max_size(group) + if maximum is None: + continue + name = group.get("name", "") + size = next((size for pool, size in pools.items() if pool and pool in name), None) + if not size: + words = [word for word in sizes if re.search(rf"(^|[-_]){re.escape(word)}([-_]|$)", name)] + size = words[0] if len(words) == 1 else None + if not size: + # the controller has its own pool, and runs no agents + if "jenkins-controller" not in name: + unattributed.append((name, maximum)) + continue + ceilings[size] = ceilings.get(size, 0) + maximum + return ceilings, unattributed + + def check_template_capacity(template: dict, ceilings: dict, seen: dict) -> Tuple[list, list]: + """One podTemplate's (errors, warnings), errors being what could never be scheduled.""" + name, size, cap = template["name"], template["size"], template["instance_cap"] + errors, warnings = [], [] + # the plugin takes the cap from either key, so a disagreement resolves to whichever applies last + if str(template["instance_cap_str"]) != str(cap): + errors.append(f"{name}: instanceCap {cap} disagrees with instanceCapStr {template['instance_cap_str']}") + for key, value in template["selector"].items(): + if key not in seen: + # the resting state is every pool at zero, so this is not worth a warning on each deploy + debug(f"{name}: nodeSelector {key}={value} unconfirmed, no node currently carries {key}") + elif value not in seen[key]: + errors.append(f"{name}: nodeSelector {key}={value} matches no node, though {key} is present with" + f" {sorted(seen[key])}; agents would never be scheduled") + if size is None: + warnings.append(f"{name}: no cassandra.jenkins.agent. nodeSelector, cap {cap} not checked") + elif size not in ceilings: + # a size unresolved while others resolved is a blind spot worth flagging. Nothing resolving at all + # means the check could not run here, a fact about the cluster and not about these values + unchecked = f"{name}: instanceCap {cap} unchecked, no ceiling could be established for {size!r}" + if ceilings: + warnings.append(unchecked) + else: + debug(unchecked) + elif isinstance(cap, int) and cap > ceilings[size]: + errors.append(f"{name}: instanceCap {cap} exceeds the {ceilings[size]} nodes the {size} pool can hold," + f" so {cap - ceilings[size]} agents could never be scheduled") + else: + debug(f"{name}: instanceCap {cap} within the {size} pool's {ceilings[size]} nodes") + return errors, warnings + + templates = agent_templates(values) + if not templates: + return + pools, seen = node_pool_sizes(kubeconfig, kubecontext, kube_ns) + ceilings, unattributed = pool_ceilings(kubeconfig, kubecontext, pools, + {template["size"] for template in templates if template["size"]}) + errors, warnings = [], [] + for template in templates: + template_errors, template_warnings = check_template_capacity(template, ceilings, seen) + errors += template_errors + warnings += template_warnings + + container_cap = values.get("agent", {}).get("containerCap") + total = sum(template["instance_cap"] for template in templates if isinstance(template["instance_cap"], int)) + if isinstance(container_cap, int) and total > container_cap: + # benign, and not a fault to warn about on every deploy: the cloud cap leaves builds queued rather + # than creating pods that cannot be scheduled, it only stops every pool reaching its cap at once + debug(f"instanceCaps sum to {total} against a containerCap of {container_cap}, so the cloud cap binds" + f" first and the pools cannot all reach their cap at once") + for name, maximum in unattributed: + warnings.append(f"node pool {name!r} (maxSize {maximum}) matched no agent size and was not counted") + + for warning in warnings: + print(f"WARNING: {warning}") + if errors: + print("\nRefusing to deploy agent podTemplates that could never be scheduled:\n") + for error in errors: + print(f" {error}") + print("\nFix the podTemplate in .jenkins/k8s/jenkins-deployment.yaml, or raise the node pool's maximum" + " first (see .jenkins/k8s/README.md).") + sys.exit(1) + +def install_jenkins(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, + values_override: Optional[str] = None): + """Installs Jenkins Operator using Helm in the specified K8s namespace.""" + + def confirm_helm_updates(): + """Prompts before an upgrade drops any values the deployed jenkins currently has.""" + + def helm_values() -> dict: + """ + The values a deployed jenkins was last installed with, or an empty dict when there is no release. + These are the user-supplied values, whatever files they came from, so they include any customisations + made to the site outside of `.jenkins/k8s/jenkins-deployment.yaml`. + """ + result = run_helm_command(kubeconfig, kubecontext, kube_ns, + ["get", "values", "cassius", "-o", "yaml"], check=False) + if result.returncode != 0: + debug(f"No existing cassius release found in namespace {kube_ns}: {result.stderr.strip()}") + return {} + return yaml.safe_load(result.stdout) or {} + + def merge_values(base: dict, override: dict) -> dict: + """Merges two helm values files the way helm does: maps key by key, everything else replaced.""" + merged = dict(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = merge_values(merged[key], value) + else: + merged[key] = value + return merged + + def detect_lost_values(live: dict, proposed: dict) -> dict: + """ + Values the deployed jenkins has that an upgrade would drop, as {dotted.key.path: live value}. + + A key held live but absent from what is about to be applied is either a customisation made to this site, + or a key that `.jenkins/k8s/jenkins-deployment.yaml` has removed since the site was last deployed. + Note also what this cannot see: a key that exists in both but was given a different value locally, such + as an edited `agent.podTemplates` entry, is silently overwritten. + Read the diff of `helm template` before deploying an unfamiliar site. + """ + def leaf_values(values, path: str = "") -> dict: + """Flattens a values map to {dotted.key.path: value}, lists are leaves (helm replaces them).""" + if not isinstance(values, dict): + return {path: values} + leaves = {} + for key, value in values.items(): + leaves.update(leaf_values(value, f"{path}.{key}" if path else str(key))) + return leaves + + live_leaves, proposed_leaves = leaf_values(live), leaf_values(proposed) + lost = {path: value for path, value in live_leaves.items() if path not in proposed_leaves} + # lists are replaced wholesale, so also report items dropped from a list that is otherwise still there + for path, value in live_leaves.items(): + if isinstance(value, list) and isinstance(proposed_leaves.get(path), list): + dropped = [item for item in value if item not in proposed_leaves[path]] + if dropped: + lost[f"{path}[]"] = dropped + return lost + + with open(DEPLOY_YAML, encoding="utf-8") as deploy_yaml: + proposed = yaml.safe_load(deploy_yaml) or {} + if values_override: + with open(values_override, encoding="utf-8") as override_yaml: + proposed = merge_values(proposed, yaml.safe_load(override_yaml) or {}) + + lost = detect_lost_values(helm_values(), proposed) + if not lost: + return proposed + + print(f"\nWARNING: {len(lost)} value(s) the deployed jenkins has are absent from what is about to be applied.") + print("Each is either a customisation of this site, or a key removed from jenkins-deployment.yaml since" + " the site was last deployed. Upgrading drops them:\n") + for path in sorted(lost): + value = str(lost[path]).replace("\n", " ") + print(f" {path}: {value[:100] + '…' if len(value) > 100 else value}") + print(f"\nTo keep any of them, add them to a values override file (see .jenkins/k8s/README.md) and pass" + f" `--values-override`{' (the file passed does not contain them)' if values_override else ''}.") + + if not sys.stdin.isatty(): + print("Refusing to drop them when running non-interactively.") + sys.exit(1) + if input("\nDrop these values and continue? [y/N] ").strip().lower() not in ("y", "yes"): + print("Aborted, nothing was deployed.") + sys.exit(1) + return proposed + + # the values checked are the merged result, so a site override raising an instanceCap is checked too + check_agent_capacity(kubeconfig, kubecontext, kube_ns, confirm_helm_updates()) + + print("Adding Helm repository for Jenkins Operator...") + subprocess.run(["helm", "repo", "add", "jenkins", "https://charts.jenkins.io"], check=True) + subprocess.run(["helm", "repo", "update"], check=True) + + # site customisations are applied last, helm merges each -f over the previous + values_files = ["-f", DEPLOY_YAML] + (["-f", values_override] if values_override else []) + # --timeout is longer than helm's 5m default, which `--wait` would otherwise spend waiting for a pod + # whose controller.probes.startupProbe already permits a 300s boot, then fail a deploy that was + # succeeding. Keep it clear of that budget plus the readinessProbe's, see jenkins-deployment.yaml + result = run_helm_command(kubeconfig, kubecontext, kube_ns, + ["upgrade", "--install"] + values_files + + ["cassius", "jenkins/jenkins", "--wait", "--timeout", "10m"]) + + run_kubectl_command(kubeconfig, kubecontext, kube_ns, + ["exec", DEFAULT_POD_NAME, "--", + "curl", "-sS", "https://www.apache.org/logos/originals/cassandra-4.svg", + "-o", "/var/jenkins_cache/war/images/svgs/logo.svg"]) + + if result.returncode != 0: + print("Failed to install Jenkins Operator using Helm. Check the configuration and/or `kubectl logs cassius-jenkins-0`.") + sys.exit(1) + + +def wait_for_jenkins_http(ip: str): + host, port = (ip.rsplit(":", 1)[0], int(ip.rsplit(":", 1)[1])) if ":" in ip else (ip, 80) + spin_while(f"Waiting for Jenkins HTTP at {host}:{port}… ", lambda: _tcp_connect_ok(host, port)) + + +def _tcp_connect_ok(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=2): + return True + except OSError: + return False + + +def get_jenkins(k8s_client: client.CoreV1Api, args, kube_ns: str) -> Tuple[str, jenkins.Jenkins]: + """Authenticates to Jenkins and returns the Jenkins ip and server objects.""" + + def get_jenkins_ip(k8s_client, kube_ns: str) -> str: + svc = k8s_client.read_namespaced_service("cassius-jenkins", kube_ns) + if svc.status.load_balancer.ingress: + # the best we can do is the public IP or hostname of the controller, which may not be the common public url + ingress = svc.status.load_balancer.ingress[0] + ip = ingress.ip if ingress.ip else ingress.hostname + if svc.spec.ports[0].port != 80: + ip += ":" + str(svc.spec.ports[0].port) + print(f"Jenkins: {ip}\n---") + return ip + raise ValueError("Unable to retrieve Jenkins IP address") + + def prompt_for_password(): + return getpass.getpass("Enter Jenkins password: ") + + kubeconfig = args.kubeconfig + kubecontext = args.kubecontext + user = args.user if args.user else "admin" + ip = args.url if args.url else get_jenkins_ip(k8s_client, kube_ns) + + password = prompt_for_password() if args.user \ + else run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["exec", DEFAULT_POD_NAME, "--", "cat", "/run/secrets/additional/chart-admin-password"]) + # Initialize Jenkins API clien + server = jenkins.Jenkins(f"http://{ip}", username=user, password=password) + return ip, server + + +def ensure_job_parameters_visible(server: jenkins.Jenkins, job_name: str): + """ + If necessary, triggers a non-parameter build to make parameterised builds visible. + """ + job_info = server.get_job_info(job_name) + if any(param.get("parameterDefinitions") for param in job_info.get("property", [])): + return + + print(f"Parameters are not visible for job {job_name}; initiating non-parameter build.") + queue_item = server.build_job(job_name) + build_number = wait_for_build_number(server, queue_item) + time.sleep(6) + try: + server.stop_build(job_name, build_number) + except client.exceptions.ApiException as e: + print(f"Failed to stop non-parameter build {job_name} {build_number}: {e}") + print(f"Parameters should now be available for job {job_name}.") + + +def ensure_cassandra_job_parameters_visible(server: jenkins.Jenkins): + """Ensures parameterised builds are visible for all cassandra* jobs.""" + for job in server.get_jobs(): + job_name = job.get("name", "") + if job_name.startswith("cassandra"): + ensure_job_parameters_visible(server, job_name) + + +def trigger_jenkins_build(server: jenkins.Jenkins, job_name: str, **build_params) -> dict: + """Triggers a Jenkins build with specified parameters and returns the queue item.""" + ensure_job_parameters_visible(server, job_name) + print("Triggering Jenkins build… ") + return server.build_job(job_name, parameters=build_params) + + +def wait_for_build_number(server: jenkins.Jenkins, queue_item: int) -> int: + spin_while("Waiting for job build number… ", lambda: ('executable' in server.get_queue_item(queue_item))) + build_number = server.get_queue_item(queue_item)['executable']['number'] + sys.stdout.write("\033[F\033[K") # Move cursor up one line and clear i + print(f"\rBuild number: {build_number}\n") + return build_number + + +def wait_for_build_complete(server: jenkins.Jenkins, job_name: str, build_number: int): + """Waits for Jenkins build completion by monitoring the build status.""" + + def get_build_info(server: jenkins.Jenkins, job_name: str, build_number: int) -> dict: + try: + return server.get_build_info(job_name, build_number) + except (jenkins.NotFoundException, jenkins.JenkinsException, requests.exceptions.ConnectionError) as e: + debug(f"Failed get_build_info: {e}") + return {} + + elapsed_time = spin_while("Waiting for build to complete… ", + lambda: get_build_info(server, job_name, build_number).get('result')) + + minutes, seconds = divmod(elapsed_time, 60) + result = get_build_info(server, job_name, build_number)['result'] + print(f"\r---\nBuild completed after {minutes:02}:{seconds:02} with status: {result}") + + +def spin_while(message="", is_complete=lambda: False) -> int: + spinner = itertools.cycle(['|', '/', '-', '\\']) + start_time = time.time() + elapsed_time = 0 + while not is_complete(): + elapsed_time = int(time.time() - start_time) + minutes, seconds = divmod(elapsed_time, 60) + for _ in range(10): + sys.stdout.write(f"\r{message} {minutes:02}:{seconds:02} {next(spinner)}\033[?25l") + sys.stdout.flush() + time.sleep(0.3) + sys.stdout.write("\r" + " " * len(message + " \033[?25h")) + sys.stdout.flush() + return elapsed_time + +def node_cleaner(k8s_client: client.CoreV1Api, kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str): + """ + Periodically checks for dangling nodes and deletes them (and the underlying cloud instances) + for either GKE (GCP) or EKS (AWS). Cloud is auto-detected via node.spec.providerID. + + Env variables (per cloud provider): AWS_REGION, GCP_PROJECT_ID, GCP_ZONE + """ + def keep_running() -> bool: + return bool(globals().get("IS_RUNNING", True)) + + def node_cleaner_debug(msg: str): + if os.environ.get("NODE_CLEANER_DEBUG"): + print(msg) + + class CloudProvider(Enum): + AWS = "aws" + GCP = "gcp" + UNKNOWN = None + + # Patterns that indicate the node is actively in use by a jenkins pod + ACTIVE_POD_NAMES = ["agent-dind", "cassius"] + + def is_node_in_use(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, node_name: str) -> bool: + desc = run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["describe", "node", node_name]) + return any(p in desc for p in ACTIVE_POD_NAMES) + + def cordon_node(node_name: str): + try: + k8s_client.patch_node(name=node_name, body={"spec": {"unschedulable": True}}) + node_cleaner_debug(f"Node {node_name} cordoned.") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to cordon node {node_name}: {e}") + + def drain_node(node_name: str): + try: + pods = k8s_client.list_pod_for_all_namespaces(field_selector=f"spec.nodeName={node_name}") + for pod in pods.items: + owner_refs = pod.metadata.owner_references or [] + # Delete only non-DaemonSet pods + if not any(ref.kind == "DaemonSet" for ref in owner_refs): + try: + k8s_client.delete_namespaced_pod(name=pod.metadata.name, namespace=pod.metadata.namespace) + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to delete pod {pod.metadata.name} on {node_name}: {e}") + node_cleaner_debug(f"Node {node_name} drained (and all non-DaemonSet pods deleted).") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to drain node {node_name}: {e}") + + def delete_k8s_node(node_name: str): + try: + k8s_client.delete_node(node_name) + node_cleaner_debug(f"Node {node_name} deleted from Kubernetes API.") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to delete node {node_name} from K8s API: {e}") + + def get_first_node_provider_id() -> Optional[str]: + try: + items = k8s_client.list_node().items + if not items: + return None + for n in items: + if n.spec and n.spec.provider_id: + return n.spec.provider_id + return None + except client.exceptions.ApiException: + return None + + def detect_cloud_provider(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, node_name: str) -> Tuple[CloudProvider, str]: + """ Returns CloudProvider.AWS, CloudProvider.GCP, or CloudProvider.UNKNOWN. """ + try: + node_obj = k8s_client.read_node(node_name) + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to read node {node_name}: {e}") + return CloudProvider.UNKNOWN, None + + provider_id = getattr(node_obj.spec, "provider_id", None).lower() + + if not provider_id: + provider_id = get_first_node_provider_id().lower() + + if provider_id: + if provider_id.startswith("aws:"): + return CloudProvider.AWS, provider_id + if provider_id.startswith("gce:"): + return CloudProvider.GCP, provider_id + return CloudProvider.UNKNOWN, provider_id + + # Fallback via current-context name + provider_id = "" + try: + ctx = run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["config", "current-context"]).lower() + if "arn:aws:eks" in ctx or "eks" in ctx: + return CloudProvider.AWS, provider_id + if "gke_" in ctx or "gke" in ctx: + return CloudProvider.GCP, provider_id + except subprocess.CalledProcessError: + debug(f"failed to determine provider_id: {e}") + return CloudProvider.UNKNOWN, None + + def parse_aws_provider_id(provider_id: str) -> Tuple[Optional[str], Optional[str]]: + """ + Returns (instance_id, region) derived from providerID. + Example providerID: "aws:///us-west-2a/i-0123456789abcdef0" + region = "us-west-2" (derived from AZ) + """ + assert provider_id + parts = provider_id.split("/") + instance_id = parts[-1] if parts else None + az = parts[-2] if len(parts) >= 2 else None # e.g., "us-west-2a" + region = None + if az and len(az) >= 2: + region = az[:-1] # drop 'a' -> "us-west-2" + # Prefer explicit env if set + if AWS_REGION: + region = AWS_REGION + return (instance_id, region) + + def parse_gce_provider_id(provider_id: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Returns (project_id, zone, instance_name) from providerID. + Example: "gce://my-project/us-central1-b/gke-...-node-..." + """ + assert provider_id + pid = provider_id.split("://", 1)[-1] + project, zone, instance = pid.split("/", 2) + # Prefer explicit env if set + project = GCP_PROJECT_ID or project + zone = GCP_ZONE or zone + return (project, zone, instance) + + def terminate_instance_gcp(project_id: str, zone: str, instance_name: str): + assert project_id and zone and instance_name + try: + from google.cloud import compute_v1 + from google.api_core.exceptions import GoogleAPICallError + except ImportError as e: + node_cleaner_debug(f"GCP client not available: {e}") + raise + try: + gcloud_compute_client = compute_v1.InstancesClient() + op = gcloud_compute_client.delete(project=project_id, zone=zone, instance=instance_name) + try: + op.result() + except GoogleAPICallError as e: + node_cleaner_debug(f"Failed to wait for GCE instance deletion operation: {e}") + return + node_cleaner_debug(f"GCE instance {instance_name} deleted (project={project_id}, zone={zone}).") + except GoogleAPICallError as e: + node_cleaner_debug(f"Failed to delete GCE instance {instance_name}: {e}") + + def terminate_instance_aws(instance_id: str, region: Optional[str]): + assert instance_id + try: + import boto3 + except ImportError as e: + node_cleaner_debug(f"AWS boto3 not available: {e}") + return + + session = boto3.session.Session(region_name=region or AWS_REGION) + autoscaling = session.client("autoscaling") + ec2 = session.client("ec2") + + # Prefer ASG termination (decrement desired capacity), fallback to EC2 terminate + try: + autoscaling.terminate_instance_in_auto_scaling_group( + InstanceId=instance_id, + ShouldDecrementDesiredCapacity=True + ) + node_cleaner_debug(f"EC2 instance {instance_id} terminated via Auto Scaling (decremented desired capacity).") + return + except autoscaling.exceptions.ClientError as e: + node_cleaner_debug(f"ASG termination failed for {instance_id}: {e}. Falling back to EC2 terminate.") + try: + ec2.terminate_instances(InstanceIds=[instance_id]) + node_cleaner_debug(f"EC2 instance {instance_id} terminated via EC2 API.") + except ec2.exceptions.ClientError as e: + node_cleaner_debug(f"Failed to terminate EC2 instance {instance_id}: {e}") + + def check_and_cleanup_node(node_name: str): + """ Check if node is dangling; if so, drain, delete from K8s, and remove the cloud instance. """ + # 1) If used by known patterns, skip (check for 1 minute) + for attempt in range(6): + if not keep_running(): + return + try: + if is_node_in_use(kubeconfig, kubecontext, kube_ns, node_name): + node_cleaner_debug(f"Node {node_name} in use [check {attempt}].") + return + except (subprocess.CalledProcessError, client.exceptions.ApiException) as e: + node_cleaner_debug(f"Failed to inspect node {node_name} [check {attempt}]: {e}") + return # Don't delete nodes we can't inspect safely + time.sleep(10) + + # 2) Determine provider + IDs from providerID of this node + cloud, provider_id = detect_cloud_provider(kubeconfig, kubecontext, kube_ns, node_name) + + # 3) Cordon & drain & delete K8s node (shared) + node_cleaner_debug(f"Deleting dangling node {node_name}…") + cordon_node(node_name) + drain_node(node_name) + delete_k8s_node(node_name) + + # 4) Cloud-specific instance delete/terminate + if CloudProvider.AWS == cloud: + instance_id, region = parse_aws_provider_id(provider_id) + if not instance_id and node_name.startswith("ip-") and "." in node_name: + # Can't derive instance-id from hostname; skip cloud deletion + node_cleaner_debug(f"No providerID for {node_name}; cannot derive EC2 instance-id from hostname.") + terminate_instance_aws(instance_id, region) + elif CloudProvider.GCP == cloud: + project_id, zone, instance_name = parse_gce_provider_id(provider_id) + terminate_instance_gcp(project_id, zone, instance_name if instance_name else node_name) + else: + node_cleaner_debug(f"Unknown cloud for node {node_name}; cloud instance not deleted.") + + # Main node_cleaner loop + while keep_running(): + try: + nodes = k8s_client.list_node().items + node_cleaner_debug(f" {len(nodes)} nodes") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to list nodes: {e}") + time.sleep(10) + continue + active_threads = {t.name for t in threading.enumerate()} + for n in nodes: + node_name = n.metadata.name + # only act on nodes with "agent" in the name + node_cleaner_debug(f"Checking node {node_name}…") + if node_name not in active_threads: + t = threading.Thread(target=check_and_cleanup_node, args=(node_name,), name=node_name, daemon=True) + t.start() + time.sleep(10) + + +def delete_remote_junit_files(k8s_client, pod_name: str, kube_ns: str, base_job_name: str, build_number: int): + debug("Cleaning remote individual JUnit XML files...") + exec_command = ['rm', '-rf', f'/var/jenkins_home/jobs/{base_job_name}/builds/{build_number}/archive/test/output'] + stream.stream(k8s_client.connect_get_namespaced_pod_exec, + pod_name, kube_ns, container=DEFAULT_CONTAINER_NAME, command=exec_command, stderr=True, stdin=False, stdout=True, tty=False, _preload_content=False) + debug("Remote JUnit XML files cleaned.") + + +def download_results_and_print_summary(k8s_client, pod_name: str, kube_ns: str, build_number: int, ip: str, args): + + def download_console_log(pod_name: str, container_name: str, kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, console_log_path: str, local_console_log: Path): + max_retries = 5 + for attempt in range(max_retries): + try: + run_kubectl_command(kubeconfig, kubecontext, kube_ns, + ["cp", "-c", container_name, f"{kube_ns}/{pod_name}:{console_log_path}", str(local_console_log)]) + + print(f"Console log saved to {local_console_log}.gz\n") + break + except subprocess.CalledProcessError as e: + if attempt < max_retries: + debug(f" Failed to download {pod_name}:{console_log_path}: {e}. Retrying ({attempt + 1}/{max_retries})...") + time.sleep(5) # Wait before retrying + else: + raise + + def download_archive_tarball(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, pod_name: str, container_name: str, remote_path: str, local_path, max_retries=5): + for attempt in range(max_retries): + try: + run_kubectl_command(kubeconfig, kubecontext, kube_ns, + ["cp", "-c", container_name, f"{kube_ns}/{pod_name}:{remote_path}", str(local_path)]) + + debug(f"Build Artifacts saved in {local_path}") + break + except subprocess.CalledProcessError as e: + if attempt < max_retries: + debug(f" Failed to download {pod_name}:{remote_path}: {e}. Retrying ({attempt + 1}/{max_retries})...") + time.sleep(5) # Wait before retrying + else: + raise + + def extract_and_rename(archive_path: str, local_results_dir: str, ci_summary_file: str, ci_details_file: str): + with tarfile.open(archive_path, "r:gz") as tar: + tar.extractall(path=local_results_dir) + if (local_results_dir / "archive/ci_summary.html").exists(): + (local_results_dir / "archive/ci_summary.html").rename(ci_summary_file) + print(f"CI summary saved as {ci_summary_file}") + if (local_results_dir / "archive/results_details.tar.xz").exists(): + (local_results_dir / "archive/results_details.tar.xz").rename(ci_details_file) + print(f"Details file saved as {ci_details_file}") + print(" (attach ci_summary….html and results_details….tar.xz to the JIRA ticket)") + os.remove(archive_path) + print("---") + print(f"Logs in {local_results_dir / 'archive/stage-logs/'} and {local_results_dir / 'archive/test/logs/'}") + + def print_results_summary_console(local_console_log): + if local_console_log.exists(): + with open(local_console_log, 'r', encoding="utf-8") as log_file: + log_content = log_file.read() + if "BUILD FAILED" in log_content: + print("---") + failed_index = log_content.index("BUILD FAILED") + # Print the 200 characters after "BUILD FAILED" + print(log_content[failed_index:failed_index + 200]) + with open(local_console_log, 'r', encoding="utf-8") as log_file: + for line in log_file: + if "Finished: " in line: + print(line.strip()) + break + else: + print("Missing console log.") + + def print_results_summary_ci_summary(ci_summary_file): + if ci_summary_file.exists(): + with open(ci_summary_file, 'r', encoding="utf-8") as log_file: + summary_parts = [] + for line in log_file: + if any(l in line for l in [">Passed<", ">Failed<", ">Skipped<", ">Total<"]): + summary_parts.append(BeautifulSoup(line, 'html.parser').get_text().strip()) + if ">Total<" in line: + break + if summary_parts: + print(" – ".join(summary_parts)) + else: + print("No tests were run (or missing summary file).") + + def print_results_summary(local_console_log, ci_summary_file): + print("--- Build Summary ---") + print_results_summary_console(local_console_log) + print_results_summary_ci_summary(ci_summary_file) + # leave console_log.txt gzipped + if local_console_log.exists(): + with open(local_console_log, 'rb') as f_in, gzip.open(f"{local_console_log}.gz", 'wb') as f_out: + f_out.writelines(f_in) + os.remove(local_console_log) + + def download_url(url, dest, max_retries=5): + for attempt in range(max_retries): + try: + urlretrieve(url, dest) + debug(f" saved {dest}") + break + except (requests.exceptions.RequestException, IOError) as e: + if attempt < max_retries: + debug(f" Failed to download {url}: {e}. Retrying ({attempt + 1}/{max_retries})...") + time.sleep(5) # Wait before retrying + else: + raise + + local_results_dir = LOCAL_RESULTS_BASEDIR / ip.replace(".", "-") / str(build_number) + local_results_dir.mkdir(parents=True, exist_ok=True) + repo_owner = args.repository.split('/')[3] if 'https' in args.repository else args.repository.split(':')[1].split('/')[0] + ci_summary_file = local_results_dir / f"ci_summary_{repo_owner}_{args.branch.replace('/', '-')}_{build_number}.html" + ci_details_file = local_results_dir / f"results_details_{repo_owner}_{args.branch.replace('/', '-')}_{build_number}.tar.xz" + if args.url: + download_url(f"http://{ip}/job/{base_job_name(args)}/{build_number}/artifact/ci_summary.html", ci_summary_file) + download_url(f"http://{ip}/job/{base_job_name(args)}/{build_number}/artifact/results_details.tar.xz", ci_details_file) + if (ci_summary_file).exists(): + print(f"CI summary saved as {ci_summary_file}") + if (ci_details_file).exists(): + print(f"Details file saved as {ci_details_file}") + print(" (attach ci_summary….html and results_details….tar.xz to the JIRA ticket)") + print("--- Build Summary ---") + print_results_summary_ci_summary(ci_summary_file) + else: + kubeconfig = args.kubeconfig + kubecontext = args.kubecontext + local_console_log = local_results_dir / "console_log.txt" + local_archive_tar = local_results_dir / "archive.tar.gz" + remote_build_dir = f"/var/jenkins_home/jobs/{base_job_name(args)}/builds/{build_number}" + remote_console_log_path = f"{remote_build_dir}/log" + remote_archive_dir = f"{remote_build_dir}/archive" + + print("Downloading build results and logs...") + console_log_thread = threading.Thread(target=download_console_log, + args=(pod_name, DEFAULT_CONTAINER_NAME, kubeconfig, kubecontext, kube_ns, remote_console_log_path, local_console_log)) + console_log_thread.start() + + # Compress and download the archive directory if it exists + archive_path_in_pod = f"{remote_archive_dir}.tar.gz" + try: + # compress + compress_command = ["tar", "czf", f"{archive_path_in_pod}", "-C", remote_build_dir, "archive"] + stream.stream(k8s_client.connect_get_namespaced_pod_exec, pod_name, kube_ns, container=DEFAULT_CONTAINER_NAME, + command=compress_command, stderr=True, stdin=False, stdout=True, tty=False) + + local_archive_tar = local_results_dir / "archive.tar.gz" + download_archive_tarball(kubeconfig, kubecontext, kube_ns, pod_name, DEFAULT_CONTAINER_NAME, archive_path_in_pod, local_archive_tar) + # delete + stream.stream(k8s_client.connect_get_namespaced_pod_exec, pod_name, kube_ns, container=DEFAULT_CONTAINER_NAME, + command=['rm', archive_path_in_pod], stderr=True, stdin=False, stdout=True, tty=False) + + extract_and_rename(local_archive_tar, local_results_dir, ci_summary_file, ci_details_file) + + console_log_thread.join() + print_results_summary(local_console_log, ci_summary_file) + except client.exceptions.ApiException as e: + print(f"Failed to tarball artifacts at {archive_path_in_pod} in {pod_name}: {e}") + + +def cleanup_and_maybe_teardown(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, tear_down: bool): + global IS_RUNNING + IS_RUNNING = False + if tear_down: + print("Cleaning up Jenkins and all resources.") + run_helm_command(kubeconfig, kubecontext, kube_ns, ["uninstall", "cassius"], capture_output=False) + # the pvc is annotated `helm.sh/resource-policy: keep`, see .jenkins/k8s/jenkins-deployment.yaml + print(f"Jenkins uninstalled. The jenkins-home volume was kept, delete it with:\n" + f" kubectl --namespace {kube_ns} delete pvc cassius-jenkins") + + +@contextmanager +def helm_installation_lock(lock_file: Path, timeout: int = 120): + with open(lock_file, "w", encoding="utf-8") as lock: + start = time.time() + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + break + except BlockingIOError as exc: + if (time.time() - start) > timeout: + raise TimeoutError("Timeout waiting for file lock.") from exc + time.sleep(1) + + +def main_download_results(k8s_client, ip, args): + build_number = int(args.download_results) + download_results_and_print_summary(k8s_client, DEFAULT_POD_NAME, DEFAULT_KUBE_NS, build_number, ip, args) + + +def main(): + load_environment_file() + args = parse_arguments() + k8s_client = None if args.url else setup_environment(args.kubeconfig, args.kubecontext) + + if args.only_tear_down: + cleanup_and_maybe_teardown(args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS, True) + return + if args.only_node_cleaner: + os.environ["NODE_CLEANER_DEBUG"] = "true" + node_cleaner(k8s_client, args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS) + return + if args.setup or args.only_setup: + init_k8s_namespace(k8s_client, DEFAULT_KUBE_NS) + with helm_installation_lock(Path("/tmp/.cassandra-run-ci.lock")): + install_jenkins(args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS, args.values_override) + + (ip, server) = get_jenkins(k8s_client, args, DEFAULT_KUBE_NS) + if args.setup or args.only_setup: + wait_for_jenkins_http(ip) + ensure_cassandra_job_parameters_visible(server) + if args.only_setup: + return + if args.download_results: + main_download_results(k8s_client, ip, args) + return + + # Background node cleaner: checks for dangling nodes and deletes them, can dramatically reduce k8s costs + # set env var NODE_CLEANER_DISABLE to disable + if not os.environ.get("NODE_CLEANER_DISABLE") and not args.url: + threading.Thread(target=node_cleaner, + args=(k8s_client, args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS), daemon=True).start() + + # Trigger Jenkins build with parameters + build_params = { + "repository": args.repository, + "branch": args.branch, + "profile": args.profile, + "profile_custom_regexp": args.profile_custom_regexp or "", + "jdk": args.jdk or "", + "repeat_test_regex": args.repeat_test_regex or "", + "repeated_tests_count": args.repeat_count or "", + "repeated_tests_stop_on_failure": "true" if args.repeat_stop_on_failure else "false", + "repeated_tests_machines": args.repeat_machines or "1", + "dtest_repository": args.dtest_repository or "", + "dtest_branch": args.dtest_branch or "" + } + + if DEFAULT_REPO_URL == args.repository and DEFAULT_REPO_BRANCH == args.branch and is_local_git_dirty(args): + print("Local uncommitted/unpushed changes.") + print(f"CI only runs on what is pushed in {args.repository} @ {args.branch}") + print(" See `git diff-index HEAD --` for uncommitted changes") + print(" See `git log @{u}.. --name-only` for unpushed changes") + print(" Do you want to continue anyway (y/N):") + if "y" != input().strip().lower(): + return + + queue_item = trigger_jenkins_build(server, base_job_name(args), **build_params) + build_number = wait_for_build_number(server, queue_item) + print(f"Jenkins UI at http://{ip}/job/{base_job_name(args)}/{build_number}/pipeline-overview/") + wait_for_build_complete(server, base_job_name(args), build_number) + + # Post-build processing and cleanup + if not args.url: + delete_remote_junit_files(k8s_client, DEFAULT_POD_NAME, DEFAULT_KUBE_NS, base_job_name(args), build_number) + download_results_and_print_summary(k8s_client, DEFAULT_POD_NAME, DEFAULT_KUBE_NS, build_number, ip, args) + cleanup_and_maybe_teardown(args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS, args.tear_down) + +if __name__ == "__main__": + main() diff --git a/.build/run-ci-local b/.build/run-ci-local new file mode 100755 index 000000000000..5e33d07290c5 --- /dev/null +++ b/.build/run-ci-local @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 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. +""" +Local CI runner – a stand-in for the cassandra-5.0 `.build/run-ci` that runs on this machine +instead of Jenkins. + +The 5.0+ CI is a set of CI-agnostic, dockerised scripts (`.build/docker/*.sh`) that run inside +docker images (apache/cassandra-ubuntu-test, apache/cassandra-bullseye-build, +apache/cassandra-almalinux-build, tagged by the md5 of their dockerfile). Those scripts are +vendored into this tree under `.build/` (copied from the cassandra-5.0 branch), the same docker +images are used, and are expected to work on this 4.x tree as well. This script: + + 1. builds the same task matrix as the 5.0 Jenkinsfile (profile -> steps -> jdk/python/split cells), + 2. runs every cell locally in docker with the same images, each in its own hardlinked workspace + under build/ci-local//cells/ (cleaned up afterwards unless --keep-workspace), + 3. organises the test results and generates the same artefacts as CI: + ci_summary.html and results_details.tar.xz under build/ci-local//. + +Notes: + - 4.x-specific adaptations: `lint` (no `ant check` target), `test-latest` and + `test-oa` are omitted (no such targets in this build.xml). Build and test cells both + run on JDK 8 and 11; the shared images carry only 11/17, so JDK 8 is + fetched into the shared Maven cache on first use (`.build/docker/_ensure_jdk8.sh`). + - Requires: docker (running), rsync, bc, xz. Python 3.8+. + - Generating ci_summary.html additionally needs the .build/ci python requirements + (`pip install -r .build/ci/requirements.txt`); without them the run still completes and the + raw JUnit xmls are kept in build/ci-local//test-results/. + +Examples: + .build/run-ci-local # skinny profile, all supported JDKs, native arch + .build/run-ci-local -j 11 -n 2 # only jdk 11, two cells in parallel + .build/run-ci-local -p custom -e 'cqlsh-test' # custom profile: only cqlsh-test + .build/run-ci-local -p custom -e '^dtest$' -j 8 -c 64/64 # one matrix cell + .build/run-ci-local -p post-commit -k trunk # python dtests from cassandra-dtest trunk + .build/run-ci-local --dry-run # print the cell plan and exit +""" + +import argparse +import concurrent.futures +import datetime +import hashlib +import os +import re +import shutil +import subprocess +import sys +import tarfile +import xml.etree.ElementTree as ET +from pathlib import Path + + +# ---------------------------------------------------------------------------- +# constants +# ---------------------------------------------------------------------------- + +TREE = Path(__file__).resolve().parent.parent +BUILD_XML = TREE / "build.xml" +CI_LOCAL_BASE = TREE / "build" / "ci-local" +DEFAULT_DTEST_REPO = "https://github.com/apache/cassandra-dtest.git" +DEFAULT_DTEST_BRANCH = "trunk" +ALPINE_IMAGE = "alpine:3.19.1" + +# the 5.0 Jenkinsfile pipelineProfiles(), without 'lint' (no `ant check` target on this branch) +PIPELINE_PROFILES = { + "packaging": ["artifacts", "debian", "redhat"], + "skinny": ["cqlsh-test", "test", "jvm-dtest", "simulator-dtest", "dtest"], + "pre-commit": ["artifacts", "debian", "redhat", "fqltool-test", "cqlsh-test", "test", + "stress-test", "test-burn", "jvm-dtest", "simulator-dtest", "dtest", + "dtest-latest", "microbench-test"], + "pre-commit w/ upgrades": ["artifacts", "debian", "redhat", "fqltool-test", "cqlsh-test", + "test", "stress-test", "test-burn", "jvm-dtest", + "jvm-dtest-upgrade", "simulator-dtest", "dtest", "dtest-novnode", + "dtest-latest", "dtest-upgrade", "microbench-test"], + "post-commit": ["artifacts", "debian", "redhat", "fqltool-test", "cqlsh-test", "test-cdc", + "test", "test-compression", "stress-test", "test-burn", "long-test", + "test-system-keyspace-directory", "jvm-dtest", "jvm-dtest-upgrade", + "simulator-dtest", "dtest", "dtest-novnode", "dtest-latest", "dtest-large", + "dtest-large-novnode", "dtest-large-latest", "dtest-upgrade", + "dtest-upgrade-novnode", "dtest-upgrade-large", "dtest-upgrade-large-novnode", + "microbench-test"], + "performance": ["microbench"], + "custom": [], +} + +# the 5.0 Jenkinsfile buildSteps(), without 'lint' (no `ant check` target on this branch) +BUILD_STEPS = { + "artifacts": {"script": "build-artifacts.sh", "extra": []}, + "debian": {"script": "build-debian.sh", "extra": []}, + "redhat": {"script": "build-redhat.sh", "extra": ["rpm"]}, +} + +# the 5.0 Jenkinsfile testSteps() (split counts), without the 5.0-only steps this build.xml +# has no targets for: test-latest / test-oa (no `testclasslist-latest` / `testclasslist-oa` targets) +TEST_STEPS = { + "cqlsh-test": 1, + "fqltool-test": 1, + "test-cdc": 8, + "test": 16, + "test-compression": 16, + "stress-test": 1, + "test-burn": 2, + "long-test": 4, + "test-system-keyspace-directory": 16, + "jvm-dtest": 12, + "jvm-dtest-upgrade": 6, + "simulator-dtest": 1, + "dtest": 64, + "dtest-novnode": 64, + "dtest-latest": 64, + "dtest-large": 6, + "dtest-large-novnode": 6, + "dtest-large-latest": 6, + "dtest-upgrade": 128, + "dtest-upgrade-novnode": 128, + "dtest-upgrade-large": 32, + "dtest-upgrade-large-novnode": 32, + "microbench-test": 4, + "microbench": 4, +} + +# per-step container timeout in hours (Jenkinsfile timeout_hours) +STEP_TIMEOUT_HOURS = {"microbench-test": 2, "microbench": 6} + +# cqlsh-test runs the python matrix (Jenkinsfile: cython for 3.8/3.11 only) +CQLSH_PYTHON_MATRIX = [("3.8", ["yes", "no"]), ("3.11", ["yes", "no"]), ("3.12", ["no"]), ("3.13", ["no"])] + +# steps that only run on the default jdk (Jenkinsfile matrix filter) +DEFAULT_JDK_ONLY_STEPS = lambda step: step in ("cqlsh-test", "simulator-dtest") or "dtest-upgrade" in step + +DOCKER_BUILD_FILES = ["bullseye-build.docker"] # jar / build steps / summary reports +DOCKER_TEST_FILES = ["ubuntu-test.docker"] # test steps +DOCKER_ALMALINUX_FILES = ["almalinux-build.docker"] # redhat step + + +# ---------------------------------------------------------------------------- +# small helpers +# ---------------------------------------------------------------------------- + +def fail(message, code=2): + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(code) + + +def which(cmd): + return shutil.which(cmd) is not None + + +def run_quiet(cmd, **kwargs): + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def docker_has_image(name): + return bool(run_quiet(["docker", "images", "-q", name]).stdout.strip()) + + +def host_cpus(): + return os.cpu_count() or 1 + + +def host_mem_gib(): + try: + with open("/proc/meminfo") as f: + for line in f: + if line.startswith("MemTotal"): + return int(line.split()[1]) / (1024 * 1024) + except OSError: + pass + return None + + +def image_name_for(dockerfile_name): + """same naming as the CI scripts: apache/cassandra-:""" + dockerfile = TREE / ".build" / "docker" / dockerfile_name + tag = hashlib.md5(dockerfile.read_bytes()).hexdigest() + return f"apache/cassandra-{dockerfile_name[:-len('.docker')]}:{tag}" + + +# ---------------------------------------------------------------------------- +# argument parsing +# ---------------------------------------------------------------------------- + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="Run the (5.0-style, dockerised) CI against this branch on the local machine.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + epilog=__doc__.split("Examples:")[-1] if "Examples:" in __doc__ else None) + parser.add_argument("-p", "--profile", choices=sorted(PIPELINE_PROFILES), default="skinny", + help="CI pipeline profile.") + parser.add_argument("-e", "--profile-custom-regexp", + help="Regexp selecting stages when using the custom profile, e.g. 'stress.*|jvm-dtest.*'") + parser.add_argument("-j", "--jdk", + help="JDK version(s) to build and test with, comma separated (default: all supported by build.xml)") + parser.add_argument("-d", "--dtest-repository", default=DEFAULT_DTEST_REPO, help="cassandra-dtest repository URL.") + parser.add_argument("-k", "--dtest-branch", default=DEFAULT_DTEST_BRANCH, help="cassandra-dtest branch.") + parser.add_argument("-t", "--test-regexp", + help="Run only tests matching this name regexp (disables splitting, mirrors run-tests.sh -t).") + parser.add_argument("-c", "--split", metavar="K/N", + help="Run only matrix split K/N, for example 64/64. The denominator must match the step's configured split count.") + parser.add_argument("-a", "--steps", metavar="REGEXP", + help="Only run steps whose name matches this regexp (applied on top of the profile).") + parser.add_argument("-n", "--concurrency", type=int, default=None, + help="Number of test cells to run in parallel. " + "Default: min(4, ncpu/4, host-memory/16GiB), at least 1.") + parser.add_argument("-m", "--m2-dir", default=str(CI_LOCAL_BASE / "m2"), + help="Shared maven repository directory used by all cells.") + parser.add_argument("--timeout-hours", type=float, default=None, + help="Override the per-cell timeout (default: 1h, 2h for microbench-test, 6h for microbench).") + parser.add_argument("--dry-run", action="store_true", help="Print the cell plan and exit.") + parser.add_argument("--keep-workspace", action="store_true", help="Keep the per-cell workspaces under build/ci-local//cells/.") + parser.add_argument("--debug", action="store_true", help="Enable DEBUG=1 in the CI scripts.") + args = parser.parse_args() + if args.split: + match = re.fullmatch(r"([1-9][0-9]*)/([1-9][0-9]*)", args.split) + if not match or int(match.group(1)) > int(match.group(2)): + parser.error("--split must be K/N with 1 <= K <= N") + if args.split and args.test_regexp: + parser.error("--split and --test-regexp cannot be used together") + return args + + +# ---------------------------------------------------------------------------- +# build.xml java properties (the CI scripts read these: java.default / java.supported) +# ---------------------------------------------------------------------------- + +def read_build_xml_property(name): + m = re.search(rf'property\s+name="{name}"\s+value="([^"]*)"', BUILD_XML.read_text()) + return m.group(1) if m else None + + +# ---------------------------------------------------------------------------- +# planning +# ---------------------------------------------------------------------------- + +class Cell: + def __init__(self, name, kind, step, jdk=None, python="3.8", cython="no", + split=1, splits=1, timeout_hours=1.0): + self.name = name + self.kind = kind # jar | build_dtest_jars | build | test + self.step = step + self.jdk = jdk + self.python = python + self.cython = cython + self.split = split + self.splits = splits + self.timeout_hours = timeout_hours + + def describe(self): + if self.kind == "jar": + return f"jar jdk{self.jdk}" + extra = f" python{self.python}" if self.step == "cqlsh-test" else "" + cython = " cython" if self.cython == "yes" else "" + split = f" {self.split}/{self.splits}" if self.splits > 1 else "" + return f"{self.step} jdk{self.jdk}{extra}{cython}{split}" + + +def select_steps(args): + if args.profile == "custom": + if not args.profile_custom_regexp: + fail("custom profile requires -e/--profile-custom-regexp") + regexp = re.compile(args.profile_custom_regexp) + steps = [s for s in sorted(BUILD_STEPS) + sorted(TEST_STEPS) if regexp.search(s)] + if not steps: + fail(f"no steps match the custom regexp: {args.profile_custom_regexp}") + else: + steps = PIPELINE_PROFILES[args.profile] + if args.steps: + regexp = re.compile(args.steps) + steps = [s for s in steps if regexp.search(s)] + if not steps: + fail(f"no steps from profile '{args.profile}' match -a/--steps: {args.steps}") + if args.profile_custom_regexp and args.profile != "custom": + print(f"WARNING: -e/--profile-custom-regexp is only used with -p custom, ignoring '{args.profile_custom_regexp}' " + f"(profile '{args.profile}' applies)") + return steps + + +def build_plan(args, steps, supported_jdks): + default_jdk = args._default_jdk + requested_split = tuple(map(int, args.split.split("/"))) if args.split else None + jdk_filter = [j.strip() for j in (args.jdk or "").split(",") if j.strip()] + if jdk_filter: + unknown = [j for j in jdk_filter if j not in supported_jdks] + if unknown: + fail(f"jdk(s) {unknown} not in build.xml java.supported: {supported_jdks}") + jdks = [j for j in supported_jdks if not jdk_filter or j in jdk_filter] + + jar_cells = [Cell(f"jar-jdk{jdk}", "jar", "jar", jdk=jdk) for jdk in jdks] + + cells = [] + split_count_matched = False + for step in steps: + timeout = args.timeout_hours or STEP_TIMEOUT_HOURS.get(step, 1.0) + if step in BUILD_STEPS: + # A split selects a test matrix cell, not independent packaging cells. + if requested_split: + continue + for jdk in jdks: + cells.append(Cell(f"{step}-jdk{jdk}", "build", step, jdk=jdk, timeout_hours=timeout)) + elif step in TEST_STEPS: + splits = 1 if args.test_regexp else TEST_STEPS[step] + if requested_split and requested_split[1] != splits: + continue + if requested_split: + split_count_matched = True + selected_splits = [requested_split[0]] if requested_split else range(1, splits + 1) + # Match Jenkins: default-JDK-only steps disappear when -j excludes the default, + # rather than scheduling cells for a jar that was not requested. + step_jdks = ([default_jdk] if default_jdk in jdks else []) if DEFAULT_JDK_ONLY_STEPS(step) else jdks + matrix = CQLSH_PYTHON_MATRIX if step == "cqlsh-test" else [("3.8", ["no"])] + for jdk in step_jdks: + for python, cythons in matrix: + for cython in cythons: + for split in selected_splits: + name = f"{step}-jdk{jdk}" + if step == "cqlsh-test": + name += f"-python{python.replace('.', '')}" + if cython == "yes": + name += "-cython" + if splits > 1: + name += f"-split{split}" + cells.append(Cell(name, "test", step, jdk=jdk, python=python, + cython=cython, split=split, splits=splits, + timeout_hours=timeout)) + if requested_split and not cells: + if not split_count_matched: + fail(f"no selected test step has a configured split count of {requested_split[1]}") + fail("the requested JDK and step filters exclude every cell for this split") + return jar_cells, cells + + +def plan_summary_text(jar_cells, cells): + lines = ["Planned cells:"] + for c in jar_cells: + lines.append(f" [jar ] {c.describe()}") + for c in cells: + lines.append(f" [{c.kind[:4]}] {c.describe()}") + dtest_steps = {c.step for c in cells if c.kind == "test" and c.step.startswith("dtest")} + return "\n".join(lines) + f"\n({len(jar_cells)} jar cell(s), {len(cells)} task cell(s), dtest steps: {sorted(dtest_steps) or 'none'})" + + +# ---------------------------------------------------------------------------- +# docker +# ---------------------------------------------------------------------------- + +def warmup_docker_images(args): + """Pull the same images the CI uses (dockerhub, falling back to the ASF jfrog mirror), + building locally when neither has them – exactly what the CI scripts do on first use.""" + dockerfiles = DOCKER_BUILD_FILES + DOCKER_TEST_FILES + DOCKER_ALMALINUX_FILES + for dockerfile in dockerfiles: + name = image_name_for(dockerfile) + if docker_has_image(name): + print(f"docker image {name} already present") + continue + print(f"pulling docker image {name} …") + if run_quiet(["docker", "pull", "-q", name]).returncode == 0: + continue + print(f"pulling docker image apache.jfrog.io/cassan-docker/{name} …") + if run_quiet(["docker", "pull", "-q", f"apache.jfrog.io/cassan-docker/{name}"]).returncode == 0: + # tag locally the way the scripts will look it up + run_quiet(["docker", "tag", f"apache.jfrog.io/cassan-docker/{name}", name], check=False) + continue + print(f"pulling failed, building {name} from .build/docker/{dockerfile} …") + subprocess.run(["docker", "build", "-t", name, "-f", f"docker/{dockerfile}", "--load", "."], + cwd=TREE / ".build", check=True) + if not docker_has_image(ALPINE_IMAGE): + print(f"pulling {ALPINE_IMAGE} …") + subprocess.run(["docker", "pull", "-q", ALPINE_IMAGE], check=True) + + +def clone_dtest_repo(run_dir, repo, branch): + dtest_dir = run_dir / "cassandra-dtest" + if (dtest_dir / "dtest.py").is_file(): + print(f"cassandra-dtest already present at {dtest_dir}") + return dtest_dir + print(f"cloning {repo} @ {branch} into {dtest_dir} …") + subprocess.run(["git", "clone", "--depth", "1", "--no-tags", "-b", branch, repo, str(dtest_dir)], check=True) + if not (dtest_dir / "dtest.py").is_file(): + fail(f"{dtest_dir}/dtest.py not found – invalid cassandra-dtest repository/branch " + f"(does {branch} even support cassandra 4.1?)") + return dtest_dir + + +# ---------------------------------------------------------------------------- +# cell execution +# ---------------------------------------------------------------------------- + +def make_cell_workspace(cell_dir, jar_cell_dir): + """Create an isolated per-cell copy of the built jar workspace. + + Prefer filesystem copy-on-write clones. Hardlinks are not safe here: packaging tools edit + tracked files in place, so a hardlinked workspace can modify both sibling cells and TREE. + Fall back to a regular rsync copy when reflinks are unavailable. + """ + if cell_dir.exists(): + shutil.rmtree(cell_dir, ignore_errors=True) + copied = subprocess.run(["cp", "-a", "--reflink=always", str(jar_cell_dir), str(cell_dir)], + capture_output=True) + if copied.returncode != 0: + shutil.rmtree(cell_dir, ignore_errors=True) + subprocess.run(["rsync", "-a", f"{jar_cell_dir}/", str(cell_dir) + "/"], check=True) + + +def create_jar_cell(cell_dir): + """Per-jdk base workspace: an isolated copy of the tree without local build output.""" + if cell_dir.exists(): + shutil.rmtree(cell_dir, ignore_errors=True) + cell_dir.parent.mkdir(parents=True, exist_ok=True) + # A regular copy is intentional: build/package scripts may edit tracked files in place. + subprocess.run( + ["rsync", "-a", + # Local output and ignored development files must not leak into package inputs. + "--exclude=/build/", "--exclude=/logs/", + "--exclude=__pycache__/", "--exclude=*.pyc", "--exclude=/.venv/", + f"{TREE}/", str(cell_dir) + "/"], + check=True) + + +def cell_command(cell_dir, cell, args): + if cell.kind == "jar": + return [str(cell_dir / ".build" / "docker" / "build-jars.sh"), cell.jdk] + if cell.kind == "build_dtest_jars": + return [str(cell_dir / ".build" / "docker" / "run-tests.sh"), "-a", "build_dtest_jars", + "-j", cell.jdk] + if cell.kind == "build": + spec = BUILD_STEPS[cell.step] + return [str(cell_dir / ".build" / "docker" / spec["script"])] + spec["extra"] + [cell.jdk] + # test + cmd = [str(cell_dir / ".build" / "docker" / "run-tests.sh"), "-a", cell.step] + if args.test_regexp: + cmd += ["-t", args.test_regexp] + elif cell.splits > 1: + cmd += ["-c", f"{cell.split}/{cell.splits}"] + return cmd + ["-j", cell.jdk] + + +def cell_env(cell_dir, cell, m2_dir, dtest_dir, args): + env = dict(os.environ) + env["cassandra_dir"] = str(cell_dir) + env["m2_dir"] = str(m2_dir) + env["python_version"] = cell.python + env["cython"] = cell.cython + if dtest_dir is not None and (cell.step.startswith("dtest") or cell.kind == "build_dtest_jars"): + env["cassandra_dtest_dir"] = str(dtest_dir) + env["docker_timeout_hours"] = str(int(cell.timeout_hours)) + if cell.jdk == "11": + # this (4.x) build.xml's validate-build-conf requires CASSANDRA_USE_JDK11=true + # whenever ant runs under jdk 11 (docker_envs becomes the --env flags of docker run) + env["docker_envs"] = "CASSANDRA_USE_JDK11=true" + if args.debug: + env["DEBUG"] = "1" + # never pick up jenkins-related settings from the environment + for key in ("JENKINS_URL", "NODE_NAME"): + env.pop(key, None) + return env + + +def execute_cell(cell, run_dir, jar_cells_by_jdk, m2_dir, dtest_dir, args): + """Create the workspace, run the cell in docker (one retry, like the Jenkinsfile), log everything.""" + cells_dir = run_dir / "cells" + logs_dir = run_dir / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_file = logs_dir / f"{cell.name}.log" + + if cell.kind == "jar": + jar_cell_dir = cells_dir / cell.name + create_jar_cell(jar_cell_dir) + else: + jar_cell = jar_cells_by_jdk.get(cell.jdk) + if jar_cell is None: + return False, "jar build for jdk%s failed, skipping" % cell.jdk + jar_cell_dir = cells_dir / jar_cell.name + cell_dir = cells_dir / cell.name + make_cell_workspace(cell_dir, jar_cell_dir) + run_dir_cell = jar_cell_dir if cell.kind == "jar" else cell_dir + + cmd = cell_command(run_dir_cell, cell, args) + env = cell_env(run_dir_cell, cell, m2_dir, dtest_dir, args) + timeout_seconds = int(cell.timeout_hours * 3600) + + for attempt in (1, 2): + if attempt > 1: + print(f" retrying {cell.name} (attempt 2/2) …") + print(f" running {cell.name} (attempt {attempt}/2) → {log_file}") + with open(log_file, "ab") as log: + log.write(f"\n===== {datetime.datetime.now()} attempt {attempt}/2: {' '.join(cmd)} =====\n".encode()) + log.flush() # keep the attempt header ahead of output written by the child process + proc = subprocess.Popen(cmd, cwd=run_dir_cell, env=env, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) + try: + rc = proc.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(proc.pid), 9) + proc.wait() + raise + if rc == 0: + return True, None + # one retry already happened + return False, f"exit status from attempt 2 (see {log_file})" + + +def run_pool(cells, label, run_dir, jar_cells_by_jdk, m2_dir, dtest_dir, args, concurrency): + results = {} + if not cells: + return results + print(f"\n=== {label}: {len(cells)} cell(s), {concurrency} in parallel ===") + with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool: + futures = {pool.submit(execute_cell, cell, run_dir, jar_cells_by_jdk, m2_dir, dtest_dir, args): cell + for cell in cells} + for future in concurrent.futures.as_completed(futures): + cell = futures[future] + try: + ok, message = future.result() + except BaseException as exc: # noqa: BLE001 – report and continue with the other cells + ok, message = False, f"crashed: {exc}" + results[cell.name] = ok + print(f" [{'ok ' if ok else 'FAIL'}] {cell.name}" + (f" – {message}" if not ok else "")) + return results + + +# ---------------------------------------------------------------------------- +# results +# ---------------------------------------------------------------------------- + +def organise_test_results(run_dir, cells): + """Mirror of the Jenkinsfile organiseTestResultFiles(): gather the JUnit xmls of all test cells + into /test-results//jdk_// (cqlshlib/nosetests directly under ).""" + results_dir = run_dir / "test-results" + arch = subprocess.run(["arch"], capture_output=True, text=True).stdout.strip() or "unknown" + moved = 0 + for cell in cells: + if cell.kind != "test": + continue + cell_dir = run_dir / "cells" / cell.name + output_dir = cell_dir / "build" / "test" / "output" + if not output_dir.is_dir(): + continue + step_dir = results_dir / cell.step + jdk_dir = step_dir / f"jdk_{cell.jdk}" / arch + for xml in output_dir.rglob("TEST*.xml"): + jdk_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(xml), str(jdk_dir / xml.name)) + moved += 1 + for name in ("cqlshlib.xml", "nosetests.xml"): + for xml in list(output_dir.rglob(name)): + step_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(xml), str(step_dir / f"{name[:-4]}_{cell.name}.xml")) + moved += 1 + print(f"Gathered {moved} test result file(s) into {results_dir}") + return results_dir + + +def count_results(results_dir): + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + if not results_dir.is_dir(): + return totals, 0 + files = list(results_dir.rglob("*.xml")) + for xml in files: + try: + root = ET.parse(str(xml)).getroot() + except ET.ParseError: + continue + for suite in ([root] if root.tag == "testsuite" else root.iter("testsuite")): + for key, attr in (("tests", "tests"), ("failures", "failures"), + ("errors", "errors"), ("skipped", "skipped")): + value = suite.get(attr) + if value is not None: + totals[key] += int(value) + return totals, len(files) + + +def generate_summary(run_dir, results_dir, args, summary_cell_dir, branch): + """Mirror of the Jenkinsfile Summary stage: per-target ant generate-test-report (in docker, + same image as CI), then ci_summary.html via .build/ci/ci_parser.py.""" + targets = sorted(p.name for p in results_dir.iterdir() if p.is_dir()) if results_dir.is_dir() else [] + if not targets: + print("\nNo test results to summarise (build-only profile?).") + return None, results_dir + + # move the results into the summary workspace where the container's defaults (build/test/…) find them + container_results = summary_cell_dir / "build" / "test" / "output" + shutil.rmtree(container_results, ignore_errors=True) + container_results.mkdir(parents=True, exist_ok=True) + for target in targets: + shutil.move(str(results_dir / target), str(container_results / target)) + + for target in targets: + print(f"generating test report for {target} …") + env = dict(os.environ) + env["cassandra_dir"] = str(summary_cell_dir) + env["m2_dir"] = str(args.m2_dir) + env["CASSANDRA_DOCKER_ANT_OPTS"] = ( + f"-Dbuild.test.output.dir=build/test/output/{target} " + f"-Dbuild.test.report.dir=build/test/reports/{target}") + if args._default_jdk == "11": + # same CASSANDRA_USE_JDK11 requirement as the cells (see cell_env) + env["docker_envs"] = "CASSANDRA_USE_JDK11=true" + for key in ("JENKINS_URL", "NODE_NAME"): + env.pop(key, None) + rc = subprocess.run( + [str(summary_cell_dir / ".build" / "docker" / "_docker_run.sh"), + "bullseye-build.docker", "ci/generate-test-report.sh"], + cwd=summary_cell_dir, env=env).returncode + if rc != 0: + print(f"WARNING: generate-test-report for {target} exited {rc} (the ci summary still proceeds)") + + # ci_summary.html – the Jenkinsfile Summary stage itself: run the vendored + # generate-ci-summary.sh inside bullseye-build, whose image ships the jinja2 and + # beautifulsoup4 that ci_parser.py needs (a plain host python may not have them). + # The script writes the HTML skeleton to ${DIST_DIR}/ci_summary.html and then runs + # ci_parser.py over ${DIST_DIR}/test/output, where the results were moved above. + in_container_summary = summary_cell_dir / "build" / "ci_summary.html" + remote = subprocess.run(["git", "-C", str(TREE), "remote", "get-url", "origin"], + capture_output=True, text=True).stdout.strip() or str(TREE) + summary_env = { + "BUILD_TAG": run_dir.name, + "REPOSITORY": remote, + "BRANCH": branch, + "PROFILE": args.profile, + "PROFILE_CUSTOM_REGEXP": args.profile_custom_regexp or "", + "ARCHITECTURE": subprocess.run(["arch"], capture_output=True, text=True).stdout.strip(), + "JDK": args.jdk or "all supported", + "DTEST_REPOSITORY": args.dtest_repository or "", + "DTEST_BRANCH": args.dtest_branch or "", + } + env = dict(os.environ) + env["cassandra_dir"] = str(summary_cell_dir) + env["m2_dir"] = str(args.m2_dir) + # newline-separated: _docker_run.sh parses these into properly quoted --env flags + env["docker_envs"] = "\n".join(f"{k}={v}" for k, v in summary_env.items()) + for key in ("JENKINS_URL", "NODE_NAME"): + env.pop(key, None) + rc = subprocess.run( + [str(summary_cell_dir / ".build" / "docker" / "_docker_run.sh"), + "bullseye-build.docker", "ci/generate-ci-summary.sh"], + cwd=summary_cell_dir, env=env).returncode + ci_summary = run_dir / "ci_summary.html" + if rc == 0 and in_container_summary.is_file(): + shutil.copy(str(in_container_summary), str(ci_summary)) + print(f"CI summary saved as {ci_summary}") + else: + print(f"WARNING: generate-ci-summary.sh exited {rc} (no ci_summary.html produced)") + + # results_details.tar.xz – same as the Jenkinsfile (the per-target html reports) + details = run_dir / "results_details.tar.xz" + with tarfile.open(details, "w:xz") as tar: + for reports in sorted(container_results.parent.glob("reports/*")): + tar.add(reports, arcname=f"reports/{reports.name}") + print(f"Details file saved as {details}") + print("(attach ci_summary….html and results_details….tar.xz to the JIRA ticket)") + return ci_summary, container_results + + +def print_console_summary(run_dir, results_dir, ci_summary): + print("\n--- Build Summary ---") + totals, files = count_results(results_dir) + if files: + passed = totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"] + print(f"{passed} passed, {totals['failures'] + totals['errors']} failed, " + f"{totals['skipped']} skipped, {totals['tests']} total, {files} test file(s)") + else: + print("No test results were found.") + if ci_summary is not None: + print(f"Full summary: {ci_summary}") + print(f"Logs: {run_dir / 'logs'}") + print(f"Results: {run_dir / 'test-results'}") + + +# ---------------------------------------------------------------------------- +# main +# ---------------------------------------------------------------------------- + +def main(): + args = parse_arguments() + + for cmd in ("rsync", "bc", "xz", "git"): + if not which(cmd): + fail(f"{cmd} must be installed and available in the PATH") + if not which("docker"): + if args.dry_run: + print("WARNING: docker is not in PATH – it will be required for a real run") + else: + fail("docker must be installed and available in the PATH") + if not BUILD_XML.is_file(): + fail(f"{BUILD_XML} not found – is this a cassandra checkout?") + + # the dockerised CI scripts are vendored into this tree (copied from the cassandra-5.0 branch) + if not (TREE / ".build" / "docker" / "run-tests.sh").is_file(): + fail(f"{TREE / '.build' / 'docker' / 'run-tests.sh'} not found – the vendored CI scripts are missing") + + # java versions (build.xml defines java.default / java.supported) + supported_jdks = (read_build_xml_property("java.supported") or "8,11").split(",") + default_jdk = read_build_xml_property("java.default") or supported_jdks[0] + args._default_jdk = default_jdk + + steps = select_steps(args) + jar_cells, cells = build_plan(args, steps, supported_jdks) + print(f"\nProfile: {args.profile}" + (f" (custom: {args.profile_custom_regexp})" if args.profile == "custom" else "")) + print(f"JDKs: {supported_jdks} (default {default_jdk}), arch: {subprocess.run(['arch'], capture_output=True, text=True).stdout.strip()}") + print(plan_summary_text(jar_cells, cells)) + + if args.dry_run: + return + + if which("docker") and run_quiet(["docker", "info"]).returncode != 0: + fail("docker needs to be running") + + run_dir = CI_LOCAL_BASE / f"run-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}" + run_dir.mkdir(parents=True, exist_ok=True) + Path(args.m2_dir).mkdir(parents=True, exist_ok=True) + print(f"\nRun directory: {run_dir}") + + # the heaviest cells (dtest, simulator-dtest, microbench) limit their containers to 15GiB, + # so assume at most 16GiB per parallel cell when sizing the default concurrency + mem = host_mem_gib() + if args.concurrency: + concurrency = args.concurrency + if mem and mem < concurrency * 16: + print(f"WARNING: {concurrency} parallel cells x up to 16GiB container memory limit against " + f"~{mem:.0f}GiB host memory – consider a lower -n/--concurrency") + else: + concurrency = min(4, max(1, host_cpus() // 4)) + if mem: + concurrency = max(1, min(concurrency, int(mem // 16))) + print(f"Running up to {concurrency} cells in parallel " + f"(auto: {host_cpus()} cpus" + (f", ~{mem:.0f}GiB memory" if mem else "") + ")") + + summary_cell_dir = None + warmup_docker_images(args) + + # the python dtest steps need the dtest repo; jvm-dtest-upgrade additionally needs it to build the dtest jars + dtest_needed = any(c.kind == "test" and (c.step.startswith("dtest") or c.step == "jvm-dtest-upgrade") for c in cells) + dtest_dir = clone_dtest_repo(run_dir, args.dtest_repository, args.dtest_branch) if dtest_needed else None + + # phase 1: jars, one workspace per jdk (Jenkinsfile 'jar' stage) + jar_results = run_pool(jar_cells, "jar stage", run_dir, {}, Path(args.m2_dir), dtest_dir, args, concurrency) + jar_cells_by_jdk = {c.jdk: c for c in jar_cells if jar_results.get(c.name)} + failed_jdks = [c.jdk for c in jar_cells if not jar_results.get(c.name)] + if failed_jdks: + print(f"WARNING: jar builds failed for jdk {failed_jdks}; their cells will be skipped") + + # phase 2: build the dtest jars for jvm-dtest-upgrade (Jenkinsfile buildJVMDTestJars) + if any(c.step == "jvm-dtest-upgrade" for c in cells) and args._default_jdk in jar_cells_by_jdk: + base = Cell("build-dtest-jars", "build_dtest_jars", "build_dtest_jars", jdk=args._default_jdk) + base_results = run_pool([base], "build dtest jars", run_dir, jar_cells_by_jdk, + Path(args.m2_dir), dtest_dir, args, concurrency) + if not base_results.get(base.name): + print("WARNING: build_dtest_jars failed – jvm-dtest-upgrade cells will likely fail") + + # phase 3: all build and test cells (Jenkinsfile 'Tests' stage) + test_results = run_pool(cells, "tests stage", run_dir, jar_cells_by_jdk, + Path(args.m2_dir), dtest_dir, args, concurrency) + + # results + summary (Jenkinsfile 'Summary' stage) + results_dir = organise_test_results(run_dir, cells) + ci_summary, final_results_dir = None, results_dir + if args._default_jdk in jar_cells_by_jdk: + summary_cell_dir = run_dir / "summary" + make_cell_workspace(summary_cell_dir, + run_dir / "cells" / jar_cells_by_jdk[args._default_jdk].name) + ci_summary, final_results_dir = generate_summary( + run_dir, results_dir, args, summary_cell_dir, + subprocess.run(["git", "-C", str(TREE), "branch", "--show-current"], + capture_output=True, text=True).stdout.strip() or "local") + else: + print("\nSkipping summary generation: the default jdk jar build failed") + print_console_summary(run_dir, final_results_dir, ci_summary) + + failed = [name for name, ok in test_results.items() if not ok] + \ + [f"jar-{jdk}" for jdk in failed_jdks] + if failed: + print(f"\nBUILD FAILED – {len(failed)} failed cell(s):") + for name in sorted(failed): + print(f" {name} (log: {run_dir / 'logs' / (name + '.log')})") + exit_code = 1 + else: + print("\nBUILD SUCCESSFUL – all cells passed") + exit_code = 0 + + # cleanup the bulk (cell workspaces are the only big thing), keep logs/results/artefacts + if not args.keep_workspace: + shutil.rmtree(run_dir / "cells", ignore_errors=True) + if summary_cell_dir is not None: + shutil.rmtree(summary_cell_dir, ignore_errors=True) + print(f"Removed cell workspaces (kept {run_dir} with logs, results and artefacts)") + if not sys.exc_info()[0]: + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/.build/run-ci.d/README.md b/.build/run-ci.d/README.md new file mode 100644 index 000000000000..d5af7aa6f7ac --- /dev/null +++ b/.build/run-ci.d/README.md @@ -0,0 +1,103 @@ +# Help for `.build/run-ci` + +``` +➤ .build/run-ci --help +usage: run-ci [-h] [-c KUBECONFIG] [-x KUBECONTEXT] [-i URL] [-u USER] [-r REPOSITORY] [-b BRANCH] [-p {packaging,skinny,pre-commit,pre-commit w/ upgrades,post-commit,custom}] [-e PROFILE_CUSTOM_REGEXP] [-j JDK] + [-t REPEAT_TEST_REGEX] [-n REPEAT_COUNT] [--repeat-stop-on-failure] [-m REPEAT_MACHINES] [-d DTEST_REPOSITORY] [-k DTEST_BRANCH] [-s] [--only-setup] [-v VALUES_OVERRIDE] + [--tear-down] [--only-tear-down] [--only-node-cleaner] [-o DOWNLOAD_RESULTS] + +Run CI pipeline for Cassandra on K8s using Jenkins. + +options: + -h, --help show this help message and exit + -c KUBECONFIG, --kubeconfig KUBECONFIG + Path to a different kubeconfig. + -x KUBECONTEXT, --kubecontext KUBECONTEXT + Use a different Kubernetes context. + -i URL, --url URL Jenkins url. Suitable when kubectl access in not available. Can also be specified via the JENKINS_URL environment variable (and in .build/.run-ci.env) + -u USER, --user USER Jenkins user. Can also be specified via the JENKINS_USER environment variable (and in .build/.run-ci.env) + -r REPOSITORY, --repository REPOSITORY + Repository URL. Defaults to current tracking remote. + -b BRANCH, --branch BRANCH + Repository branch. Defaults to current branch. + -p {packaging,skinny,pre-commit,pre-commit w/ upgrades,post-commit,custom}, --profile {packaging,skinny,pre-commit,pre-commit w/ upgrades,post-commit,custom} + CI pipeline profile. Defaults to skinny. + -e PROFILE_CUSTOM_REGEXP, --profile-custom-regexp PROFILE_CUSTOM_REGEXP + Regexp for stages when using custom profile. See `testSteps` in Jenkinsfile for list of stages. Example: 'stress.*|jvm-dtest.' + -j JDK, --jdk JDK Specify JDK version. Defaults to all JDKs the current branch supports. + -t REPEAT_TEST_REGEX, --repeat-test-regex REPEAT_TEST_REGEX + Test name regexp (csv list) to run repeatedly via the *-repeat stages. Requires -p custom and -e selecting a *-repeat stage. Example: 'HostReplacementTest' + -n REPEAT_COUNT, --repeat-count REPEAT_COUNT + How many times to run the *-repeat stages. Example: 200 + --repeat-stop-on-failure + Stop a *-repeat stage on the first failed run (default: run all iterations and report the failure rate) + -m REPEAT_MACHINES, --repeat-machines REPEAT_MACHINES + Number of machines that each run the full set of repeated test iterations in parallel (default 1). Example: 4 + -d DTEST_REPOSITORY, --dtest-repository DTEST_REPOSITORY + DTest repository URL. + -k DTEST_BRANCH, --dtest-branch DTEST_BRANCH + DTest repository branch. + -s, --setup Set up Jenkins before the build. + --only-setup Only install Jenkins into the k8s cluster. + -v VALUES_OVERRIDE, --values-override VALUES_OVERRIDE + Path to an additional helm values file, applied over .jenkins/k8s/jenkins-deployment.yaml. Required when the target cluster carries site customisations, see .jenkins/k8s/README.md + --tear-down Tear down Jenkins after the build. + --only-tear-down Only tear down Jenkins. + --only-node-cleaner Only run the node cleaner. The node cleaner scans the k8s nodes, eagerly terminating those unused. + -o DOWNLOAD_RESULTS, --download-results DOWNLOAD_RESULTS + Just download the results for the specificed build number. Naming of local artefacts assumes current tracking remote and branch, use -r and -b otherwise. +``` + +## Examples +Run the current directory's fork and branch through the default "skinny" pipeline, connecting via your default kubeconfig +``` +.build/run-ci +``` + +Do the same but connecting via a jenkins url +``` +.build/run-ci --url pre-ci.cassandra.apache.org --user myuser +``` + +Run the the specified fork and branch through the "skinny" pipeline restricted to tests on jdk11 +``` +.build/run-ci -r "https://github.com/jrwest/cassandra.git" -b "jwest/15452-5.0" -p "skinny" -j 11 +``` + +Run the the specified fork and branch through just the "fqltool-test" tests +``` +.build/run-ci -r "https://github.com/jrwest/cassandra.git" -b "jwest/15452-5.0" -p "custom" -e "fqltool-test" +``` + +Run a single test 200 times to hunt for a flake (custom profile, `jvm-dtest-repeat` stage; `test-repeat` for unit tests) +``` +.build/run-ci -r "https://github.com/jrwest/cassandra.git" -b "jwest/15452-5.0" -p "custom" -e "jvm-dtest-repeat" -t "HostReplacementTest" -n 200 +``` + +The same but aborting on the first failed run +``` +.build/run-ci -r "https://github.com/jrwest/cassandra.git" -b "jwest/15452-5.0" -p "custom" -e "jvm-dtest-repeat" -t "HostReplacementTest" -n 200 --repeat-stop-on-failure +``` + +The same but on 4 machines, each running all 200 iterations in parallel (4x the samples, same wall-clock time) +``` +.build/run-ci -r "https://github.com/jrwest/cassandra.git" -b "jwest/15452-5.0" -p "custom" -e "jvm-dtest-repeat" -t "HostReplacementTest" -n 200 -m 4 +``` + +Setup/Update Jenkins Helm into your current kubeconfig +``` +.build/run-ci --only-setup +``` + +Setup/Update Jenkins Helm into a cluster that carries site customisations, e.g. pre-ci.cassandra.apache.org +``` +.build/run-ci --only-setup --values-override ~/.cassandra-ci/pre-ci-overrides.yaml +``` + +Before any setup, the values already deployed are compared against those about to be applied. Any value the deployed jenkins holds that the new files lack is listed, and confirmation is asked for before it is dropped; running non-interactively aborts instead. See `.jenkins/k8s/README.md` for what this can and cannot catch. + +Uninstall Jenkins from your current kubeconfig. +``` +.build/run-ci --only-tear-down +``` +The jenkins-home volume is kept; delete it separately with `kubectl delete pvc cassius-jenkins` \ No newline at end of file diff --git a/.build/run-ci.d/requirements.txt b/.build/run-ci.d/requirements.txt new file mode 100644 index 000000000000..ff11accd3a86 --- /dev/null +++ b/.build/run-ci.d/requirements.txt @@ -0,0 +1,26 @@ +# 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. + +bs4 +dotenv +kubernetes +python-jenkins +pyyaml +requests + +# optional for different clouds +boto3 +google-cloud-compute diff --git a/.build/run-ci.d/run-ci-test.py b/.build/run-ci.d/run-ci-test.py new file mode 100644 index 000000000000..a5e697067fc7 --- /dev/null +++ b/.build/run-ci.d/run-ci-test.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# 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. +# +# Used to test `.build/run-ci` +# Run with `python .build/run-ci.d/run-ci-test.py` +# +# +# lint with: +# `pylint --disable=C0301,W0511,C0114,C0103,W0702,C0415,C0116,C0115,R0914,W0603,R0915,R0913,R0911 run-ci-test.py` + + +import argparse +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch, MagicMock +import yaml + + +# Import the functions from the script +from run_ci import ( + DEPLOY_YAML, + check_agent_capacity, + debug, + install_jenkins, + get_jenkins, + trigger_jenkins_build, + spin_while, + delete_remote_junit_files, + cleanup_and_maybe_teardown, + helm_installation_lock, +) + +class TestCIPipeline(unittest.TestCase): + + def setUp(self): + print("\ntesting ", self._testMethodName) + + @patch('run_ci.os.environ.get') + @patch('run_ci.print') + def test_debug(self, mock_print, mock_get): + mock_get.return_value = "1" + debug("Test message") + mock_print.assert_called_with("Test message") + + # the pre-flight check is nested inside install_jenkins, so it is exercised through it: the mocked + # `helm get values` stdout stands in for the values the site already has deployed + LIVE_STORAGE_CLASS = "persistence:\n storageClass: gp2\n" + + @patch('run_ci.subprocess.run') + def test_install_jenkins(self, mock_run): + # empty stdout, i.e. nothing deployed yet, so the pre-flight check has nothing to warn about + mock_run.return_value = MagicMock(returncode=0, stdout="") + install_jenkins("test-namespace", Path("/fake/cassandra/dir"), "default") + mock_run.assert_any_call(["helm", "repo", "add", "jenkins", "https://charts.jenkins.io"], check=True) + mock_run.assert_any_call(["helm", "repo", "update"], check=True) + + @patch('run_ci.subprocess.run') + def test_install_jenkins_values_override(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="") + with tempfile.NamedTemporaryFile("w", suffix=".yaml") as override: + override.write(self.LIVE_STORAGE_CLASS) + override.flush() + install_jenkins(None, None, "default", override.name) + upgrade_cmd = [c for c in [call.args[0] for call in mock_run.call_args_list] if "upgrade" in c][0] + # the site's overrides must come after, and never replace, the repo's deployment yaml + self.assertEqual(["-f", DEPLOY_YAML, "-f", override.name], upgrade_cmd[5:9]) + + @patch('run_ci.sys.stdin.isatty') + @patch('run_ci.print') + @patch('run_ci.subprocess.run') + def test_install_jenkins_aborts_non_interactively(self, mock_run, mock_print, mock_isatty): + mock_isatty.return_value = False + mock_run.return_value = MagicMock(returncode=0, stdout=self.LIVE_STORAGE_CLASS) + with self.assertRaises(SystemExit): + install_jenkins(None, None, "default") + self.assertEqual([], [c for c in [call.args[0] for call in mock_run.call_args_list] if "upgrade" in c]) + # and continues when the customisation is passed back in as an override. This also proves the merge is + # per key: were the override to replace the whole persistence map, its other keys would now be reported lost + with tempfile.NamedTemporaryFile("w", suffix=".yaml") as override: + override.write(self.LIVE_STORAGE_CLASS) + override.flush() + install_jenkins(None, None, "default", override.name) + self.assertEqual(1, len([c for c in [call.args[0] for call in mock_run.call_args_list] if "upgrade" in c])) + + @patch('run_ci.input') + @patch('run_ci.sys.stdin.isatty') + @patch('run_ci.print') + @patch('run_ci.subprocess.run') + def test_install_jenkins_prompts(self, mock_run, mock_print, mock_isatty, mock_input): + mock_isatty.return_value = True + mock_run.return_value = MagicMock(returncode=0, stdout=self.LIVE_STORAGE_CLASS) + mock_input.return_value = "n" + with self.assertRaises(SystemExit): + install_jenkins(None, None, "default") + mock_input.return_value = "y" + install_jenkins(None, None, "default") + + @patch('run_ci.sys.stdin.isatty') + @patch('run_ci.print') + @patch('run_ci.subprocess.run') + def test_install_jenkins_reports_only_detectable_losses(self, mock_run, mock_print, mock_isatty): + mock_isatty.return_value = False + # a plugin only this site installs is reported, as is a key the site alone holds; a key held in both but + # locally edited (persistence.size) cannot be seen, and must not be claimed + mock_run.return_value = MagicMock(returncode=0, stdout="persistence:\n size: 1Ti\n" + "controller:\n installPlugins:\n - site-only-plugin\n") + with self.assertRaises(SystemExit): + install_jenkins(None, None, "default") + printed = " ".join(str(call.args[0]) for call in mock_print.call_args_list if call.args) + self.assertIn("controller.installPlugins[]", printed) + self.assertIn("site-only-plugin", printed) + self.assertNotIn("persistence.size", printed) + + @patch('run_ci.print') + @patch('run_ci.subprocess.run') + def test_install_jenkins_when_nothing_deployed(self, mock_run, mock_print): + # `helm get values` fails when there is no release, and nothing is then warned about + mock_run.side_effect = lambda cmd, **kwargs: MagicMock(returncode=1 if "get" in cmd else 0, + stdout="", stderr="release: not found") + install_jenkins(None, None, "default") + self.assertEqual([], [call.args[0] for call in mock_print.call_args_list if "WARNING" in str(call.args)]) + + @patch('run_ci.subprocess.run') + @patch('run_ci.jenkins.Jenkins') + def test_get_jenkins(self, mock_jenkins, mock_run): + mock_k8s_client = MagicMock() + mock_run.return_value = MagicMock(stdout="fake-password") + mock_jenkins_instance = MagicMock() + mock_jenkins.return_value = mock_jenkins_instance + # hack – use False values instead of None + args = argparse.Namespace(kubeconfig="/fake/kubeconfig", kubecontext="test-context", user=False, url=False) + _, server = get_jenkins(mock_k8s_client, args, "default") + self.assertEqual(server, mock_jenkins_instance) + + @patch('run_ci.jenkins.Jenkins.build_job') + @patch('run_ci.wait_for_build_number') + def test_trigger_jenkins_build(self, mock_wait_for_build_number, mock_build_job): + mock_server = MagicMock() + mock_build_job.return_value = mock_server.build_job.return_value = 123 + mock_wait_for_build_number.return_value = 456 + with patch('run_ci.spin_while', side_effect=lambda msg, condition: 0): + queue_item = trigger_jenkins_build(mock_server, "test-job", param1="value1") + self.assertEqual(queue_item, 123) + + def test_spin_while(self): + result = spin_while("Testing", lambda: True) + self.assertEqual(result, 0) + + @patch('run_ci.stream.stream') + def test_delete_remote_junit_files(self, mock_stream): + mock_k8s_client = MagicMock() + delete_remote_junit_files(mock_k8s_client, "test-pod", "test-namespace", "test-job", 456) + mock_stream.assert_called() + + @patch('run_ci.subprocess.run') + def test_cleanup_and_maybe_teardown(self, mock_run): + cleanup_and_maybe_teardown(None, None, "test-namespace", True) + mock_run.assert_called_with(["helm", "--namespace", "test-namespace", "uninstall", "cassius"], + capture_output=False, text=True, check=True) + + @patch('run_ci.fcntl.flock') + def test_helm_installation_lock(self, mock_flock): + with helm_installation_lock(Path("/tmp/.fake.lock")): + mock_flock.assert_called() + + LARGE_NODE = ('{"items":[{"metadata":{"labels":{"eks.amazonaws.com/nodegroup":"amd64-large-ondemand-2",' + '"cassandra.jenkins.agent":"true","cassandra.jenkins.agent.large":"true"}}}]}') + + @staticmethod + def ca_status(nested: bool = True) -> str: + """ + The autoscaler's status configmap, holding the live cluster's node groups and maximums. + + maxSize is the only in-cluster record of what a pool can hold, and a pool at zero nodes has no + nodes to count, so the check reads it from here. + """ + groups = [(f"eks-amd64-{size}-ondemand-{n}-{n}cfd1c1", 0, maximum) + for size, maximum in (("large", 80), ("medium", 65), ("small", 25)) for n in (2, 3)] + + groups.append(("eks-jenkins-controller-0-2acd8787", 1, 1)) + return yaml.safe_dump({"nodeGroups": [ + {"name": name, **({"health": {"minSize": minimum, "maxSize": maximum}} if nested + else {"minSize": minimum, "maxSize": maximum})} + for name, minimum, maximum in groups]}) + + def capacity_check(self, values: dict, nodes: str = '{"items":[]}', autoscaler: bool = True, + nested: bool = True): + """ + Runs check_agent_capacity against the autoscaler ceilings above, returning the exit code or 0. + + `autoscaler=False` stands in for a cluster whose ceilings cannot be read at all, a managed + autoscaler that publishes no status configmap for instance, where kubectl exits non-zero. + """ + def kubectl(_kubeconfig, _kubecontext, _ns, command): + if "nodes" in command: + return nodes + if not autoscaler: + raise subprocess.CalledProcessError(1, "kubectl", stderr="configmaps not found") + return self.ca_status(nested) + with patch('run_ci.run_kubectl_command', kubectl): + try: + check_agent_capacity(None, None, "default", values) + return 0 + except SystemExit as e: + return e.code + + def deployed_values(self, size: str = None, **overrides) -> dict: + """The committed values, optionally with one podTemplate's keys replaced.""" + with open(DEPLOY_YAML, encoding="utf-8") as deploy_yaml: + values = yaml.safe_load(deploy_yaml) + if size: + template = yaml.safe_load(values["agent"]["podTemplates"][f"agent-dind-{size}"]) + template[0].update(overrides) + values["agent"]["podTemplates"][f"agent-dind-{size}"] = yaml.safe_dump(template) + return values + + def test_check_agent_capacity_allows_the_committed_values(self): + self.assertEqual(0, self.capacity_check(self.deployed_values())) + self.assertEqual(0, self.capacity_check(self.deployed_values(), nodes=self.LARGE_NODE)) + + def test_check_agent_capacity_blocks_a_cap_above_the_pool(self): + # 200 against the 160 nodes two large groups can hold: 40 agents could never be scheduled, which is + # not idle but a churn loop, and is what preceded the 2026-08-11 controller stall + over = self.deployed_values("large", instanceCap=200, instanceCapStr="200") + self.assertEqual(1, self.capacity_check(over)) + # a cluster whose ceilings cannot be read leaves it unchecked rather than blocking a valid deploy + self.assertEqual(0, self.capacity_check(over, autoscaler=False)) + + def test_check_agent_capacity_reads_maxsize_wherever_it_is_published(self): + # the live cluster nests a group's maximum under its health condition, and the check also takes it + # from the group. Reading the wrong key costs nothing visible: the ceilings come out empty and + # every cap passes unchecked, so the shapes are pinned here rather than in a deploy + over = self.deployed_values("large", instanceCap=200, instanceCapStr="200") + for nested in (True, False): + self.assertEqual(1, self.capacity_check(over, nested=nested)) + self.assertEqual(0, self.capacity_check(self.deployed_values(), nested=nested)) + + def test_check_agent_capacity_blocks_contradictory_config(self): + # the plugin takes the cap from either key, so a disagreement resolves to whichever applies last + self.assertEqual(1, self.capacity_check(self.deployed_values("large", instanceCapStr="200"))) + # a nodeSelector the live nodes contradict strands every agent of that size + typo = self.deployed_values("large", nodeSelector="cassandra.jenkins.agent.large=ture") + self.assertEqual(1, self.capacity_check(typo, nodes=self.LARGE_NODE)) + # unconfirmable is not the same as contradicted: with that pool at zero there is nothing to check against + self.assertEqual(0, self.capacity_check(typo)) + +if __name__ == '__main__': + unittest.main() diff --git a/.build/run-ci.d/run_ci.py b/.build/run-ci.d/run_ci.py new file mode 100755 index 000000000000..c8156a85190c --- /dev/null +++ b/.build/run-ci.d/run_ci.py @@ -0,0 +1,1203 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# 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. +""" +CI Pipeline Script + +This script can initialize a Jenkins operator in a Kubernetes cluster, +start ci job builds, and retrieve results in the project standard format. +Python dependencies are found in .build/run-ci.d/requirements.txt +Custom environment variables can be set in .build/.run-ci.env + +lint with: + `pylint --disable=C0301,C0302,W0511,C0103,W0702,C0415,C0116,C0115,R0914,W0603,R0915,R0913,R0917,R0911,W0212,W0621 run-ci` + +test with: + `python run-ci.d/run-ci-test.py` +""" + +import argparse +import fcntl +import getpass +import gzip +import itertools +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import tarfile +import threading +import time +from contextlib import contextmanager +from enum import Enum +from pathlib import Path +from urllib.request import urlretrieve +from typing import Optional, Tuple + +# External Libraries (`pip install -r .build/run-ci.d/requirements.txt`) +import requests +import yaml +from bs4 import BeautifulSoup +from kubernetes import client, config, stream + +try: + import jenkins +except OSError as import_jenkins_error: + if 'lookup3.so' in str(import_jenkins_error): + print("Error: The required shared library 'lookup3.so' is missing.") + print("Please ensure it is installed and accessible in your environment.") + sys.exit(1) + else: + raise + +def base_job_name(args) -> str: + """ + Determines the default Jenkins job name based on the Cassandra version. + Separate jobs are required because Jenkinsfiles are baked into the job configuration. + ref: .jenkins/k8s/jenkins-deployment.yaml JCasC.configScripts.test-job + """ + if not hasattr(base_job_name, "_cached_result"): + raw_url = args.repository.replace("https://github.com/", "https://raw.githubusercontent.com/").removesuffix(".git") + f"/{args.branch}/build.xml" + if 200 != requests.head(raw_url, timeout=30).status_code: + raise ValueError(f"GitHub unavailable, or this branch has not been pushed yet: {args.repository} @ {args.branch} (or remote tracking not setup up: `git config --get branch.{args.branch}.remote` and `git config --get branch.{args.branch}.merge`)") + response = requests.get(raw_url, timeout=30) + response.raise_for_status() + for line in response.text.splitlines(): + if 'property' in line and 'name="base.version"' in line: + version = line.split('value="')[1].split('"')[0] + # TODO: add new version each release branching + if version.startswith("5.0."): + base_job_name._cached_result = "cassandra-5.0" + else: + base_job_name._cached_result = "cassandra" + break + return base_job_name._cached_result + +def get_current_branch() -> str: + """Returns the current branch.""" + return subprocess.run(["git", "-C", str(CASSANDRA_DIR), "branch", "--show-current"], + capture_output=True, text=True, check=True).stdout.strip() + +def require_tracking_remote(branch: str): + """ Exits when the branch tracks no remote, since nothing can then be inferred about what to build. """ + if 0 == subprocess.run(["git", "-C", str(CASSANDRA_DIR), "rev-parse", "--abbrev-ref", f"{branch}@{{u}}"], + capture_output=True, text=True, check=False).returncode: + return + print(f"Branch {branch} tracks no remote, so the fork and branch to build cannot be detected.") + print("\nEither set the tracking up, which git will do on the first push of every new branch:") + print(" git config --global push.autoSetupRemote true") + print(f" or for this branch alone: `git push --set-upstream {branch}`") + print("\nOr name what to build explicitly, with -r/--repository and -b/--branch.") + sys.exit(1) + +def is_local_git_dirty(args) -> bool: + """Returns True if there are uncommitted/unpushed changes in the local git repository.""" + # use base_job_name to verify the remote branch exists + base_job_name(args) + # check if the working directory is clean + clean = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "diff-index", "--quiet", "HEAD", "--"], check=False).returncode + # `@{u}` resolves to nothing without tracking, which would read as nothing unpushed. Callers reach here + # only past require_tracking_remote, but report dirty on failure rather than depend on that invariant + unpushed = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "log", "@{u}..HEAD", "--name-only"], + capture_output=True, text=True, check=False) + return 0 != clean or 0 != unpushed.returncode or bool(unpushed.stdout.strip()) + +def get_tracking_remote_url() -> Optional[str]: + """ Returns the tracking remote URL of the current branch, or None when the branch tracks nothing. """ + remote = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "config", "--get", f"branch.{DEFAULT_REPO_BRANCH}.remote"], + capture_output=True, text=True, check=False) + + if 0 != remote.returncode: + return None + + repo_url = subprocess.run(["git", "-C", str(CASSANDRA_DIR), "remote", "get-url", remote.stdout.strip()], + capture_output=True, text=True, check=True).stdout.strip() + if repo_url.startswith("git@github.com:"): + repo_url = repo_url.replace("git@github.com:", "https://github.com/") + + # and change gitbox to github + return repo_url.replace("https://gitbox.apache.org/repos/asf/cassandra.git", "https://github.com/apache/cassandra.git") + +# Constants +DEFAULT_KUBE_NS = "default" +CASSANDRA_DIR = Path(__file__).resolve().parent.parent +DEPLOY_YAML = str(CASSANDRA_DIR / ".jenkins/k8s/jenkins-deployment.yaml") +DEFAULT_REPO_BRANCH = get_current_branch() +DEFAULT_REPO_URL = get_tracking_remote_url() +DEFAULT_DTEST_REPO_URL = "https://github.com/apache/cassandra-dtest.git" +DEFAULT_DTEST_REPO_BRANCH = "trunk" +DEFAULT_PROFILE = "skinny" +DEFAULT_POD_NAME = "cassius-jenkins-0" +DEFAULT_CONTAINER_NAME = "jenkins" +LOCAL_RESULTS_BASEDIR = CASSANDRA_DIR / "build/ci/" +# AWS/GCloud specifics for node_cleaner function, needed for node_cleaner +AWS_REGION = os.environ.get("AWS_REGION") +GCP_PROJECT_ID = os.environ.get("GCP_PROJECT_ID") +GCP_ZONE = os.environ.get("GCP_ZONE") + +IS_RUNNING = True + + +def debug(message: str): + """Helper function to print debug messages.""" + if os.environ.get("DEBUG"): + print(message) + + +def load_environment_file(): + """ Load environment variables from a .build/.run-ci.env file. """ + try: + from dotenv import load_dotenv + load_dotenv(dotenv_path=CASSANDRA_DIR / ".build" / ".run-ci.env") + except: + print("Warning: .build/run-ci.env file not found, or dotenv module not installed.") + +def setup_environment(kubeconfig, kubecontext) -> client.CoreV1Api: + """Ensures necessary tools are installed and sets up Kubernetes configuration.""" + # Check Python version + required_version = (3, 7) + if sys.version_info < required_version: + raise EnvironmentError(f"Python {required_version[0]}.{required_version[1]} or higher is required. " + f"Current version is {sys.version_info.major}.{sys.version_info.minor}.") + # check command line dependencies + dependencies = ["helm", "kubectl"] + for cmd in dependencies: + if not shutil.which(cmd): + raise EnvironmentError(f"{cmd} must be installed and available in the PATH.") + + # Initialize Kubernetes client and API instance + config.load_kube_config(config_file=kubeconfig if kubeconfig else None, context=kubecontext or None) + return client.CoreV1Api() + +def argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run CI pipeline for Cassandra on K8s using Jenkins.") + parser.add_argument("-c", "--kubeconfig", help="Path to a different kubeconfig.") + parser.add_argument("-x", "--kubecontext", help="Use a different Kubernetes context.") + parser.add_argument("-i", "--url", help="Jenkins url. Suitable when kubectl access in not available. Can also be specified via the JENKINS_URL environment variable (and in .build/.run-ci.env)") + parser.add_argument("-u", "--user", help="Jenkins user. Can also be specified via the JENKINS_USER environment variable (and in .build/.run-ci.env)") + parser.add_argument("-r", "--repository", default=DEFAULT_REPO_URL, help="Repository URL. Defaults to current tracking remote.") + parser.add_argument("-b", "--branch", default=DEFAULT_REPO_BRANCH, help="Repository branch. Defaults to current branch.") + parser.add_argument("-p", "--profile", choices=['packaging','skinny','pre-commit','pre-commit w/ upgrades','post-commit','custom'], default=DEFAULT_PROFILE, help="CI pipeline profile. Defaults to skinny.") + parser.add_argument("-e", "--profile-custom-regexp", help="Regexp for stages when using custom profile. See `testSteps` in Jenkinsfile for list of stages. Example: 'stress.*|jvm-dtest.'") + parser.add_argument("-j", "--jdk", help="Specify JDK version. Defaults to all JDKs the current branch supports.") + parser.add_argument("-t", "--repeat-test-regex", help="Test name regexp (csv list) to run repeatedly via the *-repeat stages. Requires -p custom and -e selecting a *-repeat stage. Example: 'HostReplacementTest'") + parser.add_argument("-n", "--repeat-count", help="How many times to run the *-repeat stages. Example: 200") + parser.add_argument("--repeat-stop-on-failure", action="store_true", help="Stop a *-repeat stage on the first failed run (default: run all iterations and report the failure rate)") + parser.add_argument("-m", "--repeat-machines", default="1", help="Number of machines that each run the full set of repeated test iterations in parallel (default 1). Example: 4") + parser.add_argument("-d", "--dtest-repository", default=DEFAULT_DTEST_REPO_URL, help="DTest repository URL.") + parser.add_argument("-k", "--dtest-branch", default=DEFAULT_DTEST_REPO_BRANCH, help="DTest repository branch.") + parser.add_argument("-s", "--setup", action="store_true", help="Set up Jenkins before the build.") + parser.add_argument("--only-setup", action="store_true", help="Only install Jenkins into the k8s cluster.") + parser.add_argument("-f", "--values-override", help="Path to an additional helm values file, applied over .jenkins/k8s/jenkins-deployment.yaml. Required when the target cluster carries site customisations, see .jenkins/k8s/README.md") + parser.add_argument("--tear-down", action="store_true", help="Tear down Jenkins after the build.") + parser.add_argument("--only-tear-down", action="store_true", help="Only tear down Jenkins.") + parser.add_argument("--only-node-cleaner", action="store_true", help="Only run the node cleaner. The node cleaner scans the k8s nodes, eagerly terminating those unused.") + parser.add_argument("-o", "--download-results", help="Just download the results for the specificed build number. Naming of local artefacts assumes current tracking remote and branch, use -r and -b otherwise.") + return parser + +def parse_arguments() -> argparse.Namespace: + """ + Parses command-line arguments and sets environment variables based on inputs. + If you update this please also update `.build/run-ci.d/README.md` + """ + args = argument_parser().parse_args() + + # -r defaults to the branch's tracking remote, which is absent when the branch tracks nothing. Only the + # flows that build, or that name artefacts after a build, need it: installing and tearing down do not, + # so an untracked branch can still deploy the cluster. + if not args.repository and not (args.only_setup or args.only_tear_down or args.only_node_cleaner): + require_tracking_remote(args.branch) + + assert not args.repository or (args.repository.startswith("https://github.com/") + and args.repository.removesuffix(".git").endswith("cassandra")),\ + f"Only github apache/cassandra (forked) repository supported, got: {args.repository}" + assert args.dtest_repository.startswith("https://github.com/") and args.dtest_repository.removesuffix(".git").endswith("cassandra-dtest"),\ + f"Only github apache/cassandra-dtest (forked) repository supported, got: {args.dtest_repository}" + assert not (args.setup and args.only_setup), "Both --setup or --only-setup cannot be specified." + assert not (args.tear_down and args.only_tear_down), "Both --tear-down or --only-tear-down cannot be specified." + assert not ("custom" == args.profile and not args.profile_custom_regexp), "Custom profile requires --profile-custom-regexp." + assert re.fullmatch(r"[1-9][0-9]*", args.repeat_machines or ""), "--repeat-machines must be a positive integer." + repeat_options_used = args.repeat_test_regex or args.repeat_count or args.repeat_stop_on_failure or args.repeat_machines != "1" + repeat_stages_selected = args.profile == "custom" and any(re.fullmatch(args.profile_custom_regexp, stage) + for stage in ("test-repeat", "jvm-dtest-repeat")) + repeating_tests = repeat_options_used or repeat_stages_selected + assert not (repeating_tests and args.profile != "custom"), "Repeating tests requires --profile custom." + assert not (repeating_tests and not repeat_stages_selected), "Repeating tests requires --profile-custom-regexp selecting a *-repeat stage (see `repeatTestSteps()` in .jenkins/Jenkinsfile)." + assert not (repeating_tests and not (args.repeat_test_regex and args.repeat_count)), "Repeating tests requires both --repeat-test-regex and --repeat-count." + assert not args.repeat_count or re.fullmatch(r"[1-9][0-9]*", args.repeat_count), "--repeat-count must be a positive integer." + assert not (args.values_override and not (args.setup or args.only_setup)), "--values-override requires --setup or --only-setup." + assert not (args.values_override and not Path(args.values_override).is_file()), f"No such values override file: {args.values_override}" + + if not args.url and os.environ.get("JENKINS_URL"): + args.url = os.environ.get("JENKINS_URL") + if not args.user and os.environ.get("JENKINS_USER"): + args.user = os.environ.get("JENKINS_USER") + + assert not (args.url and (args.kubeconfig or args.kubecontext or args.setup or args.only_setup or args.tear_down or args.only_tear_down or args.only_node_cleaner)),\ + "Cannot specify both --url and any of --kubeconfig/--kubecontext/--setup/--only-setup/--tear-down/--only-tear-down/--only-node-cleaner. Setting the jenkins url implies no kubectl actions." + assert not (args.url and not args.user), "When specifying --url, --user is required." + + return args + + +def init_k8s_namespace(k8s_client, namespace: str): + """Ensures the specified namespace exists in the Kubernetes cluster.""" + try: + k8s_client.read_namespace(namespace) + debug(f"Namespace '{namespace}' already exists.") + except client.exceptions.ApiException as e: + if e.status == 404: + debug(f"Creating namespace '{namespace}'...") + ns = client.V1Namespace(metadata=client.V1ObjectMeta(name=namespace)) + k8s_client.create_namespace(ns) + print(f"Namespace '{namespace}' created.") + else: + raise + +def run_kubectl_command(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, command: list) -> str: + """ + Runs a kubectl command with the specified kubeconfig and context. + Used when functionality is not available in k8s_client. + """ + cmd = ["kubectl"] + if kubeconfig: + cmd += ["--kubeconfig", kubeconfig] + if kubecontext: + cmd += ["--context", kubecontext] + cmd += ["--namespace", kube_ns] + cmd += command + return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip() + +def run_helm_command(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, command: list, + capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess: + """Runs a helm command with the specified kubeconfig, context and namespace.""" + cmd = ["helm"] + if kubeconfig: + cmd += ["--kubeconfig", kubeconfig] + if kubecontext: + cmd += ["--kube-context", kubecontext] + cmd += ["--namespace", kube_ns] + cmd += command + return subprocess.run(cmd, capture_output=capture_output, text=True, check=check) + +def check_agent_capacity(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, values: dict): + """ + Refuses to deploy podTemplates that ask for more agents than the cluster can ever schedule. + + An instanceCap above the nodes its pool can hold does not merely idle: the podAntiAffinity in each + template puts one agent on a node, so the surplus pods can never be scheduled, they drive the + autoscaler to maxSize, expire after waitForPodSec and are requested again in a loop. That churn is + what preceded the 2026-08-11 controller stall, both large node groups pinned at their maximum while + fifteen agents did the work. + Only what is established is enforced: a ceiling that could not be read is left unchecked, since + blocking a valid deploy on a check that cannot see the answer is worse than no check. + """ + + def agent_templates(values: dict) -> list: + """ + The agent podTemplates as [{name, size, selector, instance_cap, instance_cap_str}]. + + The templates are opaque strings to the helm chart, parsed only by the kubernetes plugin, so they + are loaded here as the yaml they are. `size` is the suffix of the `cassandra.jenkins.agent.` + nodeSelector, which is what ties a template to a node pool. + """ + templates = [] + for name, raw in (values.get("agent", {}).get("podTemplates") or {}).items(): + try: + template = yaml.safe_load(raw)[0] + except (yaml.YAMLError, IndexError, TypeError): + debug(f"Could not parse podTemplate {name}, skipping it") + continue + selector = dict(pair.split("=", 1) for pair in str(template.get("nodeSelector", "")).split(",") + if "=" in pair) + size = next((key.rsplit(".", 1)[1] for key in selector if key.startswith("cassandra.jenkins.agent.")), None) + templates.append({"name": name, "size": size, "selector": selector, + "instance_cap": template.get("instanceCap"), + "instance_cap_str": template.get("instanceCapStr")}) + return templates + + def node_pool_sizes(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str) -> Tuple[dict, dict]: + """ + What the live nodes reveal, as ({node pool name: size}, {label key: set of values seen}). + + Live nodes are the authoritative way to tie a node pool to an agent size, and the only way to + confirm a nodeSelector is spelt the way the nodes actually are. Pools scaled to zero contribute + nothing, which is the normal resting state, so neither result can be treated as exhaustive. + """ + pool_label = ("eks.amazonaws.com/nodegroup", "cloud.google.com/gke-nodepool") + pools, seen = {}, {} + try: + nodes = json.loads(run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["get", "nodes", "-o", "json"])) + except (subprocess.CalledProcessError, json.JSONDecodeError, TypeError) as e: + debug(f"Could not read nodes, agent sizes will not be attributed from them: {e}") + return pools, seen + for node in nodes.get("items", []): + labels = node.get("metadata", {}).get("labels", {}) + for key, value in labels.items(): + seen.setdefault(key, set()).add(value) + pool = next((labels[key] for key in pool_label if key in labels), None) + size = next((key.rsplit(".", 1)[1] for key, value in labels.items() + if key.startswith("cassandra.jenkins.agent.") and value == "true" + and key != "cassandra.jenkins.agent"), None) + if pool and size: + pools[pool] = size + return pools, seen + + def pool_ceilings(kubeconfig: Optional[str], kubecontext: Optional[str], pools: dict, sizes: set) -> Tuple[dict, list]: + """ + The most agents each size can ever run, as ({size: max nodes}, [(unattributed pool, maxSize)]). + + kubectl cannot see a pool's maximum directly: a pool at zero nodes has no nodes to count, so the + only in-cluster record is the cluster-autoscaler's status configmap. A managed autoscaler (GKE) + does not publish it, in which case nothing is established and the caller must not treat that as a + pass. The autoscaler names a pool for its underlying group (`eks--`), so a live + node's pool label is matched into it as a substring, falling back to the size word in the name for + pools that are scaled to zero. Anything still unmatched is returned for reporting, never ignored. + """ + + def nodegroup_max_size(group: dict) -> Optional[int]: + """ + A group's maximum, from its health condition where the autoscaler publishes it, or from the + group itself. + """ + if not isinstance(group, dict): + return None + for value in ((group.get("health") or {}).get("maxSize"), group.get("maxSize")): + if isinstance(value, str) and value.strip().isdigit(): + return int(value) + if isinstance(value, int) and not isinstance(value, bool): + return value + return None + + try: + status = yaml.safe_load(run_kubectl_command(kubeconfig, kubecontext, "kube-system", + ["get", "configmap", "cluster-autoscaler-status", + "-o", "jsonpath={.data.status}"])) + except (subprocess.CalledProcessError, yaml.YAMLError) as e: + debug(f"Could not read the cluster-autoscaler status configmap: {e}") + return {}, [] + ceilings, unattributed = {}, [] + for group in (status or {}).get("nodeGroups", []) if isinstance(status, dict) else []: + maximum = nodegroup_max_size(group) + if maximum is None: + continue + name = group.get("name", "") + size = next((size for pool, size in pools.items() if pool and pool in name), None) + if not size: + words = [word for word in sizes if re.search(rf"(^|[-_]){re.escape(word)}([-_]|$)", name)] + size = words[0] if len(words) == 1 else None + if not size: + # the controller has its own pool, and runs no agents + if "jenkins-controller" not in name: + unattributed.append((name, maximum)) + continue + ceilings[size] = ceilings.get(size, 0) + maximum + return ceilings, unattributed + + def check_template_capacity(template: dict, ceilings: dict, seen: dict) -> Tuple[list, list]: + """One podTemplate's (errors, warnings), errors being what could never be scheduled.""" + name, size, cap = template["name"], template["size"], template["instance_cap"] + errors, warnings = [], [] + # the plugin takes the cap from either key, so a disagreement resolves to whichever applies last + if str(template["instance_cap_str"]) != str(cap): + errors.append(f"{name}: instanceCap {cap} disagrees with instanceCapStr {template['instance_cap_str']}") + for key, value in template["selector"].items(): + if key not in seen: + # the resting state is every pool at zero, so this is not worth a warning on each deploy + debug(f"{name}: nodeSelector {key}={value} unconfirmed, no node currently carries {key}") + elif value not in seen[key]: + errors.append(f"{name}: nodeSelector {key}={value} matches no node, though {key} is present with" + f" {sorted(seen[key])}; agents would never be scheduled") + if size is None: + warnings.append(f"{name}: no cassandra.jenkins.agent. nodeSelector, cap {cap} not checked") + elif size not in ceilings: + # a size unresolved while others resolved is a blind spot worth flagging. Nothing resolving at all + # means the check could not run here, a fact about the cluster and not about these values + unchecked = f"{name}: instanceCap {cap} unchecked, no ceiling could be established for {size!r}" + if ceilings: + warnings.append(unchecked) + else: + debug(unchecked) + elif isinstance(cap, int) and cap > ceilings[size]: + errors.append(f"{name}: instanceCap {cap} exceeds the {ceilings[size]} nodes the {size} pool can hold," + f" so {cap - ceilings[size]} agents could never be scheduled") + else: + debug(f"{name}: instanceCap {cap} within the {size} pool's {ceilings[size]} nodes") + return errors, warnings + + templates = agent_templates(values) + if not templates: + return + pools, seen = node_pool_sizes(kubeconfig, kubecontext, kube_ns) + ceilings, unattributed = pool_ceilings(kubeconfig, kubecontext, pools, + {template["size"] for template in templates if template["size"]}) + errors, warnings = [], [] + for template in templates: + template_errors, template_warnings = check_template_capacity(template, ceilings, seen) + errors += template_errors + warnings += template_warnings + + container_cap = values.get("agent", {}).get("containerCap") + total = sum(template["instance_cap"] for template in templates if isinstance(template["instance_cap"], int)) + if isinstance(container_cap, int) and total > container_cap: + # benign, and not a fault to warn about on every deploy: the cloud cap leaves builds queued rather + # than creating pods that cannot be scheduled, it only stops every pool reaching its cap at once + debug(f"instanceCaps sum to {total} against a containerCap of {container_cap}, so the cloud cap binds" + f" first and the pools cannot all reach their cap at once") + for name, maximum in unattributed: + warnings.append(f"node pool {name!r} (maxSize {maximum}) matched no agent size and was not counted") + + for warning in warnings: + print(f"WARNING: {warning}") + if errors: + print("\nRefusing to deploy agent podTemplates that could never be scheduled:\n") + for error in errors: + print(f" {error}") + print("\nFix the podTemplate in .jenkins/k8s/jenkins-deployment.yaml, or raise the node pool's maximum" + " first (see .jenkins/k8s/README.md).") + sys.exit(1) + +def install_jenkins(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, + values_override: Optional[str] = None): + """Installs Jenkins Operator using Helm in the specified K8s namespace.""" + + def confirm_helm_updates(): + """Prompts before an upgrade drops any values the deployed jenkins currently has.""" + + def helm_values() -> dict: + """ + The values a deployed jenkins was last installed with, or an empty dict when there is no release. + These are the user-supplied values, whatever files they came from, so they include any customisations + made to the site outside of `.jenkins/k8s/jenkins-deployment.yaml`. + """ + result = run_helm_command(kubeconfig, kubecontext, kube_ns, + ["get", "values", "cassius", "-o", "yaml"], check=False) + if result.returncode != 0: + debug(f"No existing cassius release found in namespace {kube_ns}: {result.stderr.strip()}") + return {} + return yaml.safe_load(result.stdout) or {} + + def merge_values(base: dict, override: dict) -> dict: + """Merges two helm values files the way helm does: maps key by key, everything else replaced.""" + merged = dict(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = merge_values(merged[key], value) + else: + merged[key] = value + return merged + + def detect_lost_values(live: dict, proposed: dict) -> dict: + """ + Values the deployed jenkins has that an upgrade would drop, as {dotted.key.path: live value}. + + A key held live but absent from what is about to be applied is either a customisation made to this site, + or a key that `.jenkins/k8s/jenkins-deployment.yaml` has removed since the site was last deployed. + Note also what this cannot see: a key that exists in both but was given a different value locally, such + as an edited `agent.podTemplates` entry, is silently overwritten. + Read the diff of `helm template` before deploying an unfamiliar site. + """ + def leaf_values(values, path: str = "") -> dict: + """Flattens a values map to {dotted.key.path: value}, lists are leaves (helm replaces them).""" + if not isinstance(values, dict): + return {path: values} + leaves = {} + for key, value in values.items(): + leaves.update(leaf_values(value, f"{path}.{key}" if path else str(key))) + return leaves + + live_leaves, proposed_leaves = leaf_values(live), leaf_values(proposed) + lost = {path: value for path, value in live_leaves.items() if path not in proposed_leaves} + # lists are replaced wholesale, so also report items dropped from a list that is otherwise still there + for path, value in live_leaves.items(): + if isinstance(value, list) and isinstance(proposed_leaves.get(path), list): + dropped = [item for item in value if item not in proposed_leaves[path]] + if dropped: + lost[f"{path}[]"] = dropped + return lost + + with open(DEPLOY_YAML, encoding="utf-8") as deploy_yaml: + proposed = yaml.safe_load(deploy_yaml) or {} + if values_override: + with open(values_override, encoding="utf-8") as override_yaml: + proposed = merge_values(proposed, yaml.safe_load(override_yaml) or {}) + + lost = detect_lost_values(helm_values(), proposed) + if not lost: + return proposed + + print(f"\nWARNING: {len(lost)} value(s) the deployed jenkins has are absent from what is about to be applied.") + print("Each is either a customisation of this site, or a key removed from jenkins-deployment.yaml since" + " the site was last deployed. Upgrading drops them:\n") + for path in sorted(lost): + value = str(lost[path]).replace("\n", " ") + print(f" {path}: {value[:100] + '…' if len(value) > 100 else value}") + print(f"\nTo keep any of them, add them to a values override file (see .jenkins/k8s/README.md) and pass" + f" `--values-override`{' (the file passed does not contain them)' if values_override else ''}.") + + if not sys.stdin.isatty(): + print("Refusing to drop them when running non-interactively.") + sys.exit(1) + if input("\nDrop these values and continue? [y/N] ").strip().lower() not in ("y", "yes"): + print("Aborted, nothing was deployed.") + sys.exit(1) + return proposed + + # the values checked are the merged result, so a site override raising an instanceCap is checked too + check_agent_capacity(kubeconfig, kubecontext, kube_ns, confirm_helm_updates()) + + print("Adding Helm repository for Jenkins Operator...") + subprocess.run(["helm", "repo", "add", "jenkins", "https://charts.jenkins.io"], check=True) + subprocess.run(["helm", "repo", "update"], check=True) + + # site customisations are applied last, helm merges each -f over the previous + values_files = ["-f", DEPLOY_YAML] + (["-f", values_override] if values_override else []) + # --timeout is longer than helm's 5m default, which `--wait` would otherwise spend waiting for a pod + # whose controller.probes.startupProbe already permits a 300s boot, then fail a deploy that was + # succeeding. Keep it clear of that budget plus the readinessProbe's, see jenkins-deployment.yaml + result = run_helm_command(kubeconfig, kubecontext, kube_ns, + ["upgrade", "--install"] + values_files + + ["cassius", "jenkins/jenkins", "--wait", "--timeout", "10m"]) + + run_kubectl_command(kubeconfig, kubecontext, kube_ns, + ["exec", DEFAULT_POD_NAME, "--", + "curl", "-sS", "https://www.apache.org/logos/originals/cassandra-4.svg", + "-o", "/var/jenkins_cache/war/images/svgs/logo.svg"]) + + if result.returncode != 0: + print("Failed to install Jenkins Operator using Helm. Check the configuration and/or `kubectl logs cassius-jenkins-0`.") + sys.exit(1) + + +def wait_for_jenkins_http(ip: str): + host, port = (ip.rsplit(":", 1)[0], int(ip.rsplit(":", 1)[1])) if ":" in ip else (ip, 80) + spin_while(f"Waiting for Jenkins HTTP at {host}:{port}… ", lambda: _tcp_connect_ok(host, port)) + + +def _tcp_connect_ok(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=2): + return True + except OSError: + return False + + +def get_jenkins(k8s_client: client.CoreV1Api, args, kube_ns: str) -> Tuple[str, jenkins.Jenkins]: + """Authenticates to Jenkins and returns the Jenkins ip and server objects.""" + + def get_jenkins_ip(k8s_client, kube_ns: str) -> str: + svc = k8s_client.read_namespaced_service("cassius-jenkins", kube_ns) + if svc.status.load_balancer.ingress: + # the best we can do is the public IP or hostname of the controller, which may not be the common public url + ingress = svc.status.load_balancer.ingress[0] + ip = ingress.ip if ingress.ip else ingress.hostname + if svc.spec.ports[0].port != 80: + ip += ":" + str(svc.spec.ports[0].port) + print(f"Jenkins: {ip}\n---") + return ip + raise ValueError("Unable to retrieve Jenkins IP address") + + def prompt_for_password(): + return getpass.getpass("Enter Jenkins password: ") + + kubeconfig = args.kubeconfig + kubecontext = args.kubecontext + user = args.user if args.user else "admin" + ip = args.url if args.url else get_jenkins_ip(k8s_client, kube_ns) + + password = prompt_for_password() if args.user \ + else run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["exec", DEFAULT_POD_NAME, "--", "cat", "/run/secrets/additional/chart-admin-password"]) + # Initialize Jenkins API clien + server = jenkins.Jenkins(f"http://{ip}", username=user, password=password) + return ip, server + + +def ensure_job_parameters_visible(server: jenkins.Jenkins, job_name: str): + """ + If necessary, triggers a non-parameter build to make parameterised builds visible. + """ + job_info = server.get_job_info(job_name) + if any(param.get("parameterDefinitions") for param in job_info.get("property", [])): + return + + print(f"Parameters are not visible for job {job_name}; initiating non-parameter build.") + queue_item = server.build_job(job_name) + build_number = wait_for_build_number(server, queue_item) + time.sleep(6) + try: + server.stop_build(job_name, build_number) + except client.exceptions.ApiException as e: + print(f"Failed to stop non-parameter build {job_name} {build_number}: {e}") + print(f"Parameters should now be available for job {job_name}.") + + +def ensure_cassandra_job_parameters_visible(server: jenkins.Jenkins): + """Ensures parameterised builds are visible for all cassandra* jobs.""" + for job in server.get_jobs(): + job_name = job.get("name", "") + if job_name.startswith("cassandra"): + ensure_job_parameters_visible(server, job_name) + + +def trigger_jenkins_build(server: jenkins.Jenkins, job_name: str, **build_params) -> dict: + """Triggers a Jenkins build with specified parameters and returns the queue item.""" + ensure_job_parameters_visible(server, job_name) + print("Triggering Jenkins build… ") + return server.build_job(job_name, parameters=build_params) + + +def wait_for_build_number(server: jenkins.Jenkins, queue_item: int) -> int: + spin_while("Waiting for job build number… ", lambda: ('executable' in server.get_queue_item(queue_item))) + build_number = server.get_queue_item(queue_item)['executable']['number'] + sys.stdout.write("\033[F\033[K") # Move cursor up one line and clear i + print(f"\rBuild number: {build_number}\n") + return build_number + + +def wait_for_build_complete(server: jenkins.Jenkins, job_name: str, build_number: int): + """Waits for Jenkins build completion by monitoring the build status.""" + + def get_build_info(server: jenkins.Jenkins, job_name: str, build_number: int) -> dict: + try: + return server.get_build_info(job_name, build_number) + except (jenkins.NotFoundException, jenkins.JenkinsException, requests.exceptions.ConnectionError) as e: + debug(f"Failed get_build_info: {e}") + return {} + + elapsed_time = spin_while("Waiting for build to complete… ", + lambda: get_build_info(server, job_name, build_number).get('result')) + + minutes, seconds = divmod(elapsed_time, 60) + result = get_build_info(server, job_name, build_number)['result'] + print(f"\r---\nBuild completed after {minutes:02}:{seconds:02} with status: {result}") + + +def spin_while(message="", is_complete=lambda: False) -> int: + spinner = itertools.cycle(['|', '/', '-', '\\']) + start_time = time.time() + elapsed_time = 0 + while not is_complete(): + elapsed_time = int(time.time() - start_time) + minutes, seconds = divmod(elapsed_time, 60) + for _ in range(10): + sys.stdout.write(f"\r{message} {minutes:02}:{seconds:02} {next(spinner)}\033[?25l") + sys.stdout.flush() + time.sleep(0.3) + sys.stdout.write("\r" + " " * len(message + " \033[?25h")) + sys.stdout.flush() + return elapsed_time + +def node_cleaner(k8s_client: client.CoreV1Api, kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str): + """ + Periodically checks for dangling nodes and deletes them (and the underlying cloud instances) + for either GKE (GCP) or EKS (AWS). Cloud is auto-detected via node.spec.providerID. + + Env variables (per cloud provider): AWS_REGION, GCP_PROJECT_ID, GCP_ZONE + """ + def keep_running() -> bool: + return bool(globals().get("IS_RUNNING", True)) + + def node_cleaner_debug(msg: str): + if os.environ.get("NODE_CLEANER_DEBUG"): + print(msg) + + class CloudProvider(Enum): + AWS = "aws" + GCP = "gcp" + UNKNOWN = None + + # Patterns that indicate the node is actively in use by a jenkins pod + ACTIVE_POD_NAMES = ["agent-dind", "cassius"] + + def is_node_in_use(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, node_name: str) -> bool: + desc = run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["describe", "node", node_name]) + return any(p in desc for p in ACTIVE_POD_NAMES) + + def cordon_node(node_name: str): + try: + k8s_client.patch_node(name=node_name, body={"spec": {"unschedulable": True}}) + node_cleaner_debug(f"Node {node_name} cordoned.") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to cordon node {node_name}: {e}") + + def drain_node(node_name: str): + try: + pods = k8s_client.list_pod_for_all_namespaces(field_selector=f"spec.nodeName={node_name}") + for pod in pods.items: + owner_refs = pod.metadata.owner_references or [] + # Delete only non-DaemonSet pods + if not any(ref.kind == "DaemonSet" for ref in owner_refs): + try: + k8s_client.delete_namespaced_pod(name=pod.metadata.name, namespace=pod.metadata.namespace) + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to delete pod {pod.metadata.name} on {node_name}: {e}") + node_cleaner_debug(f"Node {node_name} drained (and all non-DaemonSet pods deleted).") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to drain node {node_name}: {e}") + + def delete_k8s_node(node_name: str): + try: + k8s_client.delete_node(node_name) + node_cleaner_debug(f"Node {node_name} deleted from Kubernetes API.") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to delete node {node_name} from K8s API: {e}") + + def get_first_node_provider_id() -> Optional[str]: + try: + items = k8s_client.list_node().items + if not items: + return None + for n in items: + if n.spec and n.spec.provider_id: + return n.spec.provider_id + return None + except client.exceptions.ApiException: + return None + + def detect_cloud_provider(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, node_name: str) -> Tuple[CloudProvider, str]: + """ Returns CloudProvider.AWS, CloudProvider.GCP, or CloudProvider.UNKNOWN. """ + try: + node_obj = k8s_client.read_node(node_name) + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to read node {node_name}: {e}") + return CloudProvider.UNKNOWN, None + + provider_id = getattr(node_obj.spec, "provider_id", None).lower() + + if not provider_id: + provider_id = get_first_node_provider_id().lower() + + if provider_id: + if provider_id.startswith("aws:"): + return CloudProvider.AWS, provider_id + if provider_id.startswith("gce:"): + return CloudProvider.GCP, provider_id + return CloudProvider.UNKNOWN, provider_id + + # Fallback via current-context name + provider_id = "" + try: + ctx = run_kubectl_command(kubeconfig, kubecontext, kube_ns, ["config", "current-context"]).lower() + if "arn:aws:eks" in ctx or "eks" in ctx: + return CloudProvider.AWS, provider_id + if "gke_" in ctx or "gke" in ctx: + return CloudProvider.GCP, provider_id + except subprocess.CalledProcessError: + debug(f"failed to determine provider_id: {e}") + return CloudProvider.UNKNOWN, None + + def parse_aws_provider_id(provider_id: str) -> Tuple[Optional[str], Optional[str]]: + """ + Returns (instance_id, region) derived from providerID. + Example providerID: "aws:///us-west-2a/i-0123456789abcdef0" + region = "us-west-2" (derived from AZ) + """ + assert provider_id + parts = provider_id.split("/") + instance_id = parts[-1] if parts else None + az = parts[-2] if len(parts) >= 2 else None # e.g., "us-west-2a" + region = None + if az and len(az) >= 2: + region = az[:-1] # drop 'a' -> "us-west-2" + # Prefer explicit env if set + if AWS_REGION: + region = AWS_REGION + return (instance_id, region) + + def parse_gce_provider_id(provider_id: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Returns (project_id, zone, instance_name) from providerID. + Example: "gce://my-project/us-central1-b/gke-...-node-..." + """ + assert provider_id + pid = provider_id.split("://", 1)[-1] + project, zone, instance = pid.split("/", 2) + # Prefer explicit env if set + project = GCP_PROJECT_ID or project + zone = GCP_ZONE or zone + return (project, zone, instance) + + def terminate_instance_gcp(project_id: str, zone: str, instance_name: str): + assert project_id and zone and instance_name + try: + from google.cloud import compute_v1 + from google.api_core.exceptions import GoogleAPICallError + except ImportError as e: + node_cleaner_debug(f"GCP client not available: {e}") + raise + try: + gcloud_compute_client = compute_v1.InstancesClient() + op = gcloud_compute_client.delete(project=project_id, zone=zone, instance=instance_name) + try: + op.result() + except GoogleAPICallError as e: + node_cleaner_debug(f"Failed to wait for GCE instance deletion operation: {e}") + return + node_cleaner_debug(f"GCE instance {instance_name} deleted (project={project_id}, zone={zone}).") + except GoogleAPICallError as e: + node_cleaner_debug(f"Failed to delete GCE instance {instance_name}: {e}") + + def terminate_instance_aws(instance_id: str, region: Optional[str]): + assert instance_id + try: + import boto3 + except ImportError as e: + node_cleaner_debug(f"AWS boto3 not available: {e}") + return + + session = boto3.session.Session(region_name=region or AWS_REGION) + autoscaling = session.client("autoscaling") + ec2 = session.client("ec2") + + # Prefer ASG termination (decrement desired capacity), fallback to EC2 terminate + try: + autoscaling.terminate_instance_in_auto_scaling_group( + InstanceId=instance_id, + ShouldDecrementDesiredCapacity=True + ) + node_cleaner_debug(f"EC2 instance {instance_id} terminated via Auto Scaling (decremented desired capacity).") + return + except autoscaling.exceptions.ClientError as e: + node_cleaner_debug(f"ASG termination failed for {instance_id}: {e}. Falling back to EC2 terminate.") + try: + ec2.terminate_instances(InstanceIds=[instance_id]) + node_cleaner_debug(f"EC2 instance {instance_id} terminated via EC2 API.") + except ec2.exceptions.ClientError as e: + node_cleaner_debug(f"Failed to terminate EC2 instance {instance_id}: {e}") + + def check_and_cleanup_node(node_name: str): + """ Check if node is dangling; if so, drain, delete from K8s, and remove the cloud instance. """ + # 1) If used by known patterns, skip (check for 1 minute) + for attempt in range(6): + if not keep_running(): + return + try: + if is_node_in_use(kubeconfig, kubecontext, kube_ns, node_name): + node_cleaner_debug(f"Node {node_name} in use [check {attempt}].") + return + except (subprocess.CalledProcessError, client.exceptions.ApiException) as e: + node_cleaner_debug(f"Failed to inspect node {node_name} [check {attempt}]: {e}") + return # Don't delete nodes we can't inspect safely + time.sleep(10) + + # 2) Determine provider + IDs from providerID of this node + cloud, provider_id = detect_cloud_provider(kubeconfig, kubecontext, kube_ns, node_name) + + # 3) Cordon & drain & delete K8s node (shared) + node_cleaner_debug(f"Deleting dangling node {node_name}…") + cordon_node(node_name) + drain_node(node_name) + delete_k8s_node(node_name) + + # 4) Cloud-specific instance delete/terminate + if CloudProvider.AWS == cloud: + instance_id, region = parse_aws_provider_id(provider_id) + if not instance_id and node_name.startswith("ip-") and "." in node_name: + # Can't derive instance-id from hostname; skip cloud deletion + node_cleaner_debug(f"No providerID for {node_name}; cannot derive EC2 instance-id from hostname.") + terminate_instance_aws(instance_id, region) + elif CloudProvider.GCP == cloud: + project_id, zone, instance_name = parse_gce_provider_id(provider_id) + terminate_instance_gcp(project_id, zone, instance_name if instance_name else node_name) + else: + node_cleaner_debug(f"Unknown cloud for node {node_name}; cloud instance not deleted.") + + # Main node_cleaner loop + while keep_running(): + try: + nodes = k8s_client.list_node().items + node_cleaner_debug(f" {len(nodes)} nodes") + except client.exceptions.ApiException as e: + node_cleaner_debug(f"Failed to list nodes: {e}") + time.sleep(10) + continue + active_threads = {t.name for t in threading.enumerate()} + for n in nodes: + node_name = n.metadata.name + # only act on nodes with "agent" in the name + node_cleaner_debug(f"Checking node {node_name}…") + if node_name not in active_threads: + t = threading.Thread(target=check_and_cleanup_node, args=(node_name,), name=node_name, daemon=True) + t.start() + time.sleep(10) + + +def delete_remote_junit_files(k8s_client, pod_name: str, kube_ns: str, base_job_name: str, build_number: int): + debug("Cleaning remote individual JUnit XML files...") + exec_command = ['rm', '-rf', f'/var/jenkins_home/jobs/{base_job_name}/builds/{build_number}/archive/test/output'] + stream.stream(k8s_client.connect_get_namespaced_pod_exec, + pod_name, kube_ns, container=DEFAULT_CONTAINER_NAME, command=exec_command, stderr=True, stdin=False, stdout=True, tty=False, _preload_content=False) + debug("Remote JUnit XML files cleaned.") + + +def download_results_and_print_summary(k8s_client, pod_name: str, kube_ns: str, build_number: int, ip: str, args): + + def download_console_log(pod_name: str, container_name: str, kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, console_log_path: str, local_console_log: Path): + max_retries = 5 + for attempt in range(max_retries): + try: + run_kubectl_command(kubeconfig, kubecontext, kube_ns, + ["cp", "-c", container_name, f"{kube_ns}/{pod_name}:{console_log_path}", str(local_console_log)]) + + print(f"Console log saved to {local_console_log}.gz\n") + break + except subprocess.CalledProcessError as e: + if attempt < max_retries: + debug(f" Failed to download {pod_name}:{console_log_path}: {e}. Retrying ({attempt + 1}/{max_retries})...") + time.sleep(5) # Wait before retrying + else: + raise + + def download_archive_tarball(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, pod_name: str, container_name: str, remote_path: str, local_path, max_retries=5): + for attempt in range(max_retries): + try: + run_kubectl_command(kubeconfig, kubecontext, kube_ns, + ["cp", "-c", container_name, f"{kube_ns}/{pod_name}:{remote_path}", str(local_path)]) + + debug(f"Build Artifacts saved in {local_path}") + break + except subprocess.CalledProcessError as e: + if attempt < max_retries: + debug(f" Failed to download {pod_name}:{remote_path}: {e}. Retrying ({attempt + 1}/{max_retries})...") + time.sleep(5) # Wait before retrying + else: + raise + + def extract_and_rename(archive_path: str, local_results_dir: str, ci_summary_file: str, ci_details_file: str): + with tarfile.open(archive_path, "r:gz") as tar: + tar.extractall(path=local_results_dir) + if (local_results_dir / "archive/ci_summary.html").exists(): + (local_results_dir / "archive/ci_summary.html").rename(ci_summary_file) + print(f"CI summary saved as {ci_summary_file}") + if (local_results_dir / "archive/results_details.tar.xz").exists(): + (local_results_dir / "archive/results_details.tar.xz").rename(ci_details_file) + print(f"Details file saved as {ci_details_file}") + print(" (attach ci_summary….html and results_details….tar.xz to the JIRA ticket)") + os.remove(archive_path) + print("---") + print(f"Logs in {local_results_dir / 'archive/stage-logs/'} and {local_results_dir / 'archive/test/logs/'}") + + def print_results_summary_console(local_console_log): + if local_console_log.exists(): + with open(local_console_log, 'r', encoding="utf-8") as log_file: + log_content = log_file.read() + if "BUILD FAILED" in log_content: + print("---") + failed_index = log_content.index("BUILD FAILED") + # Print the 200 characters after "BUILD FAILED" + print(log_content[failed_index:failed_index + 200]) + with open(local_console_log, 'r', encoding="utf-8") as log_file: + for line in log_file: + if "Finished: " in line: + print(line.strip()) + break + else: + print("Missing console log.") + + def print_results_summary_ci_summary(ci_summary_file): + if ci_summary_file.exists(): + with open(ci_summary_file, 'r', encoding="utf-8") as log_file: + summary_parts = [] + for line in log_file: + if any(l in line for l in [">Passed<", ">Failed<", ">Skipped<", ">Total<"]): + summary_parts.append(BeautifulSoup(line, 'html.parser').get_text().strip()) + if ">Total<" in line: + break + if summary_parts: + print(" – ".join(summary_parts)) + else: + print("No tests were run (or missing summary file).") + + def print_results_summary(local_console_log, ci_summary_file): + print("--- Build Summary ---") + print_results_summary_console(local_console_log) + print_results_summary_ci_summary(ci_summary_file) + # leave console_log.txt gzipped + if local_console_log.exists(): + with open(local_console_log, 'rb') as f_in, gzip.open(f"{local_console_log}.gz", 'wb') as f_out: + f_out.writelines(f_in) + os.remove(local_console_log) + + def download_url(url, dest, max_retries=5): + for attempt in range(max_retries): + try: + urlretrieve(url, dest) + debug(f" saved {dest}") + break + except (requests.exceptions.RequestException, IOError) as e: + if attempt < max_retries: + debug(f" Failed to download {url}: {e}. Retrying ({attempt + 1}/{max_retries})...") + time.sleep(5) # Wait before retrying + else: + raise + + local_results_dir = LOCAL_RESULTS_BASEDIR / ip.replace(".", "-") / str(build_number) + local_results_dir.mkdir(parents=True, exist_ok=True) + repo_owner = args.repository.split('/')[3] if 'https' in args.repository else args.repository.split(':')[1].split('/')[0] + ci_summary_file = local_results_dir / f"ci_summary_{repo_owner}_{args.branch.replace('/', '-')}_{build_number}.html" + ci_details_file = local_results_dir / f"results_details_{repo_owner}_{args.branch.replace('/', '-')}_{build_number}.tar.xz" + if args.url: + download_url(f"http://{ip}/job/{base_job_name(args)}/{build_number}/artifact/ci_summary.html", ci_summary_file) + download_url(f"http://{ip}/job/{base_job_name(args)}/{build_number}/artifact/results_details.tar.xz", ci_details_file) + if (ci_summary_file).exists(): + print(f"CI summary saved as {ci_summary_file}") + if (ci_details_file).exists(): + print(f"Details file saved as {ci_details_file}") + print(" (attach ci_summary….html and results_details….tar.xz to the JIRA ticket)") + print("--- Build Summary ---") + print_results_summary_ci_summary(ci_summary_file) + else: + kubeconfig = args.kubeconfig + kubecontext = args.kubecontext + local_console_log = local_results_dir / "console_log.txt" + local_archive_tar = local_results_dir / "archive.tar.gz" + remote_build_dir = f"/var/jenkins_home/jobs/{base_job_name(args)}/builds/{build_number}" + remote_console_log_path = f"{remote_build_dir}/log" + remote_archive_dir = f"{remote_build_dir}/archive" + + print("Downloading build results and logs...") + console_log_thread = threading.Thread(target=download_console_log, + args=(pod_name, DEFAULT_CONTAINER_NAME, kubeconfig, kubecontext, kube_ns, remote_console_log_path, local_console_log)) + console_log_thread.start() + + # Compress and download the archive directory if it exists + archive_path_in_pod = f"{remote_archive_dir}.tar.gz" + try: + # compress + compress_command = ["tar", "czf", f"{archive_path_in_pod}", "-C", remote_build_dir, "archive"] + stream.stream(k8s_client.connect_get_namespaced_pod_exec, pod_name, kube_ns, container=DEFAULT_CONTAINER_NAME, + command=compress_command, stderr=True, stdin=False, stdout=True, tty=False) + + local_archive_tar = local_results_dir / "archive.tar.gz" + download_archive_tarball(kubeconfig, kubecontext, kube_ns, pod_name, DEFAULT_CONTAINER_NAME, archive_path_in_pod, local_archive_tar) + # delete + stream.stream(k8s_client.connect_get_namespaced_pod_exec, pod_name, kube_ns, container=DEFAULT_CONTAINER_NAME, + command=['rm', archive_path_in_pod], stderr=True, stdin=False, stdout=True, tty=False) + + extract_and_rename(local_archive_tar, local_results_dir, ci_summary_file, ci_details_file) + + console_log_thread.join() + print_results_summary(local_console_log, ci_summary_file) + except client.exceptions.ApiException as e: + print(f"Failed to tarball artifacts at {archive_path_in_pod} in {pod_name}: {e}") + + +def cleanup_and_maybe_teardown(kubeconfig: Optional[str], kubecontext: Optional[str], kube_ns: str, tear_down: bool): + global IS_RUNNING + IS_RUNNING = False + if tear_down: + print("Cleaning up Jenkins and all resources.") + run_helm_command(kubeconfig, kubecontext, kube_ns, ["uninstall", "cassius"], capture_output=False) + # the pvc is annotated `helm.sh/resource-policy: keep`, see .jenkins/k8s/jenkins-deployment.yaml + print(f"Jenkins uninstalled. The jenkins-home volume was kept, delete it with:\n" + f" kubectl --namespace {kube_ns} delete pvc cassius-jenkins") + + +@contextmanager +def helm_installation_lock(lock_file: Path, timeout: int = 120): + with open(lock_file, "w", encoding="utf-8") as lock: + start = time.time() + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + break + except BlockingIOError as exc: + if (time.time() - start) > timeout: + raise TimeoutError("Timeout waiting for file lock.") from exc + time.sleep(1) + + +def main_download_results(k8s_client, ip, args): + build_number = int(args.download_results) + download_results_and_print_summary(k8s_client, DEFAULT_POD_NAME, DEFAULT_KUBE_NS, build_number, ip, args) + + +def main(): + load_environment_file() + args = parse_arguments() + k8s_client = None if args.url else setup_environment(args.kubeconfig, args.kubecontext) + + if args.only_tear_down: + cleanup_and_maybe_teardown(args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS, True) + return + if args.only_node_cleaner: + os.environ["NODE_CLEANER_DEBUG"] = "true" + node_cleaner(k8s_client, args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS) + return + if args.setup or args.only_setup: + init_k8s_namespace(k8s_client, DEFAULT_KUBE_NS) + with helm_installation_lock(Path("/tmp/.cassandra-run-ci.lock")): + install_jenkins(args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS, args.values_override) + + (ip, server) = get_jenkins(k8s_client, args, DEFAULT_KUBE_NS) + if args.setup or args.only_setup: + wait_for_jenkins_http(ip) + ensure_cassandra_job_parameters_visible(server) + if args.only_setup: + return + if args.download_results: + main_download_results(k8s_client, ip, args) + return + + # Background node cleaner: checks for dangling nodes and deletes them, can dramatically reduce k8s costs + # set env var NODE_CLEANER_DISABLE to disable + if not os.environ.get("NODE_CLEANER_DISABLE") and not args.url: + threading.Thread(target=node_cleaner, + args=(k8s_client, args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS), daemon=True).start() + + # Trigger Jenkins build with parameters + build_params = { + "repository": args.repository, + "branch": args.branch, + "profile": args.profile, + "profile_custom_regexp": args.profile_custom_regexp or "", + "jdk": args.jdk or "", + "repeat_test_regex": args.repeat_test_regex or "", + "repeated_tests_count": args.repeat_count or "", + "repeated_tests_stop_on_failure": "true" if args.repeat_stop_on_failure else "false", + "repeated_tests_machines": args.repeat_machines or "1", + "dtest_repository": args.dtest_repository or "", + "dtest_branch": args.dtest_branch or "" + } + + if DEFAULT_REPO_URL == args.repository and DEFAULT_REPO_BRANCH == args.branch and is_local_git_dirty(args): + print("Local uncommitted/unpushed changes.") + print(f"CI only runs on what is pushed in {args.repository} @ {args.branch}") + print(" See `git diff-index HEAD --` for uncommitted changes") + print(" See `git log @{u}.. --name-only` for unpushed changes") + print(" Do you want to continue anyway (y/N):") + if "y" != input().strip().lower(): + return + + queue_item = trigger_jenkins_build(server, base_job_name(args), **build_params) + build_number = wait_for_build_number(server, queue_item) + print(f"Jenkins UI at http://{ip}/job/{base_job_name(args)}/{build_number}/pipeline-overview/") + wait_for_build_complete(server, base_job_name(args), build_number) + + # Post-build processing and cleanup + if not args.url: + delete_remote_junit_files(k8s_client, DEFAULT_POD_NAME, DEFAULT_KUBE_NS, base_job_name(args), build_number) + download_results_and_print_summary(k8s_client, DEFAULT_POD_NAME, DEFAULT_KUBE_NS, build_number, ip, args) + cleanup_and_maybe_teardown(args.kubeconfig, args.kubecontext, DEFAULT_KUBE_NS, args.tear_down) + +if __name__ == "__main__": + main() diff --git a/.build/run-python-dtests.sh b/.build/run-python-dtests.sh new file mode 100755 index 000000000000..8af989d7b4d6 --- /dev/null +++ b/.build/run-python-dtests.sh @@ -0,0 +1,237 @@ +#!/bin/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. + +# +# Wrapper script for running a split or regexp of a pytest run from cassandra-dtest +# + +################################ +# +# Prep +# +################################ + +[ $DEBUG ] && set -x + +# target types +TARGET_TYPES="dtest dtest-upgrade" +for base in ${TARGET_TYPES}; do + for large in "" "-large"; do + for novnode in "" "-novnode"; do + for latest in "" "-latest"; do + variant="${large}${novnode}${latest}" + [[ -n "${variant}" ]] && TARGET_TYPES="${TARGET_TYPES} ${base}${variant}" + done + done + done +done + +# help +if [ "$#" -lt 1 ] || [ "$1" == "-h" ]; then + echo "" + echo "Usage: $0 [-a|-t|-c|-j|-h]" + echo " -a Test target type: ${TARGET_TYPES}" + echo " -t Test name regexp to run." + echo " -c Chunk to run in the form X/Y: Run chunk X from a total of Y chunks." + echo "" + echo " default split_chunk is 1/1" + exit 1 +fi + +# Pass in target to run, defaults to dtest +DTEST_TARGET="dtest" + +# TODO implement repeated runs, eg CASSANDRA-18942 +while getopts "a:t:c:hj:" opt; do + case $opt in + a ) DTEST_TARGET="$OPTARG" + [[ " ${TARGET_TYPES} " =~ " ${DTEST_TARGET/-repeat/} " ]] || error 1 "Invalid test target type '${DTEST_TARGET}'. Valid types: ${TARGET_TYPES}" + ;; + t ) DTEST_SPLIT_CHUNK="$OPTARG" + ;; + c ) DTEST_SPLIT_CHUNK="$OPTARG" + ;; + h ) print_help + exit 0 + ;; + j ) ;; # To avoid failing on java_version param from docker/run_tests.sh + \?) error 1 "Invalid option: -$OPTARG" + ;; + esac +done +shift $((OPTIND-1)) +if [ "$#" -ne 0 ]; then + error 1 "Unexpected arguments" +fi + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/..)" +[ "x${CASSANDRA_DTEST_DIR}" != "x" ] || CASSANDRA_DTEST_DIR="$(readlink -f ${CASSANDRA_DIR}/../cassandra-dtest)" +[ "x${DIST_DIR}" != "x" ] || DIST_DIR="${CASSANDRA_DIR}/build" +[ "x${TMPDIR}" != "x" ] || { TMPDIR_SET=1 && export TMPDIR="$(mktemp -d ${DIST_DIR}/run-python-dtest.XXXXXX)" ; } +[ "x${CCM_CONFIG_DIR}" != "x" ] && ls $CCM_CONFIG_DIR + +export PYTHONIOENCODING="utf-8" +export PYTHONUNBUFFERED=true +export CASS_DRIVER_NO_EXTENSIONS=true +export CASS_DRIVER_NO_CYTHON=true +export CCM_MAX_HEAP_SIZE="1024M" +export CCM_HEAP_NEWSIZE="512M" +export NUM_TOKENS="16" +# Have Cassandra skip all fsyncs to improve test performance and reliability +export CASSANDRA_SKIP_SYNC=true +unset CASSANDRA_HOME + +# pre-conditions +command -v ant >/dev/null 2>&1 || { echo >&2 "ant needs to be installed"; exit 1; } +command -v virtualenv >/dev/null 2>&1 || { echo >&2 "virtualenv needs to be installed"; exit 1; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { echo >&2 "${CASSANDRA_DIR}/build.xml must exist"; exit 1; } +[ -d "${DIST_DIR}" ] || { mkdir -p "${DIST_DIR}" ; } +ALLOWED_DTEST_VARIANTS="large|latest|upgrade|novnode|latest" +[[ "${DTEST_TARGET}" =~ ^dtest(-(${ALLOWED_DTEST_VARIANTS}))*$ ]] || { echo >&2 "Unknown dtest target: ${DTEST_TARGET}. Allowed variants are ${ALLOWED_DTEST_VARIANTS}"; exit 1; } + +java_version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | awk -F. '{print $1}') +version=$(grep 'property\s*name=\"base.version\"' ${CASSANDRA_DIR}/build.xml |sed -ne 's/.*value=\"\([^"]*\)\".*/\1/p') +java_version_default=`grep 'property\s*name="java.default"' ${CASSANDRA_DIR}/build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` + +if [ "${java_version}" -eq 17 ] && [[ "${target}" == "dtest-upgrade" ]] ; then + echo "Invalid JDK${java_version}. Only overlapping supported JDKs can be used when upgrading, as the same jdk must be used over the upgrade path." + exit 1 +fi + +python_version=$(python -V 2>&1 | awk '{print $2}' | awk -F'.' '{print $1"."$2}') +python_regx_supported_versions="^(3.8|3.9|3.10|3.11)$" +[[ $python_version =~ $python_regx_supported_versions ]] || { echo "Python ${python_version} not supported."; exit 1; } + +# check project is already built. no cleaning is done, so jenkins unstash works, beware. +[[ -f "${DIST_DIR}/apache-cassandra-${version}.jar" ]] || [[ -f "${DIST_DIR}/apache-cassandra-${version}-SNAPSHOT.jar" ]] || { echo "Project must be built first. Use \`ant jar\`. Build directory is ${DIST_DIR} with: $(ls ${DIST_DIR})"; exit 1; } + +# check if dist artifacts exist, this breaks the dtests +[[ -d "${DIST_DIR}/dist" ]] && { echo "dtests don't work when build/dist ("${DIST_DIR}/dist") exists (from \`ant artifacts\`)"; exit 1; } + +# print debug information on versions +java -version +ant -version +python --version +virtualenv --version + +# cheap trick to ensure dependency libraries are in place. allows us to stash only project specific build artifacts. +ant -quiet -silent resolver-dist-lib + +# Set up venv with dtest dependencies +set -e # enable immediate exit if venv setup fails + +# fresh virtualenv and test logs results everytime +[[ "/" == "${DIST_DIR}" ]] || rm -rf "${DIST_DIR}/venv" "${DIST_DIR}/test/{html,output,logs}" + +# re-use when possible the pre-installed virtualenv found in the cassandra-ubuntu-test docker image +virtualenv-clone ${BUILD_HOME}/env${python_version} ${DIST_DIR}/venv || virtualenv --python=python${python_version} ${DIST_DIR}/venv +source ${DIST_DIR}/venv/bin/activate +pip3 install --exists-action w -r ${CASSANDRA_DTEST_DIR}/requirements.txt +pip3 freeze + +################################ +# +# Main +# +################################ + +cd ${CASSANDRA_DTEST_DIR} + +set +e # disable immediate exit from this point +DTEST_ARGS="--keep-failed-test-dir" +# Check for specific keywords in DTEST_TARGET and append corresponding options +if [[ "${DTEST_TARGET}" == *"-large"* ]]; then + DTEST_ARGS+=" --only-resource-intensive-tests --force-resource-intensive-tests" +else + DTEST_ARGS+=" --skip-resource-intensive-tests" +fi +if [[ "${DTEST_TARGET}" != *"-novnode"* ]]; then + DTEST_ARGS+=" --use-vnodes --num-tokens=${NUM_TOKENS}" +fi +if [[ "${DTEST_TARGET}" == *"-latest"* ]]; then + DTEST_ARGS+=" --configuration-yaml=cassandra_latest.yaml" +fi +if [[ "${DTEST_TARGET}" == *"-upgrade"* ]]; then + DTEST_ARGS+=" --execute-upgrade-tests --execute-upgrade-tests-only --upgrade-target-version-only --upgrade-version-selection all" +fi + +touch ${DIST_DIR}/test_list.txt +./run_dtests.py --cassandra-dir=${CASSANDRA_DIR} ${DTEST_ARGS} --dtest-print-tests-only --dtest-print-tests-output=${DIST_DIR}/test_list.txt 2>&1 > ${DIST_DIR}/test_stdout.txt + +[[ $? -eq 0 ]] || { cat ${DIST_DIR}/test_stdout.txt ; exit 1; } + +if [[ "${DTEST_SPLIT_CHUNK}" =~ ^[0-9]+/[0-9]+$ ]]; then + split_cmd=split + ( split --help 2>&1 ) | grep -q "r/K/N" || split_cmd=gsplit + command -v ${split_cmd} >/dev/null 2>&1 || { echo >&2 "${split_cmd} needs to be installed"; exit 1; } + SPLIT_TESTS=$(${split_cmd} -n r/${DTEST_SPLIT_CHUNK} ${DIST_DIR}/test_list.txt) + if [[ -z "${SPLIT_TESTS}" ]]; then + # something has to run in the split to generate a nosetest xml result (and to not rerun all tests) + echo "Hacking ${DTEST_TARGET} to run only first test found as no tests in split ${DTEST_SPLIT_CHUNK} were found: " + SPLIT_TESTS="$( echo ${DIST_DIR}/test_list.txt | head -n1)" + echo " ${SPLIT_TESTS}" + fi + SPLIT_STRING="_${DTEST_SPLIT_CHUNK//\//_}" +elif [[ "x" != "x${DTEST_SPLIT_CHUNK}" ]] ; then + SPLIT_TESTS=$(grep -e "${DTEST_SPLIT_CHUNK}" ${DIST_DIR}/test_list.txt) + [[ "x" != "x${SPLIT_TESTS}" ]] || { echo "no tests match regexp \"${DTEST_SPLIT_CHUNK}\""; exit 1; } +else + SPLIT_TESTS=$(cat ${DIST_DIR}/test_list.txt) +fi +SPLIT_TESTS="${SPLIT_TESTS//$'\n'/ }" + +pytest_results_file="${DIST_DIR}/test/output/nosetests.xml" +pytest_opts="-vv --log-cli-level=DEBUG --junit-xml=${pytest_results_file} --junit-prefix=${DTEST_TARGET} -s" + +echo "" +echo "pytest ${pytest_opts} --cassandra-dir=${CASSANDRA_DIR} --keep-failed-test-dir ${DTEST_ARGS} ${SPLIT_TESTS}" +echo "" + +pytest ${pytest_opts} --cassandra-dir=${CASSANDRA_DIR} --keep-failed-test-dir ${DTEST_ARGS} ${SPLIT_TESTS} 2>&1 | tee -a ${DIST_DIR}/test_stdout.txt + +# tar up any ccm logs for easy retrieval +if ls ${TMPDIR}/*/test/*/logs/* &>/dev/null ; then + mkdir -p ${DIST_DIR}/test/logs + tar -C ${TMPDIR} -cJf ${DIST_DIR}/test/logs/ccm_logs.tar.xz ${TMPDIR}/*/test/*/logs +fi + +# merge all unit xml files into one, and print summary test numbers +pushd ${CASSANDRA_DIR}/ >/dev/null +# remove wrapping elements. ant generate-test-report` doesn't like it, and update testsuite name +sed -r "s/<[\/]?testsuites>//g" ${pytest_results_file} > ${TMPDIR}/nosetests.xml +cat ${TMPDIR}/nosetests.xml > ${pytest_results_file} +sed "s/testsuite name=\"Cassandra dtests\"/testsuite name=\"${DTEST_TARGET}_jdk${java_version}_python${python_version}_cython${cython}_$(uname -m)${SPLIT_STRING}\"/g" ${pytest_results_file} > ${TMPDIR}/nosetests.xml +cat ${TMPDIR}/nosetests.xml > ${pytest_results_file} + +ant -quiet -silent generate-test-report +popd >/dev/null + +################################ +# +# Clean +# +################################ + +if [ ${TMPDIR_SET} ] ; then + [[ "${TMPDIR}" == *"${DIST_DIR}/run-python-dtest."* ]] && rm -rf "${TMPDIR}" + unset TMPDIR +fi +deactivate + +# Exit cleanly for usable "Unstable" status +exit 0 diff --git a/.build/run-tests.sh b/.build/run-tests.sh new file mode 100755 index 000000000000..cfc1b724d19f --- /dev/null +++ b/.build/run-tests.sh @@ -0,0 +1,498 @@ +#!/bin/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. + +# +# Wrapper script for running a split or regexp of tests (excluding python dtests) +# + +[ $DEBUG ] && set -x + +set -o errexit +set -o pipefail + +# variables, with defaults +[ "x${CASSANDRA_DIR}" != "x" ] || CASSANDRA_DIR="$(readlink -f $(dirname -- "$0")/..)" +[ "x${DIST_DIR}" != "x" ] || DIST_DIR="${CASSANDRA_DIR}/build" + +# target types +TARGET_TYPES="build_dtest_jars stress-test fqltool-test microbench microbench-test test-burn long-test cqlsh-test simulator-dtest test test-cdc test-compression test-oa test-system-keyspace-directory test-latest jvm-dtest jvm-dtest-upgrade jvm-dtest-novnode jvm-dtest-upgrade-novnode" + +# pre-conditions +command -v ant >/dev/null 2>&1 || { error 1 "ant needs to be installed"; } +command -v git >/dev/null 2>&1 || { error 1 "git needs to be installed"; } +command -v uuidgen >/dev/null 2>&1 || test -f /proc/sys/kernel/random/uuid || { error 1 "uuidgen needs to be installed"; } +[ -d "${CASSANDRA_DIR}" ] || { error 1 "Directory ${CASSANDRA_DIR} must exist"; } +[ -f "${CASSANDRA_DIR}/build.xml" ] || { error 1 "${CASSANDRA_DIR}/build.xml must exist"; } +[ -d "${DIST_DIR}" ] || { mkdir -p "${DIST_DIR}" ; } + + +error() { + echo >&2 $2; + set -x + exit $1 +} + +print_help() { + echo "Usage: $0 [-a|-t|-c|-e|-i|-b|-s|-h]" + echo " -a Test target type: ${TARGET_TYPES}" + echo " -t Test name regexp to run." + echo " -c Chunk to run in the form X/Y: Run chunk X from a total of Y chunks." + echo " -b Specify the base git branch for comparison when determining changed tests to" + echo " repeat. Defaults to ${BASE_BRANCH}. Note that this option is not used when" + echo " the '-a' option is specified." + echo " -s Skip automatic detection of changed tests. Useful when you need to repeat a few ones," + echo " or when there are too many changed tests the CI env to handle." + echo " -e Environment variables to be used in the repeated runs:" + echo " -e REPEATED_TESTS_STOP_ON_FAILURE=false" + echo " -e REPEATED_TESTS_COUNT=500" + echo " If you want to specify multiple environment variables simply add multiple -e options." + echo " -i Ignore unknown environment variables" + echo " -h Print help" +} + + +# legacy argument handling +if [[ " ${TARGET_TYPES} " =~ " ${1} " ]]; then + test_type="-a ${1}" + if [[ -z ${2} ]]; then + test_list="" + elif [[ -n ${2} && "${2}" =~ ^[0-9]+/[0-9]+$ ]]; then + test_list="-c ${2}"; + else + test_list="-t ${2}"; + fi + echo "Using deprecated legacy arguments. Please update to new parameter format: ${test_type} ${test_list}" + $0 ${test_type} ${test_list} + exit $? +fi + + +env_vars="" +has_env_vars=false +check_env_vars=true +detect_changed_tests=true +while getopts "a:t:c:e:ib:shj:" opt; do + case $opt in + a ) test_target="$OPTARG" + [[ " ${TARGET_TYPES} " =~ " ${test_target/-repeat/} " ]] || error 1 "Invalid test target type '${test_target}'. Valid types: ${TARGET_TYPES}" + ;; + t ) test_name_regexp="$OPTARG" + ;; + c ) chunk="$OPTARG" + ;; + e ) if (! ($has_env_vars)); then + env_vars="$OPTARG" + else + env_vars="$env_vars|$OPTARG" + fi + has_env_vars=true + ;; + b ) BASE_BRANCH="$OPTARG" + ;; + i ) check_env_vars=false + ;; + s ) detect_changed_tests=false + ;; + h ) print_help + exit 0 + ;; + j ) ;; # To avoid failing on java_version param from docker/run_tests.sh + \?) error 1 "Invalid option: -$OPTARG" + ;; + esac +done +shift $((OPTIND-1)) +if [ "$#" -ne 0 ]; then + error 1 "Unexpected arguments" +fi + +# validate environment variables +if $has_env_vars && $check_env_vars; then + for entry in $(echo $env_vars | tr "|" "\n"); do + key=$(echo $entry | tr "=" "\n" | sed -n 1p) + case $key in + "REPEATED_TESTS_STOP_ON_FAILURE" | "REPEATED_TESTS_COUNT" ) + [[ ${test_target} == *"-repeat" ]] || { error 1 "'-e REPEATED_*' variables only valid against *-repeat target types"; } + ;; + *) + error 1 "unrecognized environment variable name: $key" + ;; + esac + done +fi + +# print debug information on versions +ant -version +git --version +java -version 2>&1 +javac -version 2>&1 + +# set the OFFLINE env var (to anything) to allow running jvm-dtest-upgrade offline +[ "x" != "x${OFFLINE}" ] && echo "WARNING: running in offline mode. jvm-dtest-upgrade results may be stale." + +# lists all tests for the specific test type +_list_tests() { + local -r classlistprefix="$1" + find "test/${classlistprefix}" -name '*Test.java' | sed "s;^test/${classlistprefix}/;;g" | sort +} + +_split_tests() { + local -r _split_chunk="$1" + split_cmd=split + if [[ "${_split_chunk}" =~ ^[0-9]+/[0-9]+$ ]]; then + ( split --help 2>&1 ) | grep -q "r/K/N" || split_cmd=gsplit + command -v ${split_cmd} >/dev/null 2>&1 || { error 1 "${split_cmd} needs to be installed"; } + ${split_cmd} -n r/${_split_chunk} + elif [[ "x" != "x${_split_chunk}" ]] ; then + grep -e "${_split_chunk}" + else + echo + fi +} + +_timeout_for() { + grep "name=\"${1}\"" build.xml | awk -F'"' '{print $4}' +} + +_get_env_var() { + [[ ${env_vars} =~ ${1}=([^|]+) ]] + echo "${BASH_REMATCH[1]}" +} + +_build_all_dtest_jars() { + # build the dtest-jar for the branch under test. remember to `ant clean` if you want a new dtest jar built + dtest_jar_version=$(grep 'property\s*name=\"base.version\"' build.xml |sed -ne 's/.*value=\"\([^"]*\)\".*/\1/p') + if [ -f "${DIST_DIR}/dtest-${dtest_jar_version}.jar" ] ; then + echo "Skipping dtest jar build for branch under test as ${DIST_DIR}/dtest-${dtest_jar_version}.jar already exists" + else + ant jar dtest-jar ${ANT_TEST_OPTS} -Dbuild.dir=${TMP_DIR}/cassandra-dtest-jars/build + cp "${TMP_DIR}/cassandra-dtest-jars/build/dtest-${dtest_jar_version}.jar" ${DIST_DIR}/ + fi + + if [ -d ${TMP_DIR}/cassandra-dtest-jars/.git ] && [ "https://github.com/apache/cassandra.git" == "$(git -C ${TMP_DIR}/cassandra-dtest-jars remote get-url origin)" ] ; then + echo "Reusing ${TMP_DIR}/cassandra-dtest-jars for past branch dtest jars" + if [ "x" == "x${OFFLINE}" ] ; then + until git -C ${TMP_DIR}/cassandra-dtest-jars fetch --quiet --tags origin ; do echo "git -C ${TMP_DIR}/cassandra-dtest-jars fetch failed… trying again… " ; done + fi + else + echo "Cloning cassandra to ${TMP_DIR}/cassandra-dtest-jars for past branch dtest jars" + rm -fR ${TMP_DIR}/cassandra-dtest-jars + pushd $TMP_DIR >/dev/null + until git clone --quiet --depth 1 --no-single-branch --tags https://github.com/apache/cassandra.git cassandra-dtest-jars ; do echo "git clone failed… trying again… " ; done + popd >/dev/null + fi + + # cassandra-4 branches need CASSANDRA_USE_JDK11 to allow jdk11 + [ "${java_version}" -eq 11 ] && export CASSANDRA_USE_JDK11=true + + pushd ${TMP_DIR}/cassandra-dtest-jars >/dev/null + # Note: cassandra-5.0.7 tag is used instead of cassandra-5.0 branch to enable + # testing upgrades from 5.0.7 to the current local build for autorepair feature + for branch in cassandra-4.0 cassandra-4.1 cassandra-5.0.7 ; do + git clean -qxdff && git reset --hard HEAD || echo "failed to reset/clean ${TMP_DIR}/cassandra-dtest-jars… continuing…" + git checkout --quiet $branch + dtest_jar_version=$(grep 'property\s*name=\"base.version\"' build.xml |sed -ne 's/.*value=\"\([^"]*\)\".*/\1/p') + if [ -f "${DIST_DIR}/dtest-${dtest_jar_version}.jar" ] ; then + echo "Skipping dtest jar build for branch ${branch} as ${DIST_DIR}/dtest-${dtest_jar_version}.jar already exists" + continue + fi + # redefine the build.dir to local build folder, rightmost definition wins with java command line system properties + ant realclean -Dbuild.dir=${TMP_DIR}/cassandra-dtest-jars/build + ant jar dtest-jar ${ANT_TEST_OPTS} -Dbuild.dir=${TMP_DIR}/cassandra-dtest-jars/build + cp "${TMP_DIR}/cassandra-dtest-jars/build/dtest-${dtest_jar_version}.jar" ${DIST_DIR}/ + done + popd >/dev/null + ls -l ${DIST_DIR}/dtest*.jar + unset CASSANDRA_USE_JDK11 +} + +_run_testlist() { + local _target_prefix=$1 + local _testlist_target=$2 + local _test_name_regexp=$3 + local _split_chunk=$4 + local _test_timeout=$5 + local _test_iterations=${6:-1} + + # are we running ${_test_name_regexp} or ${_split_chunk} + if [ -n "${_test_name_regexp}" ]; then + echo "Running tests: ${_test_name_regexp} (${_test_iterations} times)" + # test regexp can come in csv + for i in ${_test_name_regexp//,/ }; do + [ -n "${testlist}" ] && testlist="${testlist}"$'\n' + testlist="${testlist}$( _list_tests "${_target_prefix}" | _split_tests "${i}")" + done + [[ -z "${testlist}" ]] && error 1 "No tests found in test name regexp: ${_test_name_regexp}" + else + [ -n "${_split_chunk}" ] || { error 1 "Neither name regexp or split chunk defined"; } + echo "Running split: ${_split_chunk}" + testlist="$( _list_tests "${_target_prefix}" | _split_tests "${_split_chunk}")" + if [[ -z "${testlist}" ]]; then + # something has to run in the split to generate a junit xml result + echo "Hacking ${_target_prefix} ${_testlist_target} to run only first test found as no tests in split ${_split_chunk} were found" + testlist="$( _list_tests "${_target_prefix}" | sed -n 1p)" + fi + fi + + local -r _results_uuid="$(command -v uuidgen >/dev/null 2>&1 && uuidgen || cat /proc/sys/kernel/random/uuid)" + local failures=0 + for ((i=0; i < _test_iterations; i++)); do + [ "${_test_iterations}" -eq 1 ] || printf "–––– run ${i}\n" + set +o errexit + ant "$_testlist_target" -Dtest.classlistprefix="${_target_prefix}" -Dtest.classlistfile=<(echo "${testlist}") -Dtest.timeout="${_test_timeout}" ${ANT_TEST_OPTS} + ant_status=$? + set -o errexit + if [[ $ant_status -ne 0 ]]; then + echo "failed ${_target_prefix} ${_testlist_target} ${split_chunk} ${_test_name_regexp}" + + # Only store logs for failed tests on repeats to save up space + if [ "${_test_iterations}" -gt 1 ]; then + # Get this test results and rename file with iteration and 'fail' + find "${DIST_DIR}"/test/output/ -type f -name "*.xml" -not -name "*fail.xml" -print0 | while read -r -d $'\0' file; do + mv "${file}" "${file%.xml}-${_results_uuid}-${i}-fail.xml" + done + find "${DIST_DIR}"/test/logs/ -type f -name "*.log" -not -name "*fail.log" -print0 | while read -r -d $'\0' file; do + mv "${file}" "${file%.log}-${_results_uuid}-${i}-fail.log" + done + + if [ "$(_get_env_var 'REPEATED_TESTS_STOP_ON_FAILURE')" == true ]; then + error 0 "fail fast, after ${i} successful runs" + fi + let failures+=1 + fi + fi + done + [ "${_test_iterations}" -eq 1 ] || printf "––––\nfailure rate: ${failures}/${_test_iterations}\n" +} + +_list_microbench_tests() { + # Extract blacklist from build-bench.xml property (see CASSANDRA-18873) + local blacklist_pattern=$(grep 'name="microbench.exclude.pattern"' .build/build-bench.xml | sed -n 's/.*value="\([^"]*\)".*/\1/p') + + # Find all *Bench.java files, strip prefix, sort, and filter out blacklisted ones + find "test/microbench" -name '*Bench.java' | \ + sed "s;^test/microbench/;;g" | \ + sort | \ + grep -vE "(${blacklist_pattern})\.java$" +} + +_run_microbench() { + local _target=$1 + local _test_name_regexp=$2 + local _split_chunk=$3 + local testlist="" + + # Assert no *Test.java files exist under test/microbench + # uncomment once CachingBenchTest and GcCompactionBenchTest are rewritten to JMH benchmarks + #_list_tests "microbench" | grep -q 'Test\.java$' && error 1 "Found *Test.java files under test/microbench, these should be moved to test/unit" + + # Build test list from either regexp or split + if [ -n "${_test_name_regexp}" ]; then + echo "Running tests: ${_test_name_regexp}" + # test regexp can come in csv + for i in ${_test_name_regexp//,/ }; do + [ -n "${testlist}" ] && testlist="${testlist}"$'\n' + testlist="${testlist}$( _list_microbench_tests | _split_tests "${i}")" + done + [[ -z "${testlist}" ]] && error 1 "No tests found in test name regexp: ${_test_name_regexp}" + else + [ -n "${_split_chunk}" ] || { error 1 "Neither name regexp or split chunk defined"; } + echo "Running split: ${_split_chunk}" + testlist="$( _list_microbench_tests | _split_tests "${_split_chunk}")" + if [[ -z "${testlist}" ]]; then + echo "No microbench tests in split ${_split_chunk}, skipping" + return 0 + fi + fi + + # Convert file paths to the JMH classname pattern + local benchmark_pattern=$(echo "${testlist}" | sed 's/\.java$//g' | sed 's|^org/apache/cassandra/test/microbench/||g' | sed 's/\//./g' | tr '\n' '|' | sed 's/|$//') + echo "Running benchmarks: ${benchmark_pattern}" + + # override build.test.output.dir, adding jdk and arch to output path for report separation + local -r java_version="$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | awk -F. '{print $1}')" + local -r arch="$(uname -m)" + local -r output_dir="${DIST_DIR}/test/output/${_target}/jdk${java_version}/${arch}/${_split_chunk//\//_}" + + ant $_target ${ANT_TEST_OPTS} -Dbuild.test.output.dir=${output_dir} -Dbenchmark.name="${benchmark_pattern}" -Dmaven.test.failure.ignore=true + + # Post-process jmh-result.json to add jdk and arch parameters + local jmh_result="${output_dir}/jmh-result.json" + if [ -f "${jmh_result}" ]; then + python3 -c " +import json,sys +with open('${jmh_result}','r') as f: + data=json.load(f) +for r in (data if isinstance(data,list) else [data]): + if 'params' not in r: + r['params']={} + r['params']['jdk']='${java_version}' + r['params']['arch']='${arch}' +with open('${jmh_result}','w') as f: + json.dump(data,f) +" + fi +} + +_main() { + # parameters + local -r target="${test_target/-repeat/}" + local -r split_chunk="${chunk:-1/1}" # Chunks formatted as "K/N" for the Kth chunk of N chunks + + # check split_chunk is compatible with target (if not a regexp) + if [[ "${_split_chunk}" =~ ^\d+/\d+$ ]] && [[ "1/1" != "${split_chunk}" ]] ; then + case ${target} in + "stress-test" | "fqltool-test" | "cqlsh-test" | "simulator-dtest") + error 1 "Target ${target} does not support splits." + ;; + *) + ;; + esac + fi + + # "-repeat" is a reserved suffix on target types. + # Splits are allowed and multiply the number of machines: every split chunk runs the full set + # of REPEATED_TESTS_COUNT iterations itself (the chunk does not partition the iterations). + if [[ ${test_target} == *"-repeat" ]] ; then + if [[ -z "${test_name_regexp}" ]] ; then + error 1 "Repeated tests requires use of -t option" + fi + local -r repeat_count="$(_get_env_var 'REPEATED_TESTS_COUNT')" + else + test_name_regexp="${test_name_regexp:-}" + fi + + pushd ${CASSANDRA_DIR}/ >/dev/null + + # jdk check + local -r java_version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}' | awk -F. '{print $1}') + local -r version=$(grep 'property\s*name=\"base.version\"' build.xml |sed -ne 's/.*value=\"\([^"]*\)\".*/\1/p') + local -r java_version_default=`grep 'property\s*name="java.default"' build.xml |sed -ne 's/.*value="\([^"]*\)".*/\1/p'` + + if [ "${java_version}" -eq 17 ] && [[ "${target}" == "jvm-dtest-upgrade" ]] ; then + error 1 "Invalid JDK${java_version}. Only overlapping supported JDKs can be used when upgrading, as the same jdk must be used over the upgrade path." + fi + + # check project is already built. no cleaning is done, so jenkins unstash works, beware. + [[ -f "${DIST_DIR}/apache-cassandra-${version}.jar" ]] || [[ -f "${DIST_DIR}/apache-cassandra-${version}-SNAPSHOT.jar" ]] || { error 1 "Project must be built first. Use \`ant jar\`. Build directory is ${DIST_DIR} with: $(ls ${DIST_DIR} | xargs)"; } + + # check if dist artifacts exist, this breaks the dtests + [[ -d "${DIST_DIR}/dist" ]] && { error 1 "tests don't work when build/dist ("${DIST_DIR}/dist") exists (from \`ant artifacts\`)"; } + + # ant test setup + export TMP_DIR="${DIST_DIR}/tmp" + [ -d ${TMP_DIR} ] || mkdir -p "${TMP_DIR}" + export ANT_TEST_OPTS="-Dno-build-test=true -Dtmp.dir=${TMP_DIR} -Dbuild.test.output.dir=${DIST_DIR}/test/output/${target}" + + # fresh virtualenv and test logs results everytime + [[ "/" == "${DIST_DIR}" ]] || rm -rf "${DIST_DIR}/test/{html,output,logs,reports}" + + # cheap trick to ensure dependency libraries are in place. allows us to stash only project specific build artifacts. + # also recreate some of the non-build files we need + # createVersionPropFile is 4.x's name for 5.x's _createVersionPropFile + ant -quiet -silent resolver-dist-lib createVersionPropFile + + case ${target} in + "stress-test") + # hard fail on test compilation, but dont fail the test run as unstable test reports are processed + ant stress-build-test ${ANT_TEST_OPTS} + ant $target ${ANT_TEST_OPTS} || echo "failed ${target} ${split_chunk}" + ;; + "fqltool-test") + # hard fail on test compilation, but dont fail the test run so unstable test reports are processed + ant fqltool-build-test ${ANT_TEST_OPTS} + ant $target ${ANT_TEST_OPTS} || echo "failed ${target} ${split_chunk}" + ;; + "microbench" | "microbench-test") + _run_microbench "$target" "${test_name_regexp}" "${split_chunk}" + ;; + "test") + _run_testlist "unit" "testclasslist" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.timeout')" "${repeat_count}" + ;; + "test-cdc") + _run_testlist "unit" "testclasslist-cdc" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.timeout')" "${repeat_count}" + ;; + "test-compression") + _run_testlist "unit" "testclasslist-compression" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.timeout')" "${repeat_count}" + ;; + "test-oa") + _run_testlist "unit" "testclasslist-oa" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.timeout')" "${repeat_count}" + ;; + "test-system-keyspace-directory") + _run_testlist "unit" "testclasslist-system-keyspace-directory" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.timeout')" "${repeat_count}" + ;; + "test-latest") + _run_testlist "unit" "testclasslist-latest" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.timeout')" "${repeat_count}" + ;; + "test-burn") + _run_testlist "burn" "testclasslist" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.burn.timeout')" "${repeat_count}" + ;; + "long-test") + _run_testlist "long" "testclasslist" "${test_name_regexp}" "${split_chunk}" "$(_timeout_for 'test.long.timeout')" "${repeat_count}" + ;; + "simulator-dtest") + ant test-simulator-dtest ${ANT_TEST_OPTS} || echo "failed ${target}" + ;; + "jvm-dtest" | "jvm-dtest-novnode") + [ "jvm-dtest-novnode" == "${target}" ] || ANT_TEST_OPTS="${ANT_TEST_OPTS} -Dcassandra.dtest.num_tokens=16" + if [[ -z "${test_name_regexp}" ]] ; then + test_name_regexp=$( _list_tests "distributed" | grep -v "upgrade" | _split_tests "${split_chunk}") + if [[ -z "${test_name_regexp}" ]]; then + [[ "${split_chunk}" =~ ^[0-9]+/[0-9]+$ ]] || { error 1 "No tests match ${test_name_regexp}"; } + # something has to run in the split to generate a junit xml result + echo "Hacking jvm-dtest to run only first test found as no tests in split ${split_chunk} were found" + test_name_regexp="$( _list_tests "distributed" | grep -v "upgrade" | sed -n 1p)" + fi + fi + _run_testlist "distributed" "testclasslist" "${test_name_regexp}" "" "$(_timeout_for 'test.distributed.timeout')" "${repeat_count}" + ;; + "build_dtest_jars") + _build_all_dtest_jars + ;; + "jvm-dtest-upgrade" | "jvm-dtest-upgrade-novnode") + _build_all_dtest_jars + [ "jvm-dtest-upgrade-novnode" == "${target}" ] || ANT_TEST_OPTS="${ANT_TEST_OPTS} -Dcassandra.dtest.num_tokens=16" + if [[ -z "${test_name_regexp}" ]] ; then + test_name_regexp=$( _list_tests "distributed" | grep "upgrade" | _split_tests "${split_chunk}") + if [[ -z "${test_name_regexp}" ]]; then + [[ "${split_chunk}" =~ ^[0-9]+/[0-9]+$ ]] || { error 1 "No tests match ${test_name_regexp}"; } + # something has to run in the split to generate a junit xml result + echo "Hacking jvm-dtest-upgrade to run only first test found as no tests in split ${split_chunk} were found" + test_name_regexp="$( _list_tests "distributed" | grep "upgrade" | sed -n 1p)" + fi + fi + _run_testlist "distributed" "testclasslist" "${test_name_regexp}" "" "$(_timeout_for 'test.distributed.timeout')" "${repeat_count}" + ;; + "cqlsh-test") + ./pylib/cassandra-cqlsh-tests.sh $(pwd) + # 4.x's cqlsh script leaves the results at the repo root: move them where + # generate-test-report (which needs the dir to exist) and the CI summary find them + mkdir -p "${DIST_DIR}/test/output" + if [ -f cqlshlib.xml ] ; then mv cqlshlib.xml "${DIST_DIR}/test/output/" ; fi + ;; + *) + error 1 "unconfigured build command for test type \"${target}\"" + ;; + esac + + # merge all unit xml files into one, and print summary test numbers + ant -quiet -silent generate-test-report + + popd >/dev/null +} + +_main "$@" diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile index fe32d0f4cd34..5abbc07da171 100644 --- a/.jenkins/Jenkinsfile +++ b/.jenkins/Jenkinsfile @@ -1,3 +1,4 @@ +#!/usr/bin/env groovy // 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 @@ -11,528 +12,807 @@ // 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. -// Se# Licensed to the Apache Software Foundation (ASF) under onee the License for the specific language governing permissions and +// See the License for the specific language governing permissions and // limitations under the License. // // -// Jenkins declaration of how to build and test the current codebase. -// Jenkins infrastructure related settings should be kept in -// https://github.com/apache/cassandra-builds/blob/trunk/jenkins-dsl/cassandra_job_dsl_seed.groovy +// Jenkins CI declaration. +// +// This is the Cassandra 4.1 adaptation of the 5.0+ pipeline: no `lint` step +// (no `ant check` target on this branch) and no 5.0-only test steps +// (test-latest and test-oa – no targets for them in this build.xml). +// +// The declarative pipeline is presented first as a high level view. +// +// Build and Test Stages are dynamic, the full possible list defined by the `tasks()` function. +// There is a choice of pipeline profles with sets of tasks that are run, see `pipelineProfiles()`. +// +// All tasks use the dockerised CI-agnostic scripts found under `.build/docker/` +// The `type: test` always `.build/docker/run-tests.sh` +// +// +// This Jenkinsfile is expected to work on any Jenkins infrastructure. +// The controller should have 4 cpu, 12GB ram (and be configured to use `-XX:+UseG1GC -Xmx8G`) +// +// It is required to have agents providing 6+ labels, each that can provide docker and the following capabilities: +// +// - cassandra-small + cassandra-${arch}-small : 1 cpu, 1GB ram (alias for above but for any arch) +// - cassandra-medium + cassandra-${arch}-medium : 3 cpu, 5GB ram +// - cassandra-large + cassandra-${arch}-large : 7 cpu, 16GB ram +// +// Performance targets required a `cassandra-${arch}-large-dedicated` labelled nodes. +// +// When running builds parameterised to other architectures the corresponding labels are expected. +// For example 'arm64' requires the labels: cassandra-arm64-small, cassandra-arm64-medium, cassandra-arm64-large. +// +// Plugins required are: +// git, workflow-job, workflow-cps, junit, workflow-aggregator, ws-cleanup, pipeline-build-step, test-stability, copyartifact, jmh-report. +// See .jenkins/k8s/jenkins-deployment.yaml for up to date list of plugins. +// +// Any functionality that depends upon ASF Infra ( i.e. the canonical ci-cassandra.a.o ) +// will be ignored when run on other environments. +// Note there are also differences when CI is being run pre- or post-commit. +// +// CAUTION! When running CI with changes in this file, ensure the "Pipeline script from SCM" scm details match +// the brances being tested. These details don't honour the per-build repository and branch parameterisation. // // Validate/lint this file using the following command // `curl -X POST -F "jenkinsfile=<.jenkins/Jenkinsfile" https://ci-cassandra.apache.org/pipeline-model-converter/validate` +// + +/** CONSTANTS for both the pipeline and scripting **/ +import groovy.transform.Field +@Field List archsSupported = ["amd64", "arm64"] +@Field List pythonsSupported = ["3.8", "3.11", "3.12", "3.13"] +@Field String pythonDefault = "3.8" +/** CONSTANTS end **********************************/ pipeline { - agent { label 'cassandra' } + agent { label 'cassandra-small' } + options { + // must have: avoids agents waste in idle time on controller bottleneck + durabilityHint('PERFORMANCE_OPTIMIZED') + disableResume() + } + parameters { + string(name: 'repository', defaultValue: params.repository ?: scm.userRemoteConfigs[0].url, description: 'Cassandra Repository') + string(name: 'branch', defaultValue: params.branch ?: scm.branch, description: 'Branch') + + choice(name: 'profile', choices: pipelineProfileNames(params.profile ?: ''), description: 'Pick a pipeline profile.') + string(name: 'profile_custom_regexp', defaultValue: params.profile_custom_regexp ?: '', description: 'Regexp for stages when using custom profile. See `testSteps` in Jenkinsfile for list of stages. Example: stress.*|jvm-dtest.*') + string(name: 'repeat_test_regex', defaultValue: params.repeat_test_regex ?: '', description: 'Test name regexp (csv list) to run repeatedly. Only used by *-repeat stages, see `repeatTestSteps()` in Jenkinsfile. Example: HostReplacementTest') + string(name: 'repeated_tests_count', defaultValue: params.repeated_tests_count ?: '', description: 'How many times to run the tests selected by repeat_test_regex in a *-repeat stage. Example: 200') + booleanParam(name: 'repeated_tests_stop_on_failure', defaultValue: false, description: 'Stop a *-repeat stage on the first failed run. Default runs all iterations and reports the failure rate.') + string(name: 'repeated_tests_machines', defaultValue: params.repeated_tests_machines ?: '1', description: 'Number of machines that each run the full set of repeated test iterations in parallel, in *-repeat stages. Example: 4') + + choice(name: 'architecture', choices: archsSupported + "all", description: 'Pick architecture. The ARM64 is disabled by default at the moment.') + string(name: 'jdk', defaultValue: params.jdk ?: '', description: 'Restrict JDK versions. (e.g. "11", "17", etc)') + + string(name: 'dtest_repository', defaultValue: params.dtest_repository ?: 'https://github.com/apache/cassandra-dtest', description: 'Cassandra DTest Repository') + string(name: 'dtest_branch', defaultValue: params.dtest_branch ?: 'trunk', description: 'DTest Branch') + } stages { - stage('Init') { + stage('init') { steps { - cleanWs() - script { - currentBuild.result='SUCCESS' + script { + // this helps assure folk their parameters are correct and will be used (despite the earlier output about the configured job coordinates) + echo "Printing parameters used for this build" + ["Repository: ${params.repository}", "Branch: ${params.branch}", "Profile: ${params.profile}", "Custom Profile Regexp: ${params.profile_custom_regexp}", "Architecture: ${params.architecture}", "JDK: ${params.jdk}", "DTest Repository: ${params.dtest_repository}", "DTest Branch: ${params.dtest_branch}", "Repeat Test Regexp: ${params.repeat_test_regex}", "Repeated Tests Count: ${params.repeated_tests_count}", "Repeated Tests Stop On Failure: ${params.repeated_tests_stop_on_failure}"].each { println it } + def repeatStagesSelected = repeatTestSteps().keySet().findAll { it ==~ (params.profile_custom_regexp ?: '') } + if ("custom" == params.profile && repeatStagesSelected) { + if (!(params.repeat_test_regex?.trim() && params.repeated_tests_count?.trim())) { + error("The custom profile regexp '${params.profile_custom_regexp}' selects the repeat stages ${repeatStagesSelected}, which require the 'repeat_test_regex' and 'repeated_tests_count' parameters") + } + if (!(params.repeated_tests_count?.trim() ==~ /^[1-9][0-9]*$/)) { + error("The 'repeated_tests_count' parameter must be a positive integer, got: '${params.repeated_tests_count}'") + } + if (!(params.repeated_tests_machines?.trim() ==~ /^[1-9][0-9]*$/)) { + error("The 'repeated_tests_machines' parameter must be a positive integer, got: '${params.repeated_tests_machines}'") + } } + } } } - stage('Build') { + stage('jar') { + // the jar stage executes only the 'jar' build step, via the build(…) function + // the results of these (per jdk, per arch) are then stashed and used for every other build and test step steps { - script { - def attempt = 1 - retry(2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - build job: "${env.JOB_NAME}-artifacts" + script { + parallel(getJarTasks()) } - } } } - stage('Test') { - parallel { - stage('stress') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - stress = build job: "${env.JOB_NAME}-stress-test", propagate: false - if (stress.result != 'FAILURE') break - } - if (stress.result != 'SUCCESS') unstable('stress test failures') - if (stress.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('stress-test', stress.getNumber()) - } - } - } - } + stage('Tests') { + // the Tests stage executes all other build and task steps. + // build steps are sent to the build(…) function, test steps sent to the test(…) function + // these steps are parameterised and split by the tasks() function + when { + expression { hasNonJarTasks() } + } + steps { + script { + parallel(tasks()['tests']) } - stage('fqltool') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - fqltool = build job: "${env.JOB_NAME}-fqltool-test", propagate: false - if (fqltool.result != 'FAILURE') break - } - if (fqltool.result != 'SUCCESS') unstable('fqltool test failures') - if (fqltool.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('fqltool-test', fqltool.getNumber()) - } + } + } + stage('Summary') { + // generate the ci_summary.html and results_details.tar.xz artefacts + steps { + generateTestReports() + } + } + } + post { + failure { + echo "ERROR pipeline failed – not all tests were run" + } + always { + sendNotifications() + } + } +} + +/////////////////////////// +//// scripting support //// +/////////////////////////// + +@NonCPS +def pipelineProfiles() { + return [ + 'packaging': ['artifacts', 'debian', 'redhat'], + 'skinny': ['cqlsh-test', 'test', 'jvm-dtest', 'simulator-dtest', 'dtest'], + 'pre-commit': ['artifacts', 'debian', 'redhat', 'fqltool-test', 'cqlsh-test', 'test', 'stress-test', 'test-burn', 'jvm-dtest', 'simulator-dtest', 'dtest', 'dtest-latest', 'microbench-test'], + 'pre-commit w/ upgrades': ['artifacts', 'debian', 'redhat', 'fqltool-test', 'cqlsh-test', 'test', 'stress-test', 'test-burn', 'jvm-dtest', 'jvm-dtest-upgrade', 'simulator-dtest', 'dtest', 'dtest-novnode', 'dtest-latest', 'dtest-upgrade', 'microbench-test'], + 'post-commit': ['artifacts', 'debian', 'redhat', 'fqltool-test', 'cqlsh-test', 'test-cdc', 'test', 'test-compression', 'stress-test', 'test-burn', 'long-test', 'test-system-keyspace-directory', 'jvm-dtest', 'jvm-dtest-upgrade', 'simulator-dtest', 'dtest', 'dtest-novnode', 'dtest-latest', 'dtest-large', 'dtest-large-novnode', 'dtest-large-latest', 'dtest-upgrade', 'dtest-upgrade-novnode', 'dtest-upgrade-large', 'dtest-upgrade-large-novnode', 'microbench-test'], + 'performance': ['microbench'], + 'custom': [] + ] +} + +@NonCPS +def repeatTestSteps() { + // stages that re-run the test(s) named by the `repeat_test_regex` parameter `repeated_tests_count` times + // (the REPEATED_TESTS_COUNT/REPEATED_TESTS_STOP_ON_FAILURE options of .build/run-tests.sh). + // Custom profile only (see isStageEnabled). The stage is "split" across `repeated_tests_machines` + // machines, each running every iteration (see splitsFor()). A long timeout: 200 iterations of a + // dtest easily outlasts the default hour. + return [ + 'test-repeat': [splits: 1, size: 'medium', timeout_hours: 24], + 'jvm-dtest-repeat': [splits: 1, size: 'medium', timeout_hours: 24], + ] +} + +// number of machines each running the full set of repeated test iterations in *-repeat stages +def repeatMachines() { + return (params.repeated_tests_machines?.trim() ?: '1').toInteger() +} + +// repeat stages are "split" across machines, each running every iteration; other stages split the test set +def splitsFor(String step, def stepConfig) { + return step.endsWith('-repeat') ? repeatMachines() : stepConfig.splits +} + +@NonCPS +def pipelineProfileNames(putFirst) { + set = pipelineProfiles().keySet() as List + set = set - putFirst + set.add(0, putFirst) + return set +} + +@NonCPS +def extractBuildXmlProperty(String xml, String name) { + def m = xml =~ /property\s*name="${name}"\s*value="([^"]*)"/ + assert m, "${name} not found in build.xml" + return m[0][1] +} + +@Field Map cachedTasks = null + +def tasks() { + if (null != cachedTasks) return cachedTasks + + // Steps config + def buildSteps = [ + 'jar': [script: 'build-jars.sh', toCopy: null], + 'artifacts': [script: 'build-artifacts.sh', toCopy: 'apache-cassandra-*.tar.gz,apache-cassandra-*.jar,apache-cassandra-*.pom'], + 'debian': [script: 'build-debian.sh', toCopy: 'cassandra_*,cassandra-tools_*'], + 'redhat': [script: 'build-redhat.sh rpm', toCopy: '*.rpm'], + ] + buildSteps.each() { + it.value.put('type', 'build') + it.value.put('size', 'small') + it.value.put('splits', 1) + } + + def testSteps = [ + // Each splits size need to be high enough to avoid the one hour per split timeout, + // and low enough so test time is factors more than the setup+build time in each split. + // Splits can also be poorly balanced: splitting or renaming test classes is the best tactic. + // On unsaturated systems 10 minutes per split is optimal, higher with saturation + // (some buffer on the heaviest split under the 1h max is required, ref `timeout(…)` in `test(…)`) + 'cqlsh-test': [splits: 1], + 'fqltool-test': [splits: 1, size: 'small'], + 'test-cdc': [splits: 8], + 'test': [splits: 16], + 'test-compression': [splits: 16], + 'stress-test': [splits: 1, size: 'small'], + 'test-burn': [splits: 2], + 'long-test': [splits: 4], + 'test-system-keyspace-directory': [splits: 16], + 'jvm-dtest': [splits: 12], + 'jvm-dtest-upgrade': [splits: 6], + 'simulator-dtest': [splits: 1, size: 'large'], + 'dtest': [splits: 64, size: 'large'], + 'dtest-novnode': [splits: 64, size: 'large'], + 'dtest-latest': [splits: 64, size: 'large'], + 'dtest-large': [splits: 6, size: 'large'], + 'dtest-large-novnode': [splits: 6, size: 'large'], + 'dtest-large-latest': [splits: 6, size: 'large'], + 'dtest-upgrade': [splits: 128, size: 'large'], + 'dtest-upgrade-novnode': [splits: 128, size: 'large'], + 'dtest-upgrade-large': [splits: 32, size: 'large'], + 'dtest-upgrade-large-novnode': [splits: 32, size: 'large'], + 'microbench-test': [splits: 4, size: 'large', timeout_hours: 2], + // performance tests need 'cassandra-*large-dedicated' nodes + 'microbench': [splits: 4, size: 'large', timeout_hours: 6, benchmark: true], + ] + // *-repeat stages: re-run a single test (csv list) N times, see the repeat parameters + testSteps.putAll(repeatTestSteps()) + testSteps.each() { + it.value.put('type', 'test') + if (!it.value['size']) { + it.value.put('size', 'medium') + } + if (!it.value['timeout_hours']) { + // default 1 hour + it.value.put('timeout_hours', 1) + } + if (it.key.startsWith('dtest')) { + it.value.put('python-dtest', true) + } + } + + def stepsMap = buildSteps + testSteps + + // find the default JDK and the supported JDKs defined in the build.xml + def build_xml = readFile(file: 'build.xml') + def javaVersionDefault = extractBuildXmlProperty(build_xml, 'java.default') + def javaVersionsSupported = extractBuildXmlProperty(build_xml, 'java.supported').split(',') as List + + // define matrix axes + def Map matrix_axes = [ + arch: archsSupported, + jdk: javaVersionsSupported, + python: pythonsSupported, + cython: ['yes', 'no'], + step: stepsMap.keySet(), + split: (1..Math.max(testSteps.values().splits.max(), repeatMachines())).toList() + ] + + def List _axes = getMatrixAxes(matrix_axes).findAll { axis -> + (isArchEnabled(axis['arch'])) && // skip disabled archs + (isJdkEnabled(axis['jdk'])) && // skip disabled jdks + (isStageEnabled(axis['step'])) && // skip disabled steps + !(axis['python'] != pythonDefault && 'cqlsh-test' != axis['step']) && // Use only python 3.8 for all tests but cqlsh-test + !(axis['cython'] != 'no' && 'cqlsh-test' != axis['step']) && // cython only for cqlsh-test, disable for others + !(axis['cython'] == 'yes' && (axis['python'] == '3.12' || axis['python'] == '3.13')) && // Skip cython for Python 3.12+ see CASSANDRA-21482 + !(axis['jdk'] != javaVersionDefault && ('cqlsh-test' == axis['step'] || 'simulator-dtest' == axis['step'] || axis['step'].contains('dtest-upgrade'))) && // run cqlsh-test, simulator-dtest, *dtest-upgrade only with the default jdk + // Disable splits for all but proper stages + !(axis['split'] > 1 && !stepsMap.findAll { entry -> splitsFor(entry.key, entry.value) >= axis['split'] }.keySet().contains(axis['step'])) && + // run only the build types on non-amd64 + !(axis['arch'] != 'amd64' && !stepsMap.findAll { entry -> 'build' == entry.value.type }.keySet().contains(axis['step'])) + } + + def Map tasks = [ + jars: [failFast: true], + tests: [failFast: true] + ] + + for (def axis in _axes) { + def cell = axis + def name = getStepName(cell, stepsMap[cell.step]) + tasks[cell.step == "jar" ? "jars" : "tests"][name] = { -> + "${stepsMap[cell.step].type}"(stepsMap[cell.step], cell) + } + } + + return cachedTasks = tasks +} + +@NonCPS +def List getMatrixAxes(Map matrix_axes) { + def List axes = [] + matrix_axes.each { axis, values -> + List axisList = [] + values.each { value -> + axisList << [(axis): value] + } + axes << axisList + } + axes.combinations()*.sum() +} + +def getStepName(cell, command) { + def arch = "amd64" == cell.arch ? "" : " ${cell.arch}" + def python = "cqlsh-test" != cell.step ? "" : " python${cell.python}" + def cython = "no" == cell.cython ? "" : " cython" + def splits = splitsFor(cell.step, command) + def split = splits > 1 ? " ${cell.split}/${splits}" : "" + return "${cell.step}${arch} jdk${cell.jdk}${python}${cython}${split}" +} + +def getJarTasks() { + Map jars = tasks()['jars'] + assert jars.size() > 0, "Nothing to build. Check parameters: jdk ${params.jdk}, arch ${params.architecture}" + return jars +} + +def hasNonJarTasks() { + return tasks()['tests'].size() > 0 +} + +/** + * Is this a post-commit build (or a pre-commit build) + **/ +def isPostCommit() { + // any build of a branch found on github.com/apache/cassandra is considered a post-commit (post-merge) CI run + return params.repository && params.repository.contains("apache/cassandra") // no params exist first build +} + +/** + * Are we running on ci-cassandra.apache.org ? + **/ +def isCanonical() { + return "${JENKINS_URL}".contains("ci-cassandra.apache.org") +} + +def isStageEnabled(stage) { + return "jar" == stage || pipelineProfiles()[params.profile]?.contains(stage) || ("custom" == params.profile && stage ==~ params.profile_custom_regexp && + // *-repeat stages also need their parameters, otherwise run-tests.sh would fail inside the container + (!stage.endsWith('-repeat') || (params.repeat_test_regex?.trim() && params.repeated_tests_count?.trim()))) +} + +def isArchEnabled(arch) { + return params.architecture == arch || "all" == params.architecture +} + +def isJdkEnabled(jdk) { + return !params.jdk?.trim() || params.jdk.trim() == jdk +} + +/** + * Renders build script into pipeline steps + **/ +def build(command, cell) { + def build_script = ".build/docker/${command.script}" + def maxAttempts = 2 + def attempt = 0 + def nodeExclusion = "" + retry(maxAttempts) { + attempt++ + node(getNodeLabel(command, cell) + nodeExclusion) { + nodeExclusion = "&&!${NODE_NAME}" + withEnv(cell.collect { k, v -> "${k}=${v}" }) { + ws("workspace/${JOB_NAME}/${BUILD_NUMBER}/${cell.step}/${cell.arch}/jdk-${cell.jdk}") { + try { + fetchSource(cell.step, cell.arch, cell.jdk) + sh label: "checking Jenkinsfile validity", script: """ + test -f .jenkins/Jenkinsfile || { echo "Invalid git fork/branch"; exit 1; } + grep -q "Jenkins CI declaration" .jenkins/Jenkinsfile || { echo "Only Cassandra 5.0+ supported"; exit 1; } + """ + fetchDockerImages("redhat" == cell.step ? ['almalinux-build'] : ['bullseye-build']) + def cell_suffix = "_jdk${cell.jdk}_${cell.arch}" + def logfile = "stage-logs/${JOB_NAME}_${BUILD_NUMBER}_${cell.step}${cell_suffix}_attempt${attempt}.log.xz" + def script_vars = "#!/bin/bash \n set -o pipefail ; " // pipe to tee needs pipefail + script_vars = "${script_vars} m2_dir=\'${WORKSPACE}/build/m2\'" + timeout(time: 1, unit: 'HOURS') { + try { + def status = sh label: "RUNNING ${cell.step}...", script: "${script_vars} ${build_script} ${cell.jdk} 2>&1 | tee >( xz -c > build/${logfile} )", returnStatus: true + dir("build") { + archiveArtifacts artifacts: "${logfile}", fingerprint: true + copyToNightlies("${logfile}", "${cell.step}/jdk${cell.jdk}/${cell.arch}/") } - } - } - } - stage('units') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - test = build job: "${env.JOB_NAME}-test", propagate: false - if (test.result != 'FAILURE') break - } - if (test.result != 'SUCCESS') unstable('unit test failures') - if (test.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('test', test.getNumber()) - } + if (0 != status) { error("Stage ${cell.step}${cell_suffix} failed with exit status ${status}") } + if ("jar" == cell.step) { + _stash(cell) } - } - } - } - stage('long units') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - long_test = build job: "${env.JOB_NAME}-long-test", propagate: false - if (long_test.result != 'FAILURE') break - } - if (long_test.result != 'SUCCESS') unstable('long unit test failures') - if (long_test.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('long-test', long_test.getNumber()) - } + dir("build") { + copyToNightlies("${command.toCopy}", "${cell.step}/jdk${cell.jdk}/${cell.arch}/") } - } - } - } - stage('burn') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - burn = build job: "${env.JOB_NAME}-test-burn", propagate: false - if (burn.result != 'FAILURE') break - } - if (burn.result != 'SUCCESS') unstable('burn test failures') - if (burn.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('test-burn', burn.getNumber()) + } catch (exc) { + if ("org.jenkinsci.plugins.workflow.steps.FlowInterruptedException" == exc.getClass().getName()) { + def descriptions = [] + for (def cause in exc.getCauses()) { + echo "CauseOfInterruption: ${cause.getClass().getName()} - ${cause.getShortDescription()}" + if (cause.getClass().getName().contains('CauseOfInterruption$UserInterruption')) { + throw exc // user explicitly aborted — do not retry } - } - } - } - } - stage('cdc') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) + descriptions.add(cause.getShortDescription()) } - attempt = attempt + 1 - cdc = build job: "${env.JOB_NAME}-test-cdc", propagate: false - if (cdc.result != 'FAILURE') break - } - if (cdc.result != 'SUCCESS') unstable('cdc failures') - if (cdc.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('test-cdc', cdc.getNumber()) - } + error("Retryable interruption: ${descriptions.join(', ')}") } - } - } - } - stage('compression') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - compression = build job: "${env.JOB_NAME}-test-compression", propagate: false - if (compression.result != 'FAILURE') break + throw exc } - if (compression.result != 'SUCCESS') unstable('compression failures') - if (compression.result == 'FAILURE') currentBuild.result='FAILURE' } + } finally { + cleanAgent(cell.step) } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('test-compression', compression.getNumber()) - } - } - } - } - } - stage('cqlsh') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - cqlsh = build job: "${env.JOB_NAME}-cqlsh-tests", propagate: false - if (cqlsh.result != 'FAILURE') break - } - if (cqlsh.result != 'SUCCESS') unstable('cqlsh failures') - if (cqlsh.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('cqlsh-tests', cqlsh.getNumber()) - } - } - } - } } } } - stage('Distributed Test') { - parallel { - stage('jvm-dtest') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - jvm_dtest = build job: "${env.JOB_NAME}-jvm-dtest", propagate: false - if (jvm_dtest.result != 'FAILURE') break - } - if (jvm_dtest.result != 'SUCCESS') unstable('jvm-dtest failures') - if (jvm_dtest.result == 'FAILURE') currentBuild.result='FAILURE' - } + } +} + +def test(command, cell) { + if (command.containsKey('script')) { error("test commands all use `.build/docker/run-tests.sh`") } + def splits = splitsFor(cell.step, command) + def maxAttempts = 2 + def attempt = 0 + def nodeExclusion = "" + retry(maxAttempts) { + attempt++ + node(getNodeLabel(command, cell) + nodeExclusion) { + nodeExclusion = "&&!${NODE_NAME}" + withEnv(cell.collect { k, v -> "${k}=${v}" }) { + ws("workspace/${JOB_NAME}/${BUILD_NUMBER}/${cell.step}/${cell.arch}/jdk-${cell.jdk}/python-${cell.python}") { + try { + fetchSource(cell.step, cell.arch, cell.jdk) + fetchDockerImages(['ubuntu-test']) + def cell_suffix = "_jdk${cell.jdk}_python_${cell.python}_${cell.cython}_${cell.arch}_${cell.split}_${splits}" + def logfile = "stage-logs/${JOB_NAME}_${BUILD_NUMBER}_${cell.step}${cell_suffix}_attempt${attempt}.log.xz" + def script_vars = "#!/bin/bash \n set -o pipefail ; " // pipe to tee needs pipefail + script_vars = "${script_vars} python_version=\'${cell.python}\'" + script_vars = "${script_vars} m2_dir=\'${WORKSPACE}/build/m2\'" + if ("cqlsh-test" == cell.step) { + script_vars = "${script_vars} cython=\'${cell.cython}\'" } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('jvm-dtest', jvm_dtest.getNumber()) - } + script_vars = fetchDTestsSource(command, script_vars) + timeout(time: command.timeout_hours, unit: 'HOURS') { // best throughput with each cell at ~10 minutes + def timer = System.currentTimeMillis() + try { + buildJVMDTestJars(cell, script_vars, logfile) + script_vars = "${script_vars} docker_timeout_hours=\"${command.timeout_hours}\"" + def test_args = ".build/docker/run-tests.sh -a ${cell.step} -c '${cell.split}/${splits}' -j ${cell.jdk}" + if (cell.step.endsWith('-repeat')) { + // each "split" machine runs the full set of iterations itself, hence -c plus the repeat options + test_args += " -t '${params.repeat_test_regex}' -e REPEATED_TESTS_COUNT=${params.repeated_tests_count}" + if (params.repeated_tests_stop_on_failure) { + test_args += " -e REPEATED_TESTS_STOP_ON_FAILURE=true" } - } - } - } - stage('jvm-dtest-upgrade') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - jvm_dtest_upgrade = build job: "${env.JOB_NAME}-jvm-dtest-upgrade", propagate: false - if (jvm_dtest_upgrade.result != 'FAILURE') break } - if (jvm_dtest_upgrade.result != 'SUCCESS') unstable('jvm-dtest-upgrade failures') - if (jvm_dtest_upgrade.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('jvm-dtest-upgrade', jvm_dtest_upgrade.getNumber()) - } - } - } - } - } - stage('dtest') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - dtest = build job: "${env.JOB_NAME}-dtest", propagate: false - if (dtest.result != 'FAILURE') break + def status = sh label: "RUNNING TESTS ${cell.step}...", script: "${script_vars} ${test_args} 2>&1 | tee >( xz -c > build/${logfile} )", returnStatus: true + dir("build") { + archiveArtifacts artifacts: "${logfile}", fingerprint: true } - if (dtest.result != 'SUCCESS') unstable('dtest failures') - if (dtest.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('dtest', dtest.getNumber()) - } - } - } - } - } - stage('dtest-large') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - dtest_large = build job: "${env.JOB_NAME}-dtest-large", propagate: false - if (dtest_large.result != 'FAILURE') break - } - if (dtest_large.result != 'SUCCESS') unstable('dtest-large failures') - if (dtest_large.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('dtest-large', dtest_large.getNumber()) - } - } - } - } - } - stage('dtest-novnode') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - dtest_novnode = build job: "${env.JOB_NAME}-dtest-novnode", propagate: false - if (dtest_novnode.result != 'FAILURE') break - } - if (dtest_novnode.result != 'SUCCESS') unstable('dtest-novnode failures') - if (dtest_novnode.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('dtest-novnode', dtest_novnode.getNumber()) - } - } - } - } - } - stage('dtest-offheap') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) + if (0 != status) { error("Stage ${cell.step}${cell_suffix} failed with exit status ${status}") } + } catch (exc) { + if ("org.jenkinsci.plugins.workflow.steps.FlowInterruptedException" == exc.getClass().getName()) { + def descriptions = [] + for (def cause in exc.getCauses()) { + echo "CauseOfInterruption: ${cause.getClass().getName()} - ${cause.getShortDescription()}" + if (cause.getClass().getName().contains('CauseOfInterruption$UserInterruption') || cause.getClass().getName().contains('ParallelStep$FailFastCause')) { + throw exc // user abort or fail-fast — do not retry } - attempt = attempt + 1 - dtest_offheap = build job: "${env.JOB_NAME}-dtest-offheap", propagate: false - if (dtest_offheap.result != 'FAILURE') break - } - if (dtest_offheap.result != 'SUCCESS') unstable('dtest-offheap failures') - if (dtest_offheap.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('dtest-offheap', dtest_offheap.getNumber()) - } - } - } - } - } - stage('dtest-large-novnode') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - dtest_large_novnode = build job: "${env.JOB_NAME}-dtest-large-novnode", propagate: false - if (dtest_large_novnode.result != 'FAILURE') break - } - if (dtest_large_novnode.result != 'SUCCESS') unstable('dtest-large-novnode failures') - if (dtest_large_novnode.result == 'FAILURE') currentBuild.result='FAILURE' - } - } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('dtest-large-novnode', dtest_large_novnode.getNumber()) - } - } - } - } - } - stage('dtest-upgrade') { - steps { - script { - def attempt = 1 - while (attempt <=2) { - if (attempt > 1) { - sleep(60 * attempt) - } - attempt = attempt + 1 - dtest_upgrade = build job: "${env.JOB_NAME}-dtest-upgrade", propagate: false - if (dtest_upgrade.result != 'FAILURE') break + descriptions.add(cause.getShortDescription()) + } + error("Retryable interruption: ${descriptions.join(', ')}") } - if (dtest_upgrade.result != 'SUCCESS') unstable('dtest failures') - if (dtest_upgrade.result == 'FAILURE') currentBuild.result='FAILURE' + throw exc + } finally { + def duration = System.currentTimeMillis() - timer + def formattedTime = String.format("%tT.%tL", duration, duration) + echo "Time ${cell.step}${cell_suffix}: ${formattedTime}" } } - post { - always { - warnError('missing test xml files') { - script { - copyTestResults('dtest-upgrade', dtest_upgrade.getNumber()) - } - } + dir("build") { + organiseTestResultFiles(cell, cell_suffix) + if (!cell.step.startsWith("microbench")) { + junit testResults: "test/**/TEST-*.xml,test/**/cqlshlib*.xml,test/**/nosetests*.xml", testDataPublishers: [[$class: 'StabilityTestDataPublisher']] } + debugOomKiller() + compressTestResultFiles() + archiveArtifacts artifacts: "test/logs/**,test/**/TEST-*.xml.xz,test/**/cqlshlib*.xml.xz,test/**/nosetests*.xml.xz,test/**/jmh-result.json", fingerprint: true + copyToNightlies("${logfile},test/logs/**,test/**/jmh-result.json", "${cell.step}/${cell.arch}/jdk${cell.jdk}/python${cell.python}/cython_${cell.cython}/" + "split_${cell.split}_${splits}".replace("/", "_")) } + } finally { + cleanAgent(cell.step) } } - } - stage('Summary') { - steps { - sh "rm -fR cassandra-builds" - sh "git clone --depth 1 --single-branch https://gitbox.apache.org/repos/asf/cassandra-builds.git" - sh "./cassandra-builds/build-scripts/cassandra-test-report.sh" - junit testResults: '**/build/test/**/TEST*.xml,**/cqlshlib.xml,**/nosetests.xml', testDataPublishers: [[$class: 'StabilityTestDataPublisher']] - - // the following should fail on any installation other than ci-cassandra.apache.org - // TODO: keep jenkins infrastructure related settings in `cassandra_job_dsl_seed.groovy` - warnError('cannot send notifications') { - script { - changes = formatChanges(currentBuild.changeSets) - echo "changes: ${changes}" - } - slackSend channel: '#cassandra-builds', message: ":apache: <${env.BUILD_URL}|${currentBuild.fullDisplayName}> completed: ${currentBuild.result}. \n${changes}" - emailext to: 'builds@cassandra.apache.org', subject: "Build complete: ${currentBuild.fullDisplayName} [${currentBuild.result}] ${env.GIT_COMMIT}", presendScript: '${FILE,path="cassandra-builds/jenkins-dsl/cassandra_email_presend.groovy"}', body: ''' -------------------------------------------------------------------------------- -Build ${ENV,var="JOB_NAME"} #${BUILD_NUMBER} ${BUILD_STATUS} -URL: ${BUILD_URL} -------------------------------------------------------------------------------- -Changes: -${CHANGES} -------------------------------------------------------------------------------- -Failed Tests: -${FAILED_TESTS,maxTests=500,showMessage=false,showStack=false} -------------------------------------------------------------------------------- -For complete test report and logs see https://nightlies.apache.org/cassandra/${JOB_NAME}/${BUILD_NUMBER}/ -''' - } - sh "echo \"summary) cassandra-builds: `git -C cassandra-builds log -1 --pretty=format:'%H %an %ad %s'`\" > builds.head" - sh "./cassandra-builds/jenkins-dsl/print-shas.sh" - sh "xz TESTS-TestSuites.xml" - sh "wget --retry-connrefused --waitretry=1 \"\${BUILD_URL}/timestamps/?time=HH:mm:ss&timeZone=UTC&appendLog\" -qO - > console.log || echo wget failed" - sh "xz console.log" - sh "echo \"For test report and logs see https://nightlies.apache.org/cassandra/${JOB_NAME}/${BUILD_NUMBER}/\"" } - post { - always { - sshPublisher(publishers: [sshPublisherDesc(configName: 'Nightlies', transfers: [sshTransfer(remoteDirectory: 'cassandra/${JOB_NAME}/${BUILD_NUMBER}/', sourceFiles: 'console.log.xz,TESTS-TestSuites.xml.xz')])]) - } + } + } +} + +def fetchSource(stage, arch, jdk) { + cleanAgent(stage) + if ("jar" == stage) { + checkout changelog: false, scm: scmGit(branches: [[name: params.branch ?: 'trunk']], extensions: [cloneOption(depth: 1, noTags: true, reference: '', shallow: true)], userRemoteConfigs: [[url: params.repository]]) + sh "mkdir -p build/stage-logs" + } else { + unstash name: "${arch}_${jdk}" + } +} + +def fetchDTestsSource(command, script_vars) { + if (command.containsKey('python-dtest')) { + checkout changelog: false, poll: false, scm: scmGit(branches: [[name: params.dtest_branch ?: 'trunk']], extensions: [cloneOption(depth: 1, noTags: true, reference: '', shallow: true), [$class: 'RelativeTargetDirectory', relativeTargetDir: "${WORKSPACE}/build/cassandra-dtest"]], userRemoteConfigs: [[url: params.dtest_repository]]) + sh "test -f build/cassandra-dtest/requirements.txt || { echo 'Invalid cassandra-dtest fork/branch'; exit 1; }" + return "${script_vars} cassandra_dtest_dir='${WORKSPACE}/build/cassandra-dtest'" + } + return script_vars +} + +def buildJVMDTestJars(cell, script_vars, logfile) { + if (cell.step.startsWith("jvm-dtest-upgrade")) { + try { + unstash name: "jvm_dtests_${cell.arch}_${cell.jdk}" + } catch (error) { + sh label: "RUNNING build_dtest_jars...", script: "${script_vars} .build/docker/run-tests.sh -a build_dtest_jars -j ${cell.jdk} 2>&1 | tee >( xz -c > build/${logfile} )" + stash name: "jvm_dtests_${cell.arch}_${cell.jdk}", includes: '**/dtest*.jar' + } + } +} + +def fetchDockerImages(dockerfiles) { + // prefetch, from apache jfrog, reduces risking dockerhub pull rate limits + // also prefetch alpine:latest as its used as a utility in the scripts + def dockerfilesVar = dockerfiles.join(' ') + sh label: "fetching docker images...", script: """#!/bin/bash + for dockerfile in ${dockerfilesVar} ; do + image_tag="\$(md5sum .build/docker/\${dockerfile}.docker | cut -d' ' -f1)" + image_name="apache/cassandra-\${dockerfile}:\${image_tag}" + if ! ( [[ "" != "\$(docker images -q \${image_name} 2>/dev/null)" ]] ) ; then + docker pull -q apache.jfrog.io/cassan-docker/\${image_name} & + fi + done + docker pull -q apache.jfrog.io/cassan-docker/alpine:3.19.1 & + wait + # debug how much space the images have taken. df reports the node's filesystem: an emptyDir has no + # size of its own, so this sees node pressure coming, never the docker-storage sizeLimit + { set +x; } 2>/dev/null + free_gib=\$(df --output=avail -B 1073741824 / | tail -1 | tr -d ' ') + echo "docker images total: \$(docker system df --format '{{.Size}}' | head -1) (node free: \${free_gib}Gi)" + if [ "\${free_gib}" -le 10 ] ; then + echo "WARNING: only \${free_gib}Gi free on the node after pulling images — review the agents' ephemeral-storage budget in .jenkins/k8s/jenkins-deployment.yaml" + fi + """ +} + +def getNodeLabel(command, cell) { + def label = "cassandra-${cell.arch}-${command.size}" + if (command.containsKey('benchmark') && command.benchmark) { + // to provide reliable results the "microbench" target + // expects to be running on baremetal jenkins agents configured with only one executor + // those jenkins agents need to be manually configured to have the "cassandra-amd64-large-dedicated" label + label = "${label}-dedicated" + } + echo "using node label: ${label}" + return label +} + +def copyToNightlies(sourceFiles, remoteDirectory='') { + if (isCanonical() && sourceFiles?.trim()) { + def remotePath = remoteDirectory.startsWith("cassandra/") ? "${remoteDirectory}" : "cassandra/${JOB_NAME}/${BUILD_NUMBER}/${remoteDirectory}" + def attempt = 1 + retry(9) { + if (attempt > 1) { sleep(60 * attempt) } + sshPublisher( + continueOnError: true, failOnError: false, + publishers: [ + sshPublisherDesc( + configName: "Nightlies", + transfers: [ sshTransfer( sourceFiles: sourceFiles, remoteDirectory: remotePath) ] + ) + ]) + } + echo "archived to https://nightlies.apache.org/${remotePath}" + } +} + +def cleanAgent(job_name) { + // get any public IP which is more helpful correlating back to the cloud instance + sh script: 'hostname; curl -sm 10 ifconfig.me', returnStatus: true + def agentScriptsUrl = "https://raw.githubusercontent.com/apache/cassandra-builds/trunk/jenkins-dsl/agent_scripts/" + if (isCanonical()) { + cleanAgentDocker(job_name, agentScriptsUrl) + } + logAgentInfo(job_name, agentScriptsUrl) + cleanWs() + if (isCanonical()) { + // in the workspace prune any abandoned or uncleaned builds (CASSANDRA-20436) + sh label: "prune abandoned and uncleaned workspace builds files...", script: """#!/bin/bash + set +e + find /home/jenkins/jenkins-*/workspace/ -mindepth 2 -maxdepth 2 -type d -regextype posix-extended -regex '.*/[0-9]+' -mtime +31 -print -exec rm -rf {} + + """ + } +} + +def cleanAgentDocker(job_name, agentScriptsUrl) { + // we don't expect any build to have been running for longer than maxBuildHours + def maxBuildHours = 12 + sh label: "Pruning docker for '${job_name}' on ${NODE_NAME}...", script: """#!/bin/bash + set +e + wget -q ${agentScriptsUrl}/docker_image_pruner.py + wget -q ${agentScriptsUrl}/docker_agent_cleaner.sh + bash docker_agent_cleaner.sh ${maxBuildHours} + """ +} + +def logAgentInfo(job_name, agentScriptsUrl) { + // post-run remaining build/ and docker usage. used to validate the agents' ephemeral-storage budget + sh label: "log build usage...", script: """ + { set +x; } 2>/dev/null + du -sh ${WORKSPACE}/build/m2 ${WORKSPACE}/build/tmp ${WORKSPACE}/build/test ${WORKSPACE}/build 2>/dev/null || true + df -h / || true + """ + if (isCanonical()) { + sh label: "running agent_report.sh for disk usage stats (and more)...", script: """#!/bin/bash + set +e -o pipefail + wget -q ${agentScriptsUrl}/agent_report.sh + bash -x agent_report.sh | tee -a \$(date +"%Y%m%d%H%M")-disk-usage-stats.txt + """ + copyToNightlies("*-disk-usage-stats.txt", "cassandra/ci-cassandra.apache.org/agents/${NODE_NAME}/disk-usage/") + } +} + +def _stash(cell) { + sh label: "check stash size...", script: """ + { set +x; } 2>/dev/null + free_gib=\$(df --output=avail -B 1073741824 / | tail -1 | tr -d ' ') + stash_gb=\$(du -sb ${WORKSPACE} | awk '{printf "%.1f", \$1/1024/1024/1024}') + echo "stash size: \${stash_gb}G (node free: \${free_gib}Gi)" + if [ "\${free_gib}" -le 10 ] ; then + echo "WARNING: only \${free_gib}Gi free on the node after building stash (\${stash_gb}G) — review jnlp's resourceLimitEphemeralStorage in .jenkins/k8s/jenkins-deployment.yaml" + fi + """ + stash name: "${cell.arch}_${cell.jdk}" +} + +def organiseTestResultFiles(cell, cell_suffix) { + sh label: "organise test result files...", script: """ + mkdir -p test/output/${cell.step} + find test/output -type f -name "TEST*.xml" -execdir mkdir -p jdk_${cell.jdk}/${cell.arch} ';' -execdir mv {} jdk_${cell.jdk}/${cell.arch}/{} ';' + find test/output -name cqlshlib.xml -execdir mv cqlshlib.xml ${cell.step}/cqlshlib${cell_suffix}.xml ';' + find test/output -name nosetests.xml -execdir mv nosetests.xml ${cell.step}/nosetests${cell_suffix}.xml ';' + """ +} + +def debugOomKiller() { + // check if we had Linux OOM killer active within the test container which could kill forked JUnit JVM processes + sh label: "checking for oom kills...", script: """ + # docker memory/oomkiller debug: + cat /sys/fs/cgroup/docker/memory.events || true + """ +} + +def compressTestResultFiles() { + sh label: "compress test result files...", script: """ + { set +x; } 2>/dev/null + find test/output -type f -name "*.xml" -print0 | xargs -0 -r -n1 -P"\$(nproc)" xz -f + echo "\$(find test/output -type f -name "*.xml.xz" | wc -l) test result files compressed" + """ +} + +///////////////////////////////////////// +////// scripting support for summary //// +///////////////////////////////////////// + +def generateTestReports() { + node("cassandra-medium") { + cleanAgent("generateTestReports") + checkout changelog: false, scm: scmGit(branches: [[name: params.branch]], extensions: [cloneOption(depth: 1, noTags: true, reference: '', shallow: true)], userRemoteConfigs: [[url: params.repository]]) + def logfile = "stage-logs/${JOB_NAME}_${BUILD_NUMBER}_generateTestReports.log.xz" + sh "mkdir -p build/stage-logs" + def teeSuffix = "2>&1 | tee >( xz -c > build/${logfile} )" + def script_vars = "#!/bin/bash -x \n " + if (isCanonical()) { + // copyArtifacts takes >4hrs, hack with manual download + sh label: "manual download (instead of copyArtifacts)...", script: """${script_vars} + ( mkdir -p build/test + wget -q ${BUILD_URL}/artifact/test/output/*zip*/output.zip + unzip -x -d build/test -q output.zip ) ${teeSuffix} + """ + } else { + copyArtifacts filter: 'test/**/TEST-*.xml.xz,test/**/cqlshlib*.xml.xz,test/**/nosetests*.xml.xz,test/**/jmh-result.json', fingerprintArtifacts: true, projectName: env.JOB_NAME, selector: specific(env.BUILD_NUMBER), target: "build/", optional: true + } + // merge and summarise test reports + if (fileExists('build/test/output') && sh(script: 'test -n "$(find build/test/output -type f -name "*.xml.xz" -print -quit)"', returnStatus: true) == 0) { + // merge splits for each target's test report, other axes are kept separate + // TODO parallelised for loop + // TODO results_details.tar.xz needs to include all logs for failed tests + sh label: "merging splits test reports...", script: """${script_vars} ( + echo "test result files to decompress"; find build/test/output -type f -name "*.xml.xz" | wc -l + find build/test/output -type f -name "*.xml.xz" -print0 | xargs -0 -r -n1 -P"\$(nproc)" xz -f --decompress + + for target in \$(ls build/test/output/) ; do + if test -d build/test/output/\${target} ; then + mkdir -p build/test/reports/\${target} + echo "Report for \${target} (\$(find build/test/output/\${target} -name '*.xml' | wc -l) test files)" + CASSANDRA_DOCKER_ANT_OPTS="-Dbuild.test.output.dir=build/test/output/\${target} -Dbuild.test.report.dir=build/test/reports/\${target}" + export CASSANDRA_DOCKER_ANT_OPTS + .build/docker/_docker_run.sh bullseye-build.docker ci/generate-test-report.sh + fi + done + + .build/docker/_docker_run.sh bullseye-build.docker ci/generate-ci-summary.sh || echo "failed generate-ci-summary.sh" + + tar -cf build/results_details.tar -C build/test/ reports + xz -8f build/results_details.tar ) ${teeSuffix} + """ + + dir('build/') { + archiveArtifacts artifacts: "ci_summary.html,results_details.tar.xz,${logfile}", fingerprint: true + copyToNightlies('ci_summary.html,results_details.tar.xz,${logfile}') } } + processJmhReports() } } -def copyTestResults(target, build_number) { - step([$class: 'CopyArtifact', - projectName: "${env.JOB_NAME}-${target}", - optional: true, - fingerprintArtifacts: true, - selector: specific("${build_number}"), - target: target]); +def processJmhReports() { + if (fileExists('build/test/output') && sh(script: 'test -n "$(find build/test/output -type f -name jmh-result.json -print -quit)"', returnStatus: true) == 0) { + sh label: "merging jmh reports...", script: ''' python3 -c " +import glob,json +m=[] +for f in glob.glob('build/test/output/**/jmh-result.json',recursive=True): + d=json.load(open(f)) + m.extend(d if isinstance(d,list) else [d]) +o=open('build/test/output/combined-result.json','w') +json.dump(m,o) +o.close() +print(f'combined {len(m)} results') +" + ''' + jmhReport('build/test/output/combined-result.json') + dir('build/') { + archiveArtifacts artifacts: "test/output/combined-result.json", fingerprint: true + copyToNightlies('test/output/combined-result.json') + } + } } -def formatChanges(changeLogSets) { - def result = '' - for (int i = 0; i < changeLogSets.size(); i++) { - def entries = changeLogSets[i].items - for (int j = 0; j < entries.length; j++) { - def entry = entries[j] - result = result + "${entry.commitId} by ${entry.author} on ${new Date(entry.timestamp)}: ${entry.msg}\n" - } +def sendNotifications() { + if (isPostCommit() && isCanonical()) { + // the following is expected only to work on ci-cassandra.apache.org + def changes = '?' + try { + script { + changes = formatChangeLogChanges(currentBuild.changeSets) + echo "changes: ${changes}" + } + slackSend channel: '#cassandra-builds', message: ":apache: <${BUILD_URL}|${currentBuild.fullDisplayName}> completed: ${currentBuild.result}. \n${changes}" + emailext to: 'builds@cassandra.apache.org', subject: "Build complete: ${currentBuild.fullDisplayName} [${currentBuild.result}] ${GIT_COMMIT}", presendScript: 'msg.removeHeader("In-Reply-To"); msg.removeHeader("References")', body: emailContent() + } catch (Exception ex) { + echo 'failed to send notifications ' + ex.toString() + } + } +} + +def formatChangeLogChanges(changeLogSets) { + def result = '' + for (int i = 0; i < changeLogSets.size(); i++) { + def entries = changeLogSets[i].items + for (int j = 0; j < entries.length; j++) { + def entry = entries[j] + result = result + "${entry.commitId} by ${entry.author} on ${new Date(entry.timestamp)}: ${entry.msg}\n" } - return result + } + return result +} + +def emailContent() { + return ''' + ------------------------------------------------------------------------------- + Build ${ENV,var="JOB_NAME"} #${BUILD_NUMBER} ${BUILD_STATUS} + URL: ${BUILD_URL} + ------------------------------------------------------------------------------- + Changes: + ${CHANGES} + ------------------------------------------------------------------------------- + Failed Tests: + ${FAILED_TESTS,maxTests=500,showMessage=false,showStack=false} + ------------------------------------------------------------------------------- + For complete test report and logs see https://nightlies.apache.org/cassandra/${JOB_NAME}/${BUILD_NUMBER}/ + ''' } diff --git a/.jenkins/k8s/README.md b/.jenkins/k8s/README.md new file mode 100644 index 000000000000..14ac7175fdd1 --- /dev/null +++ b/.jenkins/k8s/README.md @@ -0,0 +1,153 @@ +# K8s Jenkins Installation + +The files in this folder help provision ci-cassandra.a.o clones into any k8s cluster. + +This is used by the `.build/run-ci --only-setup` script invocation, but can also be done manually. + + +## One-time K8s Setup + +This is a onetime setup required in a K8s cluster, required before executing `.build/run-ci --only-setup` script. It creates the needed node-pools for different resource sized agents used in jenkins. +``` +# pick a cluster name that is identifiable to you +CLUSTER_NAME="$(whoami)--cassandra-jenkins" +``` +Follow the instructions according to your cloud. + +### GCLOUD + +``` +# choose your closest (low-carbon) zone +ZONE="us-central1-c" + +# cluster and controller node +gcloud container clusters create ${CLUSTER_NAME} --machine-type e2-standard-8 --disk-type=pd-ssd --num-nodes 1 --node-labels=cassandra.jenkins.controller=true --autoscaling-profile optimize-utilization --zone ${ZONE} + +# small resource nodes +gcloud container node-pools create agents-small --cluster ${CLUSTER_NAME} --machine-type e2-highcpu-8 --disk-type=pd-ssd --disk-size=107 --enable-autoscaling --spot --num-nodes=0 --min-nodes=0 --max-nodes=50 --node-labels=cassandra.jenkins.agent=true,cassandra.jenkins.agent.small=true --zone ${ZONE} + +# medium resource nodes +# preference (by cost): n2-highcpu-8, c3-highcpu-8, n4-highcpu-8, n1-highcpu-16 +gcloud container node-pools create agents-medium --cluster ${CLUSTER_NAME} --machine-type n2-highcpu-8 --disk-type=pd-ssd --disk-size=107 --enable-autoscaling --spot --num-nodes=0 --min-nodes=0 --max-nodes=100 --node-labels=cassandra.jenkins.agent=true,cassandra.jenkins.agent.medium=true --zone ${ZONE} + +# large resource nodes +gcloud container node-pools create agents-large --cluster ${CLUSTER_NAME} --machine-type n2-standard-8 --disk-type=pd-ssd --disk-size=107 --enable-autoscaling --spot --num-nodes=0 --min-nodes=0 --max-nodes=160 --node-labels=cassandra.jenkins.agent=true,cassandra.jenkins.agent.large=true --zone ${ZONE} + +# For each sized resource nodes, pick any machine type that fits, those listed above should work and be the most cost-effective, but this can change region to region +# See https://github.com/apache/cassandra/blob/cassandra-6.0/.jenkins/Jenkinsfile#L35-L38 +# and agent.podTemplates.*.resourceLimitCpu and agent.podTemplates.*.resourceLimitMemory (adding gke/eks requirements) in https://github.com/apache/cassandra/blob/cassandra-6.0/.jenkins/k8s/jenkins-deployment.yaml +# The jenkins resource requirements should fit into the corresponding dind podTemplate limits. +# Remember to allow a buffer for gke/eks pods deployed on each node. +``` + + +## Manual Jenkins Helm Installation + +To manually install Jenkins into a K8s cluster using the Helm yaml (rather than using the `.build/run-ci --only-setup` invocation). + +``` +# auth (and make default context) +gcloud container clusters get-credentials cassius --zone ${ZONE} + +helm repo add jenkins https://charts.jenkins.io +helm repo update +helm upgrade --install -f jenkins-deployment.yaml cassius jenkins/jenkins --wait + +# get the server's address +kubectl describe svc cassius-jenkins | grep 'LoadBalancer Ingress' + +# get the jenkins' password +kubectl exec -it svc/cassius-jenkins -c jenkins -- /bin/cat /run/secrets/additional/chart-admin-password && echo + +# open http:// +``` + +This leaves the controller running, a single e2-standard-8 instance. All other node-pools downscale to zero. + +## Upgrading an existing instance (e.g. pre-ci.cassandra.apache.org) + +A long-lived site like pre-ci.cassandra.apache.org may carry customisations: hostname, cloud load-balancer, storage class; that are deliberately absent from `jenkins-deployment.yaml`. + +Running `helm upgrade -f jenkins-deployment.yaml` or `.build/run-ci --only-setup` will drop those customisations. + +Instead keep the customisations in separate overrides file, using it like +``` +.build/run-ci --only-setup --values-override +``` + or as a second `-f` argument to `helm upgrade`. + + +##### To collect overridden values + +To get and diff currently deployed values against the version of `jenkins-deployment.yaml` that is deployed. +``` +RELEASE=cassius +NS=default +kubectl config current-context # confirm correct context +DEPLOYED_COMMIT=cassandra-5.0 # the last jenkins-deployment.yaml deployed git commit sha + +helm get values ${RELEASE} -n ${NS} -o yaml > /tmp/cassandra-ci-live-values.yaml + +git show ${DEPLOYED_COMMIT}:.jenkins/k8s/jenkins-deployment.yaml > /tmp/deployed.yaml + +for f in /tmp/deployed.yaml /tmp/cassandra-ci-live-values.yaml ; do + python3 -c 'import sys,yaml;print(yaml.safe_dump(yaml.safe_load(open(sys.argv[1])),sort_keys=True,width=10000))' ${f} > ${f}.sorted +done + +diff -u /tmp/deployed.yaml.sorted /tmp/cassandra-ci-live-values.yaml.sorted +``` + +Copy the genuine customisations you need to keep into `~/.cassandra-ci/-overrides.yaml`, keeping the full key path. + +For example: +``` +controller: + ingress: + hostName: pre-ci.cassandra.apache.org # note the capital N, `hostname` is silently ignored + serviceAnnotations: # AWS load-balancer-controller: static EIP, public subnet + service.beta.kubernetes.io/aws-load-balancer-name: pre-ci-apache-cassandra + ... +persistence: + storageClass: gp2 +``` + +Beware how Helm merges: maps are merged key by key, but lists and strings are *replaced* wholesale. Each `agent.podTemplates.*` entry is one multi-line string, so overriding a pod template masks every repo-side change to that template. Prefer keeping pod-template customisations out of the overrides file; where that is unavoidable, re-apply the repo's changes to the overridden copy by hand at each upgrade. + + +### Rolling back a bad Helm upgrade + +``` +helm history ${RELEASE} -n ${NS} +helm rollback ${RELEASE} -n ${NS} --wait --timeout 15m +``` + +`helm rollback` restores the previous chart *and* values, so the site's customisations come back with it. If the release history itself is unusable, the `/tmp/cassandra-ci-live-values.yaml` from above can be used: +``` +helm upgrade ${RELEASE} jenkins/jenkins --version ${CHART_VERSION} -n ${NS} \ + -f /tmp/cassandra-ci-live-values.yaml --wait --timeout 15m +``` + + +### Configuring Local-only Access + +If you want only local private access to Jenkins, do the following. + +Comment these lines before running `helm upgrade …` +``` +# serviceType: LoadBalancer +# ingress: +# enabled: "true" +``` +Run the helm upgrade and get the password as usual +``` +helm upgrade --install -f values.yaml cassius jenkins/jenkins --wait + +# get the jenkins' password +kubectl exec -it svc/cassius-jenkins -c jenkins -- /bin/cat /run/secrets/additional/chart-admin-password && echo + +# port-forward 8080 to the private jenkins +kubectl port-forward svc/cassius-jenkins 8080:8080 + +# open http://localhost:8080 +``` + diff --git a/.jenkins/k8s/agent-build.docker b/.jenkins/k8s/agent-build.docker new file mode 100644 index 000000000000..1e21228cf9a5 --- /dev/null +++ b/.jenkins/k8s/agent-build.docker @@ -0,0 +1,26 @@ +# 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. + + +# apache/cassandra-jenkins-k8s +# +# docker buildx build --platform="linux/amd64,linux/arm64" -t apache/cassandra-jenkins-k8s -t apache.jfrog.io/cassan-docker/apache/cassandra-jenkins-k8s --provenance=true --sbom=true -f agent-build.docker --push . +# + +FROM jenkins/inbound-agent +USER root +RUN apt-get update && apt-get -y install docker.io bc procps +USER jenkins diff --git a/.jenkins/k8s/jenkins-deployment-pvc.yaml b/.jenkins/k8s/jenkins-deployment-pvc.yaml new file mode 100644 index 000000000000..40504d5057ce --- /dev/null +++ b/.jenkins/k8s/jenkins-deployment-pvc.yaml @@ -0,0 +1,29 @@ +# 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. +# +# this is usually not needed as the helm chart (jenkins-deployment.yaml) does it +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: cassius-jenkins +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 500Gi + # aws eks needs gp2 + storageClassName: standard diff --git a/.jenkins/k8s/jenkins-deployment.yaml b/.jenkins/k8s/jenkins-deployment.yaml new file mode 100644 index 000000000000..eeff53bf3411 --- /dev/null +++ b/.jenkins/k8s/jenkins-deployment.yaml @@ -0,0 +1,547 @@ +# 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. +# +# https://github.com/jenkinsci/helm-charts/tree/main/charts/jenkins +# this yaml is primarily used by .build/run-ci +# + +# fixed deployments will want to set the controller.ingress.hostName +persistence: + enabled: true + size: "500Gi" + # keep the claim (jobs, credentials, build history) when the release is uninstalled. + # deleting it then takes a deliberate `kubectl delete pvc cassius-jenkins` + annotations: + helm.sh/resource-policy: keep + # aws needs gp2, gke can be left commented (add it to your override yaml) + #storageClass: "gp2" +controller: + # To get URL run `kubectl describe svc cassius-jenkins | grep 'LoadBalancer Ingress'` + serviceType: LoadBalancer + servicePort: 80 + targetPort: 8080 + ingress: + enabled: "true" + # if you have a "ci-cassandra" dns entry for the jenkins controller add the following value to your override yaml + #hostName: ci-cassandra. + customJenkinsLabels: + - controller + resources: + # increase cpu/memory as agent pool sizes get bigger (pre-ci.c.a.o uses 8 and 20g) + requests: + cpu: 4 + memory: 16G + limits: + cpu: 8 + memory: 20G + javaOpts: -server -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+ExplicitGCInvokesConcurrent -XX:+ParallelRefProcEnabled -XX:+UseStringDeduplication -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=40 -Xms8G -Xmx8G -Xlog:gc*,safepoint:file=/var/jenkins_home/gc.log:time,uptime,level,tags:filecount=5,filesize=50M + # A stalling controller must not be killed mid-flight. The restart abandons every running build and + # strands their agent pods, which nothing then deletes: see agent.garbageCollection below. + # Liveness now tolerates ~100s of unresponsiveness before a kill. + # Readiness is loosened for a second reason: an unready controller is dropped from the cassius-jenkins-agent Service, + # so agents cannot complete their JNLP handshake and then sit in "initialising" until waitForPodSec expires. + probes: + startupProbe: + # a 500Gi jenkins_home with this much build history can exceed the chart's default 120s budget + failureThreshold: 30 + timeoutSeconds: 10 + livenessProbe: + failureThreshold: 10 + timeoutSeconds: 10 + readinessProbe: + failureThreshold: 6 + timeoutSeconds: 10 + installPlugins: + - job-dsl + - configuration-as-code + - kubernetes + - git + - workflow-job + - workflow-cps + - junit + - workflow-aggregator + - pipeline-graph-view + - ws-cleanup + - pipeline-build-step + - pipeline-rest-api + - test-stability + - copyartifact + - jmh-report + node-selector: + cassandra.jenkins.controller: true + scriptApproval: + - "staticMethod java.lang.System setProperty java.lang.String java.lang.String" + - "staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods combinations java.util.Collection" + - "staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods getAt java.lang.Object java.lang.String" + - "staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods inspect java.lang.Object" + - "staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods max java.util.Collection" + - "staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods putAt java.util.List java.util.List java.lang.Object" + - "field hudson.plugins.git.GitSCMBackwardCompatibility branch" + - "method org.jenkinsci.plugins.workflow.steps.FlowInterruptedException getCauses" + JCasC: + configScripts: + welcome-message: | + jenkins: + systemMessage: Welcome to Apache Cassandra + # Separate jobs are needed because Jenkinsfiles differ, and are read before parameters are applied. + # if a dev branch alters the Jenkinsfile, it will not be picked up by the job – you need to edit the job configuration + # see the CAUTION warning in .jenkins/Jenkinsfile + # TODO: add new version each release branching + test-job: | + jobs: + - script: > + pipelineJob('cassandra') { + definition { + cpsScm { + scm { + git { + remote { + url('https://github.com/apache/cassandra') + } + branch('trunk') + scriptPath('.jenkins/Jenkinsfile') + } + } + lightweight() + } + } + } + - script: > + pipelineJob('cassandra-6.0') { + definition { + cpsScm { + scm { + git { + remote { + url('https://github.com/apache/cassandra') + } + branch('cassandra-6.0') + scriptPath('.jenkins/Jenkinsfile') + } + } + lightweight() + } + } + } + - script: > + pipelineJob('cassandra-4.1') { + definition { + cpsScm { + scm { + git { + remote { + url('https://github.com/apache/cassandra') + } + branch('cassandra-4.1') + scriptPath('.jenkins/Jenkinsfile') + } + } + lightweight() + } + } + } + - script: > + pipelineJob('cassandra-5.0') { + definition { + cpsScm { + scm { + git { + remote { + url('https://github.com/apache/cassandra') + } + branch('cassandra-5.0') + scriptPath('.jenkins/Jenkinsfile') + } + } + lightweight() + } + } + } + globalDefaultFlowDurabilityLevel: + durabilityHint: "PERFORMANCE_OPTIMIZED" + securityRealm: |- + local: + allowsSignup: false + enableCaptcha: false + users: + - id: "admin" + name: "Jenkins Admin" + password: "${chart-admin-password}" + authorizationStrategy: |- + loggedInUsersCanDoAnything: + allowAnonymousRead: true + googlePodMonitor: + enabled: true +agent: + disableDefaultAgent: true + maxRequestsPerHostStr: "3200" + containerCap: 300 + node-selector: + cassandra.jenkins.agent: true + waitForPodSec: "180" + # Reap agent pods the controller no longer tracks. A restart of the controller JVM clears its in-memory + # agent registry, and because agent pods are bare pods with no ownerReference nothing else deletes them. + # Preferred over a pod activeDeadlineSeconds, which cannot distinguish an orphan from a long (6h) build. + garbageCollection: + enabled: true + # seconds + timeout: 900 + # + # Each template below is an opaque string to the helm chart, parsed only by the kubernetes plugin, + # so the chart (nor .jenkins/k8s/jenkins-test.sh) can validate it. + # + # Two traps then to pay attention to: + # - a volume declared under `volumes:` is generated as `volume-N`, and the generated copy wins any + # merge with a raw `yaml:` entry of the same name, so a field set only there is silently dropped. + # Volumes wanting a field the plugin lacks, a sizeLimit for instance, are declared in `yaml:` alone. + # - ephemeral-storage is charged to the pod: every emptyDir, the workspace included, along with the + # containers' writable layers and logs, all against the containers' limits summed. Declare no + # request and the pod is BestEffort for storage, which the scheduler ignores and the node evicts + # first, reporting only `request is 0`. Each template below budgets 80Gi (of the ~89Gi that a + # 100GiB node allocates) leaving the rest to the node's own image cache and daemonsets. + # + # After any deploy, validate changes like: + # kubectl get pod -l jenkins/cassius-jenkins-agent -o json | jq '.items[0].spec | {volumes, containers: [.containers[] | {name, resources, volumeMounts}]}' + # + podTemplates: + agent-dind-small: | + - name: agent-dind-small + label: agent-dind cassandra-small cassandra-amd64-small + nodeSelector: 'cassandra.jenkins.agent.small=true' + # 0 = no pod lifetime cap: idleMinutes reuse means a pod's age is unrelated to any one build's timeout. Orphans are reaped by agent.garbageCollection instead. + activeDeadlineSeconds: '0' + idleMinutes: 1 + # should match the small pool's 50 nodes (README's --max-nodes), i.e. one agent per node. must not be higher than max nodes possible. + instanceCap: 50 + instanceCapStr: "50" + nodeUsageMode: "NORMAL" + showRawYaml: 'true' + slaveConnectTimeout: '30' + yamlMergeStrategy: override + containers: + - name: jnlp + # https://github.com/jenkinsci/kubernetes-plugin#pipeline-support + alwaysPullImage: true + envVars: + - envVar: + key: DOCKER_TLS_CERTDIR + value: /certs/client/ + - envVar: + key: DOCKER_CERT_PATH + value: /certs/client/ + - envVar: + key: DOCKER_TLS_VERIFY + value: 'true' + - envVar: + key: DOCKER_HOST + value: tcp://localhost:2376 + - envVar: + key: JENKINS_JAVA_OPTS + value: '-Dorg.jenkinsci.plugins.durabletask.BourneShellScript.USE_BINARY_WRAPPER=true -Xlog:gc+heap+exit -XX:+HeapDumpOnOutOfMemoryError' + image: apache.jfrog.io/cassan-docker/apache/cassandra-jenkins-k8s + livenessProbe: + failureThreshold: '0' + initialDelaySeconds: '0' + periodSeconds: '0' + successThreshold: '0' + timeoutSeconds: '0' + privileged: 'true' + resourceRequestCpu: 1 + resourceLimitCpu: 2 + resourceRequestMemory: 1G + resourceLimitMemory: 1G + # the workspace emptyDir + resourceRequestEphemeralStorage: 10Gi + resourceLimitEphemeralStorage: 20Gi + ttyEnabled: 'true' + workingDir: /home/jenkins/agent + - name: dind + alwaysPullImage: 'false' + envVars: + - envVar: + key: DOCKER_TLS_CERTDIR + value: /certs + - envVar: + key: "DOCKER_IPTABLES_LEGACY" + value: "1" + image: docker:dind + args: "--default-address-pool base=192.168.96.0/20,size=24" # overwrite docker subnet in case of overlapping + livenessProbe: + failureThreshold: '0' + initialDelaySeconds: '0' + periodSeconds: '0' + successThreshold: '0' + timeoutSeconds: '0' + privileged: 'true' + resourceRequestCpu: 2 + resourceLimitCpu: 4 + resourceRequestMemory: 1G + resourceLimitMemory: 2400M + # docker's images and containers, in the docker-storage emptyDir + resourceRequestEphemeralStorage: 40Gi + resourceLimitEphemeralStorage: 60Gi + ttyEnabled: 'true' + workingDir: /home/jenkins/agent + volumes: + # /var/lib/docker is not here but in `yaml:` below, the only place it can carry a sizeLimit + - emptyDirVolume: + memory: 'false' + mountPath: /certs + # limit one agent pod per node for simpler operations (like orphan cleanup) + yaml: | + spec: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: jenkins/cassius-jenkins-agent + operator: In + values: + - "true" + topologyKey: kubernetes.io/hostname + # docker's storage, named and mounted here so that the sizeLimit survives the plugin's merge. + # 60Gi bounds the images and their containers alone, of the pod's 80Gi + # fetchDockerImages in Jenkinsfile warns as the node fills as our image sizes grow. + volumes: + - name: docker-storage + emptyDir: + sizeLimit: 60Gi + containers: + - name: dind + volumeMounts: + - name: docker-storage + mountPath: /var/lib/docker + agent-dind-medium: | + - name: agent-dind-medium + label: agent-dind cassandra-medium cassandra-amd64-medium + nodeSelector: 'cassandra.jenkins.agent.medium=true' + # 0 = no pod lifetime cap: idleMinutes reuse means a pod's age is unrelated to any one build's timeout. Orphans are reaped by agent.garbageCollection instead. + activeDeadlineSeconds: '0' + idleMinutes: 1 + # should match the medium pools 100 nodes (README's --max-nodes), i.e. one agent per node. must not be higher than max nodes possible. + instanceCap: 100 + instanceCapStr: "100" + nodeUsageMode: "NORMAL" + showRawYaml: 'true' + slaveConnectTimeout: '30' + yamlMergeStrategy: override + containers: + - name: jnlp + # https://github.com/jenkinsci/kubernetes-plugin#pipeline-support + alwaysPullImage: true + envVars: + - envVar: + key: DOCKER_TLS_CERTDIR + value: /certs/client/ + - envVar: + key: DOCKER_CERT_PATH + value: /certs/client/ + - envVar: + key: DOCKER_TLS_VERIFY + value: 'true' + - envVar: + key: DOCKER_HOST + value: tcp://localhost:2376 + - envVar: + key: JENKINS_JAVA_OPTS + value: '-Dorg.jenkinsci.plugins.durabletask.BourneShellScript.USE_BINARY_WRAPPER=true' + image: apache.jfrog.io/cassan-docker/apache/cassandra-jenkins-k8s + livenessProbe: + failureThreshold: '0' + initialDelaySeconds: '0' + periodSeconds: '0' + successThreshold: '0' + timeoutSeconds: '0' + privileged: 'true' + resourceRequestCpu: 1 + resourceLimitCpu: 3 + resourceRequestMemory: 1G + resourceLimitMemory: 2400M + # the workspace emptyDir + resourceRequestEphemeralStorage: 10Gi + resourceLimitEphemeralStorage: 20Gi + ttyEnabled: 'true' + workingDir: /home/jenkins/agent + - name: dind + alwaysPullImage: 'false' + envVars: + - envVar: + key: DOCKER_TLS_CERTDIR + value: /certs + - envVar: + key: "DOCKER_IPTABLES_LEGACY" + value: "1" + image: docker:dind + args: "--default-address-pool base=192.168.96.0/20,size=24" # overwrite docker subnet in case of overlapping + livenessProbe: + failureThreshold: '0' + initialDelaySeconds: '0' + periodSeconds: '0' + successThreshold: '0' + timeoutSeconds: '0' + privileged: 'true' + resourceRequestCpu: 2 + resourceLimitCpu: 4 + resourceRequestMemory: 3400M + resourceLimitMemory: 5G + # docker's images and containers, in the docker-storage emptyDir + resourceRequestEphemeralStorage: 40Gi + resourceLimitEphemeralStorage: 60Gi + ttyEnabled: 'true' + workingDir: /home/jenkins/agent + volumes: + # /var/lib/docker is not here but in `yaml:` below, the only place it can carry a sizeLimit + - emptyDirVolume: + memory: 'false' + mountPath: /certs + # limit one agent pod per node for simpler operations (like orphan cleanup) + yaml: | + spec: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: jenkins/cassius-jenkins-agent + operator: In + values: + - "true" + topologyKey: kubernetes.io/hostname + # docker's storage, named and mounted here so that the sizeLimit survives the plugin's merge. + # 60Gi bounds the images and their containers alone, of the pod's 80Gi + # fetchDockerImages in Jenkinsfile warns as the node fills as our image sizes grow. + volumes: + - name: docker-storage + emptyDir: + sizeLimit: 60Gi + containers: + - name: dind + volumeMounts: + - name: docker-storage + mountPath: /var/lib/docker + agent-dind-large: | + - name: agent-dind-large + label: agent-dind cassandra-large cassandra-amd64-large cassandra-amd64-large-dedicated + nodeSelector: 'cassandra.jenkins.agent.large=true' + # 0 = no pod lifetime cap. A microbench cell runs up to 6h (timeout_hours in the Jenkinsfile) + # and idleMinutes reuse extends a pod's age past any one build, so age cannot stand in for + # health here. Orphans are reaped by agent.garbageCollection instead. + activeDeadlineSeconds: '0' + idleMinutes: 1 + # should match the large pools 160 nodes (README's --max-nodes), i.e. one agent per node. must not be higher than max nodes possible. + instanceCap: 160 + instanceCapStr: "160" + nodeUsageMode: "NORMAL" + showRawYaml: 'true' + slaveConnectTimeout: '30' + yamlMergeStrategy: override + containers: + - name: jnlp + # https://github.com/jenkinsci/kubernetes-plugin#pipeline-support + alwaysPullImage: true + envVars: + - envVar: + key: DOCKER_TLS_CERTDIR + value: /certs/client/ + - envVar: + key: DOCKER_CERT_PATH + value: /certs/client/ + - envVar: + key: DOCKER_TLS_VERIFY + value: 'true' + - envVar: + key: DOCKER_HOST + value: tcp://localhost:2376 + - envVar: + key: JENKINS_JAVA_OPTS + value: '-Dorg.jenkinsci.plugins.durabletask.BourneShellScript.USE_BINARY_WRAPPER=true' + image: apache.jfrog.io/cassan-docker/apache/cassandra-jenkins-k8s + livenessProbe: + failureThreshold: '0' + initialDelaySeconds: '0' + periodSeconds: '0' + successThreshold: '0' + timeoutSeconds: '0' + privileged: 'true' + resourceRequestCpu: 1 + resourceLimitCpu: 3 + resourceRequestMemory: 1G + resourceLimitMemory: 2G + # the workspace emptyDir + resourceRequestEphemeralStorage: 10Gi + resourceLimitEphemeralStorage: 20Gi + ttyEnabled: 'true' + workingDir: /home/jenkins/agent + - name: dind + alwaysPullImage: 'false' + envVars: + - envVar: + key: DOCKER_TLS_CERTDIR + value: /certs + - envVar: + key: "DOCKER_IPTABLES_LEGACY" + value: "1" + image: docker:dind + args: "--default-address-pool base=192.168.96.0/20,size=24" # overwrite docker subnet in case of overlapping + livenessProbe: + failureThreshold: '0' + initialDelaySeconds: '0' + periodSeconds: '0' + successThreshold: '0' + timeoutSeconds: '0' + privileged: 'true' + resourceRequestCpu: 6 + resourceLimitCpu: 7 + resourceRequestMemory: 16G + resourceLimitMemory: 30G + # docker's images and containers, in the docker-storage emptyDir + resourceRequestEphemeralStorage: 40Gi + resourceLimitEphemeralStorage: 60Gi + ttyEnabled: 'true' + workingDir: /home/jenkins/agent + volumes: + # /var/lib/docker is not here but in `yaml:` below, the only place it can carry a sizeLimit + - emptyDirVolume: + memory: 'false' + mountPath: /certs + # limit one agent pod per node for simpler operations (like orphan cleanup) + yaml: | + spec: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: jenkins/cassius-jenkins-agent + operator: In + values: + - "true" + topologyKey: kubernetes.io/hostname + # docker's storage, named and mounted here so that the sizeLimit survives the plugin's merge. + # 60Gi bounds the images and their containers alone, of the pod's 80Gi + # fetchDockerImages in Jenkinsfile warns as the node fills as our image sizes grow. + volumes: + - name: docker-storage + emptyDir: + sizeLimit: 60Gi + containers: + - name: dind + volumeMounts: + - name: docker-storage + mountPath: /var/lib/docker + + diff --git a/.jenkins/k8s/jenkins-test.sh b/.jenkins/k8s/jenkins-test.sh new file mode 100755 index 000000000000..eef4f47f7e3d --- /dev/null +++ b/.jenkins/k8s/jenkins-test.sh @@ -0,0 +1,117 @@ +#!/bin/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. + +# +# Validates the CI declarations under .jenkins/ without deploying anything: +# - the Jenkinsfile parses as groovy +# - jenkins-deployment.yaml renders through the jenkins helm chart +# - the yaml embedded in it (agent pod templates, JCasC config scripts) parses +# +# Requires: helm, python3 with pyyaml, and either groovy or docker. +# Run from anywhere: .jenkins/k8s/jenkins-test.sh + +set -e + +CASSANDRA_DIR="$(cd "$(dirname "$0")/../.." > /dev/null && pwd)" +JENKINS_DIR="${CASSANDRA_DIR}/.jenkins" +status=0 + +command -v helm > /dev/null || { echo "helm must be installed and in the PATH"; exit 1; } +command -v python3 > /dev/null || { echo "python3 must be installed and in the PATH"; exit 1; } +python3 -c "import yaml" 2> /dev/null || { echo "python3 pyyaml must be installed: pip install pyyaml"; exit 1; } + +echo "== Jenkinsfile groovy syntax" +# Phases.CONVERSION parses and builds the AST without resolving the pipeline DSL or @NonCPS, +# neither of which exist outside a jenkins controller +syntax_check_dir="$(mktemp -d)" +syntax_check="${syntax_check_dir}/syntax-check.groovy" +# mktemp gives 0700, which the unprivileged user inside the groovy image cannot traverse +chmod 755 "${syntax_check_dir}" +cat > "${syntax_check}" << 'EOF' +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.Phases + +def cu = new CompilationUnit() +args.each { cu.addSource(new File(it)) } +cu.compile(Phases.CONVERSION) +println " ${args.join(', ')} parses" +EOF +if command -v groovy > /dev/null ; then + groovy "${syntax_check}" "${JENKINS_DIR}/Jenkinsfile" || status=1 +elif command -v docker > /dev/null ; then + # absolute paths, the image's working directory is not where the script was mounted + docker run --rm -v "${syntax_check_dir}:/check:ro" -v "${JENKINS_DIR}:/jenkins:ro" \ + groovy:4.0-jdk17 groovy /check/syntax-check.groovy /jenkins/Jenkinsfile || status=1 +else + echo " SKIPPED: neither groovy nor docker found" +fi + +echo "== jenkins-deployment.yaml renders through the helm chart" +helm repo add jenkins https://charts.jenkins.io > /dev/null +helm repo update > /dev/null +# --namespace and a release name only so the chart's templates have something to interpolate +helm template cassius jenkins/jenkins --namespace default -f "${JENKINS_DIR}/k8s/jenkins-deployment.yaml" > /dev/null \ + && echo " jenkins-deployment.yaml renders" || status=1 + +echo "== yaml embedded in jenkins-deployment.yaml" +python3 - "${JENKINS_DIR}/k8s" << 'EOF' || status=1 +import sys, yaml +from pathlib import Path + +k8s_dir = Path(sys.argv[1]) +errors = 0 + +for path in sorted(k8s_dir.glob("*.yaml")): + try: + values = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as error: + print(f" INVALID {path.name}: {error}") + errors += 1 + continue + print(f" {path.name} parses") + + # the values in these two maps are themselves yaml documents, and a chart never validates them — + # a misindented pod template reaches the kubernetes plugin and silently loses its agents + for keys in (("agent", "podTemplates"), ("controller", "JCasC", "configScripts")): + embedded = values + for key in keys: + embedded = embedded.get(key, {}) if isinstance(embedded, dict) else {} + for name, document in (embedded or {}).items(): + location = f"{path.name} {'.'.join(keys)}.{name}" + try: + parsed = yaml.safe_load(document) + except yaml.YAMLError as error: + print(f" INVALID {location}: {error}") + errors += 1 + continue + print(f" {location} parses") + # each pod template may carry a raw kubernetes pod spec in a nested `yaml` key + for template in parsed if isinstance(parsed, list) else []: + if isinstance(template, dict) and "yaml" in template: + try: + yaml.safe_load(template["yaml"]) + except yaml.YAMLError as error: + print(f" INVALID {location}.yaml: {error}") + errors += 1 + else: + print(f" {location}.yaml parses") + +sys.exit(1 if errors else 0) +EOF + +[ 0 -eq ${status} ] && echo "== all .jenkins/ checks passed" || echo "== FAILED" +exit ${status} diff --git a/build.xml b/build.xml index e2aa0c823692..7328b3639164 100644 --- a/build.xml +++ b/build.xml @@ -33,6 +33,12 @@ + + + + diff --git a/pylib/cassandra-cqlsh-tests.sh b/pylib/cassandra-cqlsh-tests.sh index 2812b7073fcf..c4a1332769ca 100755 --- a/pylib/cassandra-cqlsh-tests.sh +++ b/pylib/cassandra-cqlsh-tests.sh @@ -71,7 +71,13 @@ fi # Set up venv with dtest dependencies set -e # enable immediate exit if venv setup fails -virtualenv --python=$PYTHON_VERSION venv +# Use the stdlib venv module (python3.X-venv is installed for every python in the image) +# instead of the `virtualenv` tool: the image's apt virtualenv is distro-patched to never +# download wheels and is too old to seed the newest pythons, and mixing a pip-installed +# virtualenv with the image's own dist-info entrypoints is version-minefield. ensurepip +# seeds a usable pip without any network access; the get-pip step below then brings the +# venv's pip up to the newest release supporting this python. +$PYTHON_VERSION -m venv venv source venv/bin/activate # 3.11 needs the newest pip, 3.8 and older have specific legacy get-pip urls PYTHON_MAJOR_MINOR=$($PYTHON_VERSION -V 2>&1 | awk '{print $2}' | cut -d. -f1,2) @@ -81,7 +87,18 @@ else curl -sS https://bootstrap.pypa.io/get-pip.py | $PYTHON_VERSION fi -pip install -r ${CASSANDRA_DIR}/pylib/requirements.txt +# current setuptools no longer ships pkg_resources, which these old-style (setup.py + +# ez_setup.py) git packages need: build against a venv-local setuptools that still has it, +# instead of pip's isolated build env (which would fetch the latest, broken-for-this one). +# The git requirements are also installed non-editable: cassandra-driver predates PEP 660, +# and old pips (3.8's get-pip) fall back to `setup.py develop` for editable VCS installs, +# which breaks against ccm's packaging<21 pin. pbr and Cython are the packages' declared +# build requirements (normally fetched into pip's isolated build env, which we bypass). +pip install "setuptools>=64,<81" pbr "Cython>=0.29.15,<3.0" +while IFS= read -r git_req ; do + [ -n "${git_req}" ] && pip install --no-build-isolation "$(echo "${git_req}" | sed 's/^-e[[:space:]]*//')" +done < <(grep '^-e git' ${CASSANDRA_DIR}/pylib/requirements.txt) +pip install --no-build-isolation -r <(grep -v '^-e git' ${CASSANDRA_DIR}/pylib/requirements.txt) pip freeze if [ "$cython" = "yes" ]; then diff --git a/pylib/requirements.txt b/pylib/requirements.txt index f28412e59a82..4e778b5899ae 100644 --- a/pylib/requirements.txt +++ b/pylib/requirements.txt @@ -16,8 +16,13 @@ # # # - --e git+https://github.com/apache/cassandra-python-driver.git@3.25.0#egg=cassandra-driver +# See python driver docs: six have to be installed before +# cythonizing the driver, perhaps only on old pips. +# http://datastax.github.io/python-driver/installation.html#cython-based-extensions +six>=1.12.0 +# 3.25.0 (2017) cannot be built with the current pip/setuptools in the shared CI test +# image (its ez_setup.py path is broken); 3.29.0 is what the cassandra-5.0 CI pins. +-e git+https://github.com/apache/cassandra-python-driver.git@3.29.0#egg=cassandra-driver # Used ccm version is tracked by cassandra-test branch in ccm repo. Please create a PR there for fixes or upgrades to new releases. -e git+https://github.com/apache/cassandra-ccm.git@cassandra-test#egg=ccm coverage