blob: 952e69fb5e246f3ff805813445081be75546826d [file]
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
source "$(dirname "${BASH_SOURCE[0]}")/lib/init.sh"
# storage-compat.sh -- Prove HEAD still reads a data directory written by master.
#
# Builds two iggy-server binaries and hands both to one integration test:
# baseline built from --baseline-ref, copied aside. Default: on a
# pull_request run, the master tip under the checked-out merge ref;
# anywhere else, origin/master.
# HEAD built from the working tree, left at target/debug/iggy-server
# The test boots the baseline, seeds a data directory, swaps the binary to HEAD
# and restarts against that same directory.
#
# Both binaries MUST be built here. `core/integration` has no dependency on the
# `server` package; the harness only LOCATES a binary, through
# `assert_cmd::Command::cargo_bin`, which falls back to whatever file happens to
# sit at target/debug/iggy-server (assert_cmd 2.2.2 `legacy_cargo_bin`). So
# `cargo nextest run -p integration` compiles no server at all, and a lane that
# does not build both halves compares a stale binary against itself and reports
# green forever.
#
# Exit codes: 0 = compatible, non-zero = build failure or format regression.
# Empty selects the default described above once HEAD is known.
BASELINE_REF=""
REBUILD_BASELINE=0
# The single #[ignore]d test this script exists to drive. The integration crate
# is one binary built from tests/mod.rs, so tests are selected by module path.
TEST_FILTER="data_integrity::storage_compat"
while [[ $# -gt 0 ]]; do
case "$1" in
--baseline-ref)
BASELINE_REF="${2:-}"
if [ -z "${BASELINE_REF}" ]; then
echo "--baseline-ref requires a git ref"
exit 1
fi
shift 2
;;
--rebuild-baseline)
REBUILD_BASELINE=1
shift
;;
--help|-h)
echo "Usage: $0 [--baseline-ref <ref>] [--rebuild-baseline]"
echo ""
echo "Options:"
echo " --baseline-ref <ref> Ref to build the baseline server from (default: the merge ref's"
echo " first parent on a pull_request run, origin/master otherwise)"
echo " --rebuild-baseline Rebuild the baseline even when its binary is already on disk"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Absolute before any cd: cargo resolves a relative CARGO_TARGET_DIR against
# its own CWD, and the baseline below builds from a worktree elsewhere on
# disk, so a relative value would scatter the two builds and the lookup of
# either binary across three directories. Exported so every cargo call here,
# nextest included, lands in the same place -- except the baseline build, which
# must have a target directory of its own (see the worktree build below).
TARGET_DIR="${CARGO_TARGET_DIR:-${REPO_ROOT}/target}"
mkdir -p "${TARGET_DIR}"
TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)"
export CARGO_TARGET_DIR="${TARGET_DIR}"
cd "${REPO_ROOT}"
if ! command -v cargo-nextest &>/dev/null; then
echo "cargo-nextest is not installed"
echo ""
echo "Install with:"
echo " cargo install cargo-nextest --locked"
exit 1
fi
HEAD_SERVER="${TARGET_DIR}/debug/iggy-server"
WORKTREE_DIR=""
# Every command is guarded: under `set -e` a failure inside an EXIT trap would
# replace the script's real exit status with the trap's.
cleanup() {
if [ -n "${WORKTREE_DIR}" ]; then
git worktree remove --force "${WORKTREE_DIR}" 2>/dev/null || rm -rf "${WORKTREE_DIR}" || true
fi
git worktree prune 2>/dev/null || true
}
trap cleanup EXIT
HEAD_SHA="$(git rev-parse --verify HEAD)"
# On a pull_request run actions/checkout leaves HEAD at the synthetic
# refs/pull/N/merge commit, whose first parent is the exact master tip GitHub
# merged the PR onto: an ancestor of HEAD by construction. Live origin/master
# can already be newer by the time this step runs, and a baseline HEAD does
# not contain would blame the PR for master's own changes. Read from the raw
# object: in the depth-1 checkout the parent is a shallow boundary, so
# `HEAD^1` does not resolve. Two parents required, so a checkout pinned to the
# PR head instead of the merge ref falls through rather than picking the PR's
# own parent.
if [ -z "${BASELINE_REF}" ]; then
if [[ "${GITHUB_REF:-}" =~ ^refs/pull/[0-9]+/merge$ ]] \
&& [ "$(git cat-file -p HEAD | grep -c '^parent ')" -eq 2 ]; then
BASELINE_REF="$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}')"
echo "Pull request run: baseline is the master tip under the merge ref"
else
BASELINE_REF="origin/master"
fi
fi
# The remote side of a fetch takes a branch or tag name (or a reachable SHA),
# never a remote-tracking name, so drop the remote prefix before asking origin.
#
# --depth=1 only where the clone is already shallow (the CI checkout). On a
# full clone that flag does not save anything, it WRITES a shallow boundary at
# the fetched tip and grafts every older commit off the developer's history,
# for every worktree sharing the repository.
FETCH_DEPTH=()
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
FETCH_DEPTH=(--depth=1)
fi
FETCHED=0
if git fetch --no-tags "${FETCH_DEPTH[@]}" origin "${BASELINE_REF#origin/}" 2>/dev/null; then
FETCHED=1
fi
BASELINE_SHA=""
if [ "${FETCHED}" -eq 1 ]; then
# FETCH_HEAD in preference to the named ref: actions/checkout narrows
# remote.origin.fetch on a shallow clone, so refs/remotes/origin/master can
# stay absent or stale straight through a successful fetch.
BASELINE_SHA="$(git rev-parse --verify --quiet "FETCH_HEAD^{commit}" || true)"
fi
if [ -z "${BASELINE_SHA}" ]; then
BASELINE_SHA="$(git rev-parse --verify --quiet "${BASELINE_REF}^{commit}" || true)"
fi
if [ -z "${BASELINE_SHA}" ]; then
echo "Could not resolve baseline ref '${BASELINE_REF}' locally or from origin"
exit 1
fi
echo "Baseline: ${BASELINE_SHA} (${BASELINE_REF})"
echo "HEAD: ${HEAD_SHA}"
if [ "${BASELINE_SHA}" = "${HEAD_SHA}" ]; then
echo "WARNING: baseline and HEAD are the same commit, this run proves nothing"
fi
# Only decidable with history: a shallow clone answers "no" for every pair.
if [ "$(git rev-parse --is-shallow-repository)" = "false" ] \
&& ! git merge-base --is-ancestor "${BASELINE_SHA}" "${HEAD_SHA}"; then
echo "WARNING: baseline is not an ancestor of HEAD; a failure below may be master's change, not HEAD's"
fi
# Keyed by baseline commit, which also pins that commit's Cargo.lock and
# rust-toolchain.toml. Only a developer machine ever hits this: hosted runners
# start empty and nothing publishes a baseline binary, so CI rebuilds it on
# every run.
BASELINE_SERVER="${TARGET_DIR}/storage-compat/${BASELINE_SHA}/iggy-server"
if [ "${REBUILD_BASELINE}" -eq 1 ]; then
rm -f "${BASELINE_SERVER}"
fi
if [ -x "${BASELINE_SERVER}" ]; then
echo "Reusing baseline server at ${BASELINE_SERVER}"
else
# Outside the repo on purpose: an in-tree worktree gets swept up by the
# repo-wide find(1) in the lint scripts and by cargo's workspace globs.
# A fresh mktemp path per run cannot collide with an aborted run; the only
# residue such a run leaves is an admin entry under .git/worktrees, which
# prune clears.
git worktree prune
WORKTREE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iggy-storage-compat.XXXXXX")"
git worktree add --detach "${WORKTREE_DIR}" "${BASELINE_SHA}"
echo "Building baseline iggy-server from ${BASELINE_SHA}..."
# Built from the worktree as CWD so the baseline's own rust-toolchain.toml
# applies, and into a target directory of the worktree's own.
#
# The two trees MUST NOT share one. Cargo keys a workspace member's unit hash
# on its manifest path RELATIVE to the workspace root, and records that
# member's sources in the dep-info relative too, so `core/configs` in the
# worktree and `core/configs` here hash identically and both resolve against
# whichever root cargo is invoked from. Sharing a target directory therefore
# makes the second build read the first build's rlibs as fresh: HEAD's
# `server` would compile against MASTER's `configs`, `consensus`,
# `partitions` and `shard`. Any PR touching a crate below `server` fails to
# build here with errors that do not reproduce anywhere else. The duplicated
# dependency compile is the price of the two halves being what they claim.
#
# Inside the worktree so the cleanup trap reclaims it with the worktree; only
# the copied binary below outlives the run.
#
# Debug profile on both sides, and no --all-features: release would compile
# debug_assert! out of the baseline while HEAD still panics on it, and
# --all-features turns on the server's `disable-mimalloc`, so the two halves
# would differ in ways the storage format never changed.
BASELINE_TARGET_DIR="${WORKTREE_DIR}/target"
(
cd "${WORKTREE_DIR}"
CARGO_TARGET_DIR="${BASELINE_TARGET_DIR}" cargo build --locked -p server --bin iggy-server
)
BASELINE_BUILT="${BASELINE_TARGET_DIR}/debug/iggy-server"
if [ ! -x "${BASELINE_BUILT}" ]; then
echo "Baseline build did not produce ${BASELINE_BUILT}"
exit 1
fi
mkdir -p "$(dirname "${BASELINE_SERVER}")"
cp "${BASELINE_BUILT}" "${BASELINE_SERVER}"
# Now, not at cleanup: a second full debug dependency graph is several GB, and
# the HEAD build plus the integration test still have to fit on the runner.
rm -rf "${BASELINE_TARGET_DIR}"
fi
# The baseline never writes here any more, but a binary left by an earlier run
# of this script (or by any other build in this tree) would satisfy the
# existence check below without cargo having produced it now. Delete it so that
# check means what it says.
rm -f "${HEAD_SERVER}"
echo "Building HEAD iggy-server from ${HEAD_SHA}..."
cargo build --locked -p server --bin iggy-server
if [ ! -x "${HEAD_SERVER}" ]; then
echo "HEAD build did not produce ${HEAD_SERVER}"
exit 1
fi
# Absolute path: ServerHandle only treats this value as a literal path when it
# has more than one component, otherwise it falls back to a cargo_bin lookup.
export COMPAT_BASELINE_SERVER="${BASELINE_SERVER}"
echo "Running storage compatibility test..."
# --run-ignored only: the test is #[ignore]d, so the normal lanes skip it.
# --no-tests=fail: a filter matching nothing has to be an error here, unlike the
# main lane's --no-tests=warn, which would report a typo'd filter as green.
# --retries 0: a read-back that only fails sometimes is exactly the signal this
# check exists for, so no retry may hide it. Explicit rather than "not
# --profile ci": a profile default or NEXTEST_RETRIES in the environment would
# otherwise apply.
# --ignore-default-filter: nextest intersects -E with the profile's
# default-filter, so one that excluded this module would turn the run into a
# --no-tests=fail abort instead of running the test.
# The version floor for these flags lives in .config/nextest.toml.
cargo nextest run --locked -p integration \
--run-ignored only \
--no-tests=fail \
--retries 0 \
--ignore-default-filter \
-E "test(${TEST_FILTER})"
echo "Storage format compatible: ${BASELINE_SHA} -> ${HEAD_SHA}"