tidaldb/scripts/build-release.sh
jordan c97aaa8e5b fleet remediation: make the workspace gate runnable, then fix what it caught
`cargo test --workspace` could not run at all: dependency resolution failed with
"aws-types@1.3.16 requires rustc 1.91.1" on the 1.91.0 default toolchain, so the
gate the project documents was dead. Making it run exposed a compile break and
two wrong tests that had been invisible for months. Now green end to end:
143 suites, 3155 tests, exit 0.

Toolchain
- rust-toolchain.toml pins the DEV toolchain to 1.91.1. The published MSRV stays
  `rust-version = "1.91"` (the engine builds on 1.91.0); only tidalctl's AWS SDK
  chain needs the patch release, and it now declares that itself.

Consumer crates migrated to the current engine API (clean cutover)
- iknowyou-engine: `AgentPolicy` gained five m10 read/profile-override fields;
  the literal now spreads `..AgentPolicy::default()` as the engine's own doc
  example does, so future fields do not break it again.
- forage-engine: `RetrieveResult` gained p1 `reasons`. The app builds its own
  candidate pool, so it now tags what it knows: PreferenceMatch for the
  preference-vector blend, SemanticMatch (with the seed item) for
  similar-to-saved, ExplorationBudget for pinned discoveries.
- forage-engine: `url_to_item_id` folded into the u32 item universe. The engine
  narrows item IDs to a u32 slot in durable per-user state and rejects anything
  above u32::MAX rather than alias two items forever, so every add_item with a
  64-bit FNV hash failed. 9 of 28 smoke tests were failing on this alone.
- forage-engine: bridge items read the top-2 preference CLUSTERS via
  `query_vectors`, not the single centroid from `preference_vectors().get()`.
  Since m12 that accessor returns only the strongest cluster, so a tech+jazz user
  whose interests split into two clusters looked single-interest and never
  bridged. Falls back to top-2 dimensions when a user has one cluster.

Reconcile tests corrected to the shipped contract
- tidal/tests/m8p3_reconcile_production.rs asserted `3 + 5 == 8` for a windowed
  count after heal. `take_crdt_snapshot` deliberately keys signal contributions
  to ONE canonical contributor (ShardId::SINGLE) because signals are relayed from
  a single writer, so per-node attribution double-counted every replicated event
  on every reconcile. Merge is therefore LWW on (last_update_ns, score) plus
  PN-counter per-node max: nodes converge on the more complete accumulator. The
  old expectation was asserting the bug that fix removed.
- Rewrote to assert convergence, count survival (not 0), and no inflation, and
  added `repeated_reconcile_of_converged_nodes_does_not_creep` - the regression
  guard for the creep itself, which nothing covered.

Pre-commit hook unified
- hooks/pre-commit dropped `-D warnings`: each crate's `[lints]` table is the
  source of truth (`clippy::all`/`unwrap_used` deny, `pedantic` warn), and the
  flag promoted ~58 deliberate pedantic warnings in integration tests to errors,
  making every Rust commit impossible.
- It now lints all five tidal crates instead of path-matching `tidal/`, which
  silently skipped tidal-server, tidal-net, tidal-stress, tidalctl and
  applications/ - the rot above lived in exactly those crates. Ported the
  CODING_GUIDELINES file-length, println, and unsafe-SAFETY checks from the
  divergent untracked copy that this replaces.
- CONTRIBUTING.md now documents the real commands and the toolchain/MSRV split.

Fleet recovery and soak
- scripts/restore-fleet.sh: the fail-closed selective restore, promoted out of an
  ignored tmp/ directory into the repository. Preflights retained storage,
  digest-pinned images, parked state, and aggregate plus per-PV-node scheduler
  headroom before the first scale; writes a durable transcript under
  tmp/restore-logs/ with structured start/error/rollback/complete events.
- k8s manifests park the standalone store, the RF3 cluster, and the soak monitor
  at zero replicas with restore-fleet.sh as the only supported scale-up path.
