diff --git a/.buildkite/hooks/pre-exit b/.buildkite/hooks/pre-exit new file mode 100644 index 00000000..0fab59ce --- /dev/null +++ b/.buildkite/hooks/pre-exit @@ -0,0 +1,11 @@ +# This hook ensures that we don't leave qdbd cluster running even if one of the steps in the pipeline fails. +# It will stop the cluster if it is running, and ignore any errors that may occur during the stopping process. + +SCRIPT_PATH="scripts/tests/setup/stop-services.sh" + +if [ -f "$SCRIPT_PATH" ]; then + bash "$SCRIPT_PATH" > /dev/null 2>&1 || true + echo "Stopped services" +fi + +exit 0 diff --git a/.buildkite/pipeline.py b/.buildkite/pipeline.py new file mode 100644 index 00000000..d3018c75 --- /dev/null +++ b/.buildkite/pipeline.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Buildkite dynamic pipeline generator for qdb-api-python. + +Step templates in steps/*.yml define nearly-complete Buildkite steps with +{placeholder} variables. This script loads them, substitutes variables, and +overlays environment variables and the Docker plugin per platform. + +Usage: + python3 pipeline.py # emit pipeline YAML to stdout + python3 pipeline.py check # validate without emitting +""" +from __future__ import annotations + +import dataclasses +import sys +from pathlib import Path + +from buildkite_sdk import Pipeline, GroupStep + +sys.path.insert(0, str(Path(__file__).parent / "tools")) +from qdb_pipeline import ( + Platform, + apply_docker_compose, + load_template, + merge_env, + select_platforms, + validate_pipeline, + get_git_ref, + set_artifact_plugin_options, +) # noqa: E402 + +STEPS_DIR = Path(__file__).parent / "steps" + +# Quasardb-specific toolchain overlays on top of shared infrastructure platforms. +_LINUX = dict() +_WIN = dict() +_MACOS = dict() + +_OS_OVERLAY = {"linux": _LINUX, "windows": _WIN, "macos": _MACOS} +PLATFORMS: list[Platform] = [ + dataclasses.replace(p, **_OS_OVERLAY.get(p.os, {})) + for p in select_platforms( + "linux-amd64-core2", + "linux-aarch64", + "windows-amd64-core2", + "macos-aarch64", + ) +] + +BUILD_TYPES = ["Release", "Debug"] + +PYTHON_VERSIONS = [ + "3.9", + "3.10", + "3.11", + "3.12", + "3.13", + "3.14", +] + +# Environment variable layering: global → step → os → os+step → platform compilers. +GLOBAL_ENV: dict[str, str] = { + "AWS_DEFAULT_REGION": "eu-west-1", + "JUNIT_XML_FILE": "build/test/pytest.xml", + "QDB_ENCRYPT_TRAFFIC": "1", +} + +STEP_ENV: dict[str, dict[str, str]] = {} + +OS_ENV: dict[str, dict[str, str]] = { + "linux": { + "PYTHON_EXECUTABLE": "/usr/bin/python3", + "PYTHON_CMD": "python3", + }, + "freebsd": { + "PYTHON_EXECUTABLE": "/usr/bin/python3", + "PYTHON_CMD": "python3", + }, + "macos": {}, + "windows": {}, +} + +OS_STEP_ENV: dict[str, dict[str, str]] = {} + +CPU_ENV: dict[str, dict[str, str]] = { + "aarch64": {"ARCH": "aarch64"}, +} + + +def _env(p: Platform, step_name: str, build_type: str) -> dict[str, str]: + """Compose the full environment dict for one step.""" + return merge_env( + GLOBAL_ENV, + STEP_ENV.get(step_name, {}), + OS_ENV.get(p.os, {}), + OS_STEP_ENV.get(f"{p.os}/{step_name}", {}), + CPU_ENV.get(p.cpu, {}), + {"CMAKE_BUILD_TYPE": build_type}, + platform=p, + ) + + +def _get_agent_python_env(platform: Platform, python_version: str) -> dict[str, str]: + """ + Returns environment variables to set for Python executable on the agent, based on platform and python version. + Applies to Windows and macOS where we have multiple Python versions installed in different locations. + """ + python_version_slug = python_version.replace(".", "") + if platform.os == "windows": + return { + "PYTHON_EXECUTABLE": f"$$QDB_CICD_AGENT_PYTHON_{python_version_slug}_64_EXE", + "PYTHON_CMD": f"$$QDB_CICD_AGENT_PYTHON_{python_version_slug}_64_EXE", + } + elif platform.os == "macos": + return { + "PYTHON_EXECUTABLE": f"$$QDB_CICD_AGENT_PYTHON_{python_version_slug}_PATH", + } + return {} + + +def _apply_doc_command(step: dict, platform: Platform) -> None: + """ + Adds a command to the step to generate documentation using pdoc to linux-amd64-core2 platform builds. + """ + if platform.os == "linux" and platform.arch == "amd64" and platform.cpu == "core2": + doc_commands = [ + 'echo "+++ Build documentation"', + "bash scripts/teamcity/30.doc.sh", + ] + existing_commands = step.get("commands", []) + existing_commands += doc_commands + + +def generate_pipeline() -> Pipeline: + """Load templates, expand across platforms × build_types, overlay env and docker.""" + pipeline = Pipeline() + git_ref = get_git_ref() + group_steps = {} + + for p in PLATFORMS: + for bt in BUILD_TYPES: + for py in PYTHON_VERSIONS: + slug = p.slug(bt.lower(), f"py{py.replace('.', '')}") + + # We want to use Release QuasarDB binaries when building Python API (debug and release) + dependency_slug = p.slug("release") + + tvars = { + "slug": slug, + "queue": f"{p.queue_os}-{p.arch}", + "name": slug.replace("-", " ").title(), + } + + artifact_vars_per_step = { + "upload": {"variant": slug, "git-ref": git_ref}, + "promote": {"variant": slug, "git-ref": git_ref}, + "download": { + "variant": dependency_slug, + "git-ref": git_ref, + }, + } + + compose_config = { + "run": "pypa", + "config": "docker/docker-compose.yml", + "propagate-uid-gid": True, + } + + step = load_template(STEPS_DIR / "_build.yml", **tvars) + env = _env(p, "test", bt) + env.update(step.get("env") or {}) + env.update({"PYTHON_VERSION": py}) + env.update(_get_agent_python_env(p, py)) + step["env"] = env + if p.os == "linux": + apply_docker_compose(step, config=compose_config) + set_artifact_plugin_options(step, artifact_vars_per_step) + _apply_doc_command(step, p) + + # add step to group + group_name = p.slug(bt.lower()).replace("-", " ").title() + if group_name not in group_steps: + group_steps[group_name] = [] + group_steps[group_name].append(step) + + # create groups and add to pipeline + for group, steps in group_steps.items(): + group_step = GroupStep(group=group, steps=steps) + pipeline.add_step(group_step) + + return pipeline + + +def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else "generate" + + try: + pipeline = generate_pipeline() + except Exception as e: + print(f"[FAIL] Pipeline generation failed: {e}", file=sys.stderr) + sys.exit(1) + + if command == "generate": + print(pipeline.to_yaml()) + elif command == "check": + errors = validate_pipeline(pipeline) + if errors: + for e in errors: + print(f"[FAIL] {e}", file=sys.stderr) + sys.exit(1) + print(f"[OK] Pipeline valid: {len(pipeline.steps)} steps") + else: + print(f"Unknown command: {command}", file=sys.stderr) + print("Usage: pipeline.py [generate|check]", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.buildkite/requirements.txt b/.buildkite/requirements.txt new file mode 100644 index 00000000..971d0fe7 --- /dev/null +++ b/.buildkite/requirements.txt @@ -0,0 +1,2 @@ +buildkite-sdk==0.8.0 +-r tools/requirements.txt \ No newline at end of file diff --git a/.buildkite/steps/_build.yml b/.buildkite/steps/_build.yml new file mode 100644 index 00000000..10702b06 --- /dev/null +++ b/.buildkite/steps/_build.yml @@ -0,0 +1,36 @@ +agents: + queue: "default-{queue}" + +retry: + automatic: + limit: 3 + +label: "{name} ({slug})" +key: "build-{slug}" + +commands: + - echo \"+++ Start Services\" + - bash scripts/tests/setup/start-services.sh + - echo \"+++ Run Tests\" + - bash scripts/cicd/20.test.sh + - echo \"+++ Stop Services\" + - bash scripts/tests/setup/stop-services.sh + - echo \"+++ Run build\" + - bash scripts/cicd/10.build.sh + +plugins: + - bureau14/qdb-artifacts#master: + download: + project_id: "quasardb" + output-dir: "qdb" + extract: true + clean: true + files: + - "*-c-api.tar.zst!*" + - "*-server.tar.zst!*" + - "*-utils.tar.zst!*" + upload: + files: + - "dist/quasardb-*.whl" + - "dist/doc.tar.gz" + promote: {} diff --git a/.buildkite/tools b/.buildkite/tools new file mode 160000 index 00000000..476f355f --- /dev/null +++ b/.buildkite/tools @@ -0,0 +1 @@ +Subproject commit 476f355fa0955dcac43ff4fdd8b6675ce7280f77 diff --git a/.gitmodules b/.gitmodules index d97d4dac..068c910f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = scripts/tests/setup url = https://github.com/bureau14/qdb-test-setup branch = master +[submodule ".buildkite/tools"] + path = .buildkite/tools + url = https://github.com/bureau14/qdb-cicd-tools.git diff --git a/docker/Dockerfile b/docker/Dockerfile index 78905214..85f3d340 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,6 +6,22 @@ FROM quay.io/pypa/$PLATFORM:${TAG} ARG PYTHON_VERSION -ADD set-python-version.sh /set-python-version.sh -RUN bash /set-python-version.sh ${PYTHON_VERSION} \ - && rm /set-python-version.sh +# We need to ensure that the container user has the same UID and GID as the host buildkite agent to use the `propagate-uid-gid` feature of the docker plugin. +# The default UID, GID matches the default UID, GID of the buildkite agent on the host, if needed can be overridden +ARG USER_ID=929 +ARG GROUP_ID=929 +ARG USERNAME=builder +ARG HOME=/home/${USERNAME} +ARG COMMENT=builder + +# Install packages required for running start-services inside the container +RUN yum install lsof -y + +RUN groupadd --gid $GROUP_ID $USERNAME +RUN useradd --comment "$COMMENT" --home-dir $HOME --create-home --system --uid $USER_ID --gid $GROUP_ID $USERNAME + +USER $USERNAME + +ADD --chown=$USERNAME:$USERNAME set-python-version.sh ${HOME}/set-python-version.sh +RUN bash ${HOME}/set-python-version.sh ${PYTHON_VERSION} \ + && rm ${HOME}/set-python-version.sh diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..7dc4b299 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,11 @@ +services: + pypa: + build: + context: . + dockerfile: Dockerfile + args: + - PYTHON_VERSION=${PYTHON_VERSION} + - ARCH=${ARCH-x86_64} + volumes: + - ../:/workdir + working_dir: /workdir diff --git a/scripts/cicd/00.common.sh b/scripts/cicd/00.common.sh new file mode 100755 index 00000000..4b5cf38b --- /dev/null +++ b/scripts/cicd/00.common.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +if [ -f $HOME/.bashrc ] +then + source $HOME/.bashrc +fi diff --git a/scripts/cicd/10.build.sh b/scripts/cicd/10.build.sh new file mode 100755 index 00000000..38663e4e --- /dev/null +++ b/scripts/cicd/10.build.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null && pwd)" + +source ${SCRIPT_DIR}/00.common.sh + +git config --global --add safe.directory '*' + +# No more errors should occur after here +set -e -u -x + +PYTHON="${PYTHON_CMD:-python3}" + +# Now use a virtualenv to run the tests. If the virtualenv already exists, we remove +# it to ensure a clean install. +${PYTHON} -m venv --clear ${SCRIPT_DIR}/../../.env/ +if [[ "$(uname)" == MINGW* ]] +then + VENV_PYTHON="${SCRIPT_DIR}/../../.env/Scripts/python.exe" +else + VENV_PYTHON="${SCRIPT_DIR}/../../.env/bin/python" +fi + +${VENV_PYTHON} --version + +function relabel_wheel { + wheel="$1" + + if ! ${VENV_PYTHON} -m auditwheel show "$wheel" + then + echo "Skipping non-platform specific wheel $wheel" + else + # ${AUDITWHEEL_PLAT} is defined in manylinux base docker image + ${VENV_PYTHON} -m auditwheel repair "$wheel" --plat "$AUDITWHEEL_PLAT" -w dist/ + rm "$wheel" + fi +} + +rm -r -f build/ dist/ + +if [[ "$OSTYPE" == "darwin"* && $PYTHON == "python3.9"* ]]; then + ${VENV_PYTHON} -m pip install --upgrade setuptools==63.0.0b1 wheel +else + ${VENV_PYTHON} -m pip install --upgrade setuptools wheel auditwheel +fi + +${VENV_PYTHON} -m pip install -r dev-requirements.txt + +export DISTUTILS_DEBUG=1 +export QDB_TESTS_ENABLED=OFF + +${VENV_PYTHON} -m build -w + +for whl in dist/*.whl; do + relabel_wheel "$whl" +done diff --git a/scripts/cicd/20.test.sh b/scripts/cicd/20.test.sh new file mode 100755 index 00000000..ea3512f8 --- /dev/null +++ b/scripts/cicd/20.test.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null && pwd)" + +source ${SCRIPT_DIR}/00.common.sh + +git config --global --add safe.directory '*' + +set -u -x + +PYTHON="${PYTHON_CMD:-python3}" + +### +# NOTE(leon): +### +# +# EVIL CODE SECTION BELOW. +# +# Looking at this will make you angry. +# Studying it will make you cry. +# Understanding it will bring enlightenment. +# Using it will enable massive parallelism for builds on Windows by using Ninja. +# +# ~~~ +# +# The problem is solves: we want to use Ninja for Windows. For this, we need to set a whole +# bunch of environment variables. Microsoft helpfully provided a batch script called 'VsDevCmd.bat' +# which does exactly this. +# +# But herein lies the problem: we cannot just "source" a windows batch script from mingw/bash. +# We can use cmd to invoke it, but then it exits and the environment variables are immediately lost. +# +# The evil code below solves this by effectively invoking the VsDevCmd.bat, and then storing the +# entire environment in a file. Then we read this environment file, and export all the env keys. +# +# `evil_inner` is responsible for invoking the VsDevCmd and printing out all the environment +# variables to the console. +# +# `evil_outer` is responsible for capturing environment variables before, after, and setting all +# the changed variables. + +function evil_inner { + local arch=$1; shift; + + # Because VsDevCmd.bat prints some garbage data, we want to skip the first X rows. Basically + # what I did was audit manually the type of regex that actually matches all environment data. + # Then, because we know 100% sure after the first row is matched, the rest is also going to + # be from printenv, we just tell grep "print the 5000 lines after your first match". This + # avoids a scenario that if for some stupid reason a new env var is introduced which doesn't + # match the regex, it still works. + + (cd /c/Program\ Files\ \(x86\)/Microsoft\ Visual\ Studio/2022/BuildTools/Common7/Tools/ \ + && cmd //C "VsDevCmd.bat -host_arch=amd64 -arch=$arch && bash -c printenv" \ + | grep -A5000 -m1 -E '^([a-zA-Z0-9_\(\):]+)=' ) +} + +function evil_outer { + local arch=$1; shift; + + before=$(mktemp) + after=$(mktemp) + added=$(mktemp) + + printenv | sort > $before + evil_inner $arch | sort > $after + + # Keep only those lines that are added/different in the $after file, and save them in a file. + # Yes, I too did not know what `comm` was until I needed it, and it basically compares two sorted + # files line-by-line, which is exactly what we need here. + + comm -13 $before $after > $added + + while read -r LINE; do + + local key=$(cut -d '=' -f 1 <<< "$LINE" ) + local value=$(cut -d '=' -f 2- <<< "$LINE" ) + + ### + # XXX(leon): + ### + # + # It seems MSVC sets a few very weird environment variables with keys: + # - !C:=C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\Tools + # - !ExitCode=00000000 + # + # I have no idea how to properly export these, but it doesn't appear to matter a lot. I *suspect* + # that they're really some internal environment variables that represent the current working dir + # and/or exit code of a program, and are not intended to be set. + # + # However, this will print out warnings. These can be safely ignored. + + export ${key}="${value}" + done < $added +} + +### +# +# EVIL CODE SECTION ABOVE. NO MORE EVIL CODE BELOW THIS LINE. +# +### + +if [[ "$(uname)" == MINGW* ]] +then + ARCH_BITS=$(${PYTHON} -c 'import struct;print( 8 * struct.calcsize("P"))') + echo "Windows build detected, target arch with bits: ${ARCH_BITS}" + + if [[ "${ARCH_BITS}" == "32" ]] + then + echo "Targeting win32" + evil_outer x86 + elif [[ "${ARCH_BITS}" == "64" ]] + then + echo "Targeting win64" + evil_outer amd64 + else + echo "Internal error: 'ARCH_BITS' is unrecognized: ${ARCH_BITS}" + exit -1 + fi +fi + +# No more errors should occur after here +set -e -o pipefail + +if [[ -d "dist/" ]] +then + echo "Removing dist/" + rm -rf dist/ +fi + +if [[ -d "build/" ]] +then + echo "Warning: build/ directory already exists, assuming incremental compilation, reusing build artifacts" +fi + +# Now use a virtualenv to run the tests. If the virtualenv already exists, we reuse it +# to avoid frequent rebuilds. + +if [[ -d "${SCRIPT_DIR}/../../.env/" ]] +then + echo "virtualenv already exists, skip creating new venv" +else + echo "Creating new virtualenv" + ${PYTHON} -m venv --clear ${SCRIPT_DIR}/../../.env/ +fi + +if [[ "$(uname)" == MINGW* ]] +then + VENV_PYTHON="${SCRIPT_DIR}/../../.env/Scripts/python.exe" +else + VENV_PYTHON="${SCRIPT_DIR}/../../.env/bin/python" +fi + +${VENV_PYTHON} -m pip install --upgrade -r dev-requirements.txt + +export QDB_TESTS_ENABLED=ON +${VENV_PYTHON} -m build -w + +${VENV_PYTHON} -m pip install --no-deps --force-reinstall dist/quasardb-*.whl + +echo "Invoking pytest" + +TEST_OPTS="$@" +if [[ ! -z ${JUNIT_XML_FILE-} ]] +then + TEST_OPTS+=" --junitxml=${JUNIT_XML_FILE}" +fi + +pushd tests +exec ${VENV_PYTHON} -m pytest ${TEST_OPTS} +popd diff --git a/scripts/cicd/30.doc.sh b/scripts/cicd/30.doc.sh new file mode 100755 index 00000000..526de99b --- /dev/null +++ b/scripts/cicd/30.doc.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null && pwd)" + +source ${SCRIPT_DIR}/00.common.sh + +set -u -x + +PYTHON="${PYTHON_CMD:-python3}" + +${PYTHON} -m venv --clear ${SCRIPT_DIR}/../../.env/ +if [[ "$(uname)" == MINGW* ]] +then + VENV_PYTHON="${SCRIPT_DIR}/../../.env/Scripts/python.exe" +else + VENV_PYTHON="${SCRIPT_DIR}/../../.env/bin/python" +fi + +${VENV_PYTHON} -m pip install -r dev-requirements.txt +${VENV_PYTHON} -m pip install --no-deps --force-reinstall dist/quasardb-*manylinux*.whl +${VENV_PYTHON} -m pip install --upgrade pdoc3==0.11.5 + + +# To avoid conflicts with `quasardb` directory and `import quasardb` +rm -rf doc/build || true + +mkdir doc/build +pushd doc + +${VENV_PYTHON} docgen.py + +popd + +tar -czvf dist/doc.tar.gz -C doc/build . diff --git a/scripts/teamcity/10.build.sh b/scripts/teamcity/10.build.sh index 100033ad..38663e4e 100755 --- a/scripts/teamcity/10.build.sh +++ b/scripts/teamcity/10.build.sh @@ -32,6 +32,7 @@ function relabel_wheel { else # ${AUDITWHEEL_PLAT} is defined in manylinux base docker image ${VENV_PYTHON} -m auditwheel repair "$wheel" --plat "$AUDITWHEEL_PLAT" -w dist/ + rm "$wheel" fi }