- soak-eval/soak-watch and the nightly CronJob fail closed on stale or missing
  restart evidence instead of silently skipping the restart-aware half of the gate.
- docs/ops/capacity-planning.md corrects the RAM envelope to the real hot-tier
  formula and separates analytic totals from the measured process envelope.
2026-08-16 12:38:14 -06:00

150 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# build-release.sh — the one committed, repeatable tidalDB release path.
#
# Replaces the ad-hoc hand-typed cross-compile + buildx + push with a single
# command on a clean checkout:
#
# ./scripts/build-release.sh <tag> [server|dr|stress|all]
#
# It HOST cross-compiles (mac-arm64 -> x86_64-unknown-linux-gnu, glibc 2.41 via the
# homebrew toolchain), packages the binaries into the committed docker/release/*
# Dockerfiles via the amd64 buildx builder, pushes, and prints the resulting
# @sha256 digest for each image (pin that digest in k8s/cluster/statefulset.yaml).
#
# The project bans CI/CD pipelines (orchard9-k3sf/CLAUDE.md) — this is a SCRIPT,
# run manually or from an operator's shell, never a pipeline. Registry creds come
# from the Docker daemon's existing login (run `docker login registry.threesix.ai`
# first); NO secret is embedded here.
set -euo pipefail
TAG="${1:-}"
COMPONENT="${2:-all}"
REGISTRY="${TIDAL_REGISTRY:-registry.threesix.ai/tidal}"
TARGET="x86_64-unknown-linux-gnu"
BUILDER="${TIDAL_BUILDX_BUILDER:-amd64builder}"
die() { echo "build-release: $*" >&2; exit 1; }
[ -n "$TAG" ] || die "usage: build-release.sh <tag> [server|dr|stress|all]"
[ "$TAG" != "latest" ] || die "refusing mutable release tag 'latest'"
[[ "$TAG" =~ ^[a-z0-9][a-z0-9._-]{0,127}$ ]] \
|| die "tag must match ^[a-z0-9][a-z0-9._-]{0,127}$"
case "$COMPONENT" in server|dr|stress|all) ;; *) die "component must be server|dr|stress|all" ;; esac
# ── Toolchain preflight (fail fast, no half-built image) ─────────────────────
command -v docker >/dev/null || die "docker not on PATH"
command -v git >/dev/null || die "git not on PATH"
command -v jq >/dev/null || die "jq not on PATH"
if [ "$COMPONENT" != "stress" ]; then
command -v cargo >/dev/null || die "cargo not on PATH"
command -v "${TARGET}-gcc" >/dev/null \
|| die "missing ${TARGET}-gcc (brew install ${TARGET})"
PROTOC_BIN="${PROTOC:-$(command -v protoc || true)}"
[ -n "$PROTOC_BIN" ] || die "protoc not found (brew install protobuf), or set PROTOC"
rustup target list --installed 2>/dev/null | grep -qx "$TARGET" \
|| die "rust target $TARGET not installed (rustup target add $TARGET)"
fi
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
# Every image must have one reproducible source identity. Clean releases use
# HEAD. An explicitly allowed canary hashes HEAD, both tracked diffs, and every
# untracked non-ignored file (path + Git blob hash), so two distinct source
# trees cannot silently share the same `-dirty` identity. This check also covers
# stress: its Docker build consumes the repository source directly.
UNTRACKED="$(git ls-files --others --exclude-standard)"
if git diff --quiet && git diff --cached --quiet && [ -z "$UNTRACKED" ]; then
export GIT_HASH="${TIDAL_BUILD_HASH:-$(git rev-parse --verify HEAD)}"
else
[ "${TIDAL_ALLOW_DIRTY:-0}" = "1" ] \
|| die "source changes present; commit them or set TIDAL_ALLOW_DIRTY=1 for an explicit canary"
DIRTY_HASH="$(
{
git rev-parse --verify HEAD
git diff --binary
git diff --cached --binary
git ls-files --others --exclude-standard -z |
while IFS= read -r -d '' file; do
printf 'untracked\0%s\0' "$file"
git hash-object -- "$file"
done
} | shasum -a 256 | cut -c1-16
)"
export GIT_HASH="${TIDAL_BUILD_HASH:-${DIRTY_HASH}-dirty}"
fi
echo "==> source identity $GIT_HASH"
# ── Pinned cross-compile environment (the documented recipe) ─────────────────
export CC_x86_64_unknown_linux_gnu="${TARGET}-gcc"
export CXX_x86_64_unknown_linux_gnu="${TARGET}-g++"
export AR_x86_64_unknown_linux_gnu="${TARGET}-ar"
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="${TARGET}-gcc"
export PROTOC="${PROTOC_BIN:-}"
# Which crates to cross-compile for the selected component(s).
PKGS=()
case "$COMPONENT" in
server) PKGS=(-p tidal-server) ;;
dr) PKGS=(-p tidalctl -p tidal-server) ;;
stress) PKGS=() ;; # stress builds IN-container (pure Rust, no engine deps)
all) PKGS=(-p tidal-server -p tidalctl) ;;
esac
if [ "${#PKGS[@]}" -gt 0 ]; then
echo "==> cross-compiling ${PKGS[*]} for $TARGET (release)"
cargo build "${PKGS[@]}" --release --target "$TARGET" --locked
fi
# ── buildx amd64 builder (idempotent) ────────────────────────────────────────
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
echo "==> creating buildx builder $BUILDER"
docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
fi
BIN_DIR="target/$TARGET/release"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# Build one image from a staged context; print its pushed digest.
build_image() { # $1=image_name $2=dockerfile $3=stage_subdir
local image="$REGISTRY/$1:$TAG" dockerfile="$2" ctx="$STAGE/$3"
echo "==> building $image"
docker buildx build --builder "$BUILDER" --platform linux/amd64 \
-f "$dockerfile" -t "$image" --push "$ctx"
local digest
digest="$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | jq -er '.digest')"
[ -n "$digest" ] || die "registry returned no digest for $image"
echo "RELEASE $1: $image@$digest"
}
if [ "$COMPONENT" = server ] || [ "$COMPONENT" = all ]; then
mkdir -p "$STAGE/server"
cp "$BIN_DIR/tidal-server" "$STAGE/server/"
cp -a docker/release/config "$STAGE/server/config"
cp docker/release/Dockerfile "$STAGE/server/Dockerfile"
build_image server "$STAGE/server/Dockerfile" server
fi
if [ "$COMPONENT" = dr ] || [ "$COMPONENT" = all ]; then
mkdir -p "$STAGE/dr"
cp "$BIN_DIR/tidalctl" "$BIN_DIR/tidal-server" "$STAGE/dr/"
cp docker/release/dr.Dockerfile "$STAGE/dr/Dockerfile"
build_image tidalctl "$STAGE/dr/Dockerfile" dr
fi
if [ "$COMPONENT" = stress ] || [ "$COMPONENT" = all ]; then
# Pure-Rust, in-container build (includes soak-eval + soak-watch). Context =
# repo root so workspace manifests resolve; the .dockerignore prunes it. The
# OCI revision label makes the source identity inspectable after the build.
echo "==> building $REGISTRY/stress:$TAG (in-container, includes soak-eval + soak-watch)"
docker buildx build --builder "$BUILDER" --platform linux/amd64 \
--build-arg "GIT_HASH=$GIT_HASH" \
-f docker/stress/Dockerfile -t "$REGISTRY/stress:$TAG" --push .
d="$(docker buildx imagetools inspect "$REGISTRY/stress:$TAG" --format '{{json .Manifest}}' | jq -er '.digest')"
[ -n "$d" ] || die "registry returned no digest for $REGISTRY/stress:$TAG"
echo "RELEASE stress: $REGISTRY/stress:$TAG@$d"
fi
echo "==> done. Deploy only the printed tag@sha256 references."