396 lines
14 KiB
Bash
Executable File
396 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Honcho sandbox — an ephemeral, seeded stack that resets to a known state in
|
|
# seconds, so harness testing stops depending on machine state.
|
|
#
|
|
# sandbox.sh up [--provider mock|real] [--build]
|
|
# sandbox.sh seed [--provider ...]
|
|
# sandbox.sh reset [--provider ...]
|
|
# sandbox.sh status [--provider ...]
|
|
# sandbox.sh down [--provider ...]
|
|
#
|
|
# See README.md.
|
|
|
|
set -euo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO="$(cd "$HERE/.." && pwd)"
|
|
|
|
DB=honcho_sandbox
|
|
BUILT_IMAGE=honcho-sandbox/honcho:built
|
|
|
|
PROVIDER="${HONCHO_SANDBOX_PROVIDER:-mock}"
|
|
BUILD=0
|
|
|
|
die() { echo "error: $*" >&2; exit 1; }
|
|
say() { echo "==> $*"; }
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Argument parsing
|
|
# --------------------------------------------------------------------------
|
|
|
|
COMMAND="${1:-}"
|
|
[ -n "$COMMAND" ] || die "usage: sandbox.sh {up|seed|reset|status|down} [--provider mock|real] [--build]"
|
|
shift
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--provider) PROVIDER="${2:-}"; shift 2 ;;
|
|
--provider=*) PROVIDER="${1#*=}"; shift ;;
|
|
--build) BUILD=1; shift ;;
|
|
*) die "unknown argument: $1" ;;
|
|
esac
|
|
done
|
|
|
|
case "$PROVIDER" in
|
|
mock|real) ;;
|
|
*) die "unknown provider '$PROVIDER' (expected 'mock' or 'real')" ;;
|
|
esac
|
|
|
|
# Each mode keeps its own template, so both can coexist and switching modes is
|
|
# free rather than forcing a reseed.
|
|
TEMPLATE="${DB}_seeded_${PROVIDER}"
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Compose invocation
|
|
#
|
|
# The base file is provider-agnostic and is not a working stack on its own;
|
|
# exactly one provider overlay is always composed on top.
|
|
# --------------------------------------------------------------------------
|
|
|
|
# The pinned default...
|
|
# shellcheck disable=SC1091
|
|
set -a; . "$HERE/image.env"; set +a
|
|
|
|
# ...then whatever the running stack was actually started with. Without this,
|
|
# `up --build` followed by a plain `reset` would flip the image back to the pinned
|
|
# digest, and Compose would helpfully recreate every container from it mid-reset.
|
|
STATE="$HERE/.state.env"
|
|
if [ "$BUILD" = 1 ]; then
|
|
HONCHO_SANDBOX_IMAGE="$BUILT_IMAGE"
|
|
elif [ -f "$STATE" ]; then
|
|
# shellcheck disable=SC1090
|
|
set -a; . "$STATE"; set +a
|
|
fi
|
|
export HONCHO_SANDBOX_IMAGE
|
|
|
|
# The provider the running stack was actually created with, read on its own because
|
|
# the source above is skipped under --build. Empty means nothing has been started
|
|
# through `up` since the last `down`.
|
|
RUNNING_PROVIDER=""
|
|
if [ -f "$STATE" ]; then
|
|
RUNNING_PROVIDER="$(sed -n 's/^SANDBOX_RUNNING_PROVIDER=//p' "$STATE")"
|
|
fi
|
|
|
|
write_state() {
|
|
cat > "$STATE" <<EOF
|
|
HONCHO_SANDBOX_IMAGE=$HONCHO_SANDBOX_IMAGE
|
|
SANDBOX_RUNNING_PROVIDER=$PROVIDER
|
|
EOF
|
|
RUNNING_PROVIDER="$PROVIDER"
|
|
}
|
|
|
|
compose() {
|
|
docker compose \
|
|
-f "$HERE/compose.yml" \
|
|
-f "$HERE/compose.$PROVIDER.yml" \
|
|
--project-directory "$HERE" \
|
|
"$@"
|
|
}
|
|
|
|
psql_db() {
|
|
# Maintenance connections go to `postgres`, never to $DB — you cannot drop a
|
|
# database you are connected to.
|
|
compose exec -T database psql -v ON_ERROR_STOP=1 -U postgres -d postgres "$@"
|
|
}
|
|
|
|
db_exists() {
|
|
[ "$(psql_db -tAc "SELECT 1 FROM pg_database WHERE datname='$1'" 2>/dev/null)" = "1" ]
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Preconditions
|
|
# --------------------------------------------------------------------------
|
|
|
|
preflight() {
|
|
docker info >/dev/null 2>&1 || die "the Docker daemon is not running"
|
|
|
|
if [ "$PROVIDER" = real ]; then
|
|
# Fail before starting anything. A real-mode stack with no key boots fine and
|
|
# then derives nothing, which reads as "the deriver found nothing".
|
|
[ -f "$HERE/real.env" ] || die \
|
|
"real mode needs $HERE/real.env. Copy real.env.example and add a provider key."
|
|
grep -Eq '^\s*LLM_[A-Z]+_API_KEY=.+' "$HERE/real.env" || die \
|
|
"no LLM_*_API_KEY with a value in real.env."
|
|
if grep -Eq '^\s*LLM_[A-Z]+_API_KEY=(sk-replace-me|your-api-key-here)\s*$' "$HERE/real.env"; then
|
|
die "real.env still has the placeholder key from real.env.example."
|
|
fi
|
|
fi
|
|
# Must not end on a failed test. Under `set -e`, a non-zero return from the last
|
|
# command in this function aborts the script with no output at all — which is
|
|
# exactly how the first real-mode `up` "failed": silently, before doing anything.
|
|
return 0
|
|
}
|
|
|
|
# Mock mode runs src/mock_provider out of the same image. A digest that predates the
|
|
# module resolves and pulls fine, then crash-loops one service on a missing module —
|
|
# so check for it up front and name the two ways out.
|
|
check_mock_provider_present() {
|
|
[ "$PROVIDER" = mock ] || return 0
|
|
if ! docker run --rm --entrypoint test "$HONCHO_SANDBOX_IMAGE" -d /app/src/mock_provider; then
|
|
die "$(cat <<MSG
|
|
the image $HONCHO_SANDBOX_IMAGE does not contain src/mock_provider.
|
|
|
|
It predates the mock provider. Either bump the digest in sandbox/image.env to an
|
|
image that has it, or build from the working tree:
|
|
|
|
sandbox/sandbox.sh up --build
|
|
MSG
|
|
)"
|
|
fi
|
|
}
|
|
|
|
# Containers carry the provider wiring they were created with, and neither seed nor
|
|
# reset recreates them: seed uses `start` plus `--no-recreate` to avoid paying a
|
|
# container rebuild, and reset touches no container at all. So a --provider that
|
|
# disagrees with the running stack does not change what the stack talks to. It only
|
|
# changes what gets recorded — seed would derive through the running provider and
|
|
# then stamp the requested one into the template, defeating the fingerprint guard,
|
|
# which compares the flag rather than reality.
|
|
#
|
|
# The real-stack-seeding-a-mock-template case is the damaging one: it spends money,
|
|
# produces non-deterministic conclusions, and labels them `mock`, so every later
|
|
# reset restores that as the deterministic baseline. Refuse rather than mislead.
|
|
require_running_provider() {
|
|
[ -n "$RUNNING_PROVIDER" ] || return 0
|
|
[ "$RUNNING_PROVIDER" != "$PROVIDER" ] || return 0
|
|
die "$(cat <<MSG
|
|
the stack is running the $RUNNING_PROVIDER provider, but this is a $PROVIDER-mode command.
|
|
|
|
seed and reset do not recreate containers, so this would act through the
|
|
$RUNNING_PROVIDER provider while recording $PROVIDER. Switch the stack first:
|
|
|
|
sandbox/sandbox.sh up --provider $PROVIDER
|
|
MSG
|
|
)"
|
|
}
|
|
|
|
seed_py() {
|
|
SANDBOX_BASE_URL="http://127.0.0.1:${SANDBOX_API_PORT:-18000}" \
|
|
uv run --no-project --with-editable "$REPO/sdks/python" \
|
|
python "$HERE/seed.py" "$1"
|
|
}
|
|
|
|
# Conclusion levels and premise links are not reachable from the public API, so this
|
|
# part of the fixture is written by a script running inside the api container, which
|
|
# already is Honcho's venv with the api's settings and embedding client. The script
|
|
# arrives on stdin and the fixture in the environment, so nothing has to be mounted
|
|
# and nothing is left behind in the container.
|
|
#
|
|
# It decides for itself whether the fixture declares any conclusions, rather than
|
|
# being gated by a grep here that a comment mentioning a level name would fool.
|
|
inject_conclusions() {
|
|
compose exec -T \
|
|
-e SANDBOX_FIXTURE_JSON="$(cat "$HERE/fixture.json")" \
|
|
api /app/.venv/bin/python - < "$HERE/inject_conclusions.py"
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Commands
|
|
# --------------------------------------------------------------------------
|
|
|
|
cmd_up() {
|
|
preflight
|
|
|
|
compose pull --quiet database redis
|
|
if [ "$BUILD" = 1 ]; then
|
|
say "building $BUILT_IMAGE from the working tree"
|
|
docker build -t "$BUILT_IMAGE" "$REPO"
|
|
elif docker image inspect "$HONCHO_SANDBOX_IMAGE" >/dev/null 2>&1; then
|
|
# Already here — either pulled on an earlier run, or built locally by --build
|
|
# and recorded in .state.env, in which case pulling it would fail outright
|
|
# because the tag exists in no registry.
|
|
say "using local image $HONCHO_SANDBOX_IMAGE"
|
|
else
|
|
say "pulling $HONCHO_SANDBOX_IMAGE"
|
|
docker pull --quiet "$HONCHO_SANDBOX_IMAGE" >/dev/null
|
|
fi
|
|
|
|
check_mock_provider_present
|
|
|
|
say "starting the $PROVIDER stack"
|
|
# --remove-orphans because both modes share one Compose project: switching from
|
|
# mock to real leaves mock-provider running and otherwise unreferenced, where it
|
|
# would sit idle and make `status` misleading about what the stack is using.
|
|
compose up -d --wait --remove-orphans
|
|
# Recorded before the seed/reset below, so their provider guard sees the stack
|
|
# that was just created rather than the one it replaced.
|
|
write_state
|
|
|
|
if db_exists "$TEMPLATE"; then
|
|
say "template $TEMPLATE already present — resetting to it"
|
|
cmd_reset
|
|
else
|
|
cmd_seed
|
|
fi
|
|
|
|
echo
|
|
say "sandbox ready at http://127.0.0.1:${SANDBOX_API_PORT:-18000} (provider: $PROVIDER)"
|
|
}
|
|
|
|
cmd_seed() {
|
|
preflight
|
|
require_running_provider
|
|
# Seeding an already-seeded database appends to it — the fixture would land a
|
|
# second time and the "expected 6 messages, found 12" check in seed.py would
|
|
# (correctly) fail. Start from an empty, freshly migrated database every time so
|
|
# `seed` means the same thing regardless of what was there before.
|
|
say "clearing the database before seeding"
|
|
compose stop api deriver >/dev/null
|
|
psql_db -c "DROP DATABASE IF EXISTS $DB WITH (FORCE)" >/dev/null
|
|
psql_db -c "CREATE DATABASE $DB" >/dev/null
|
|
compose exec -T database psql -q -v ON_ERROR_STOP=1 -U postgres -d "$DB" \
|
|
-c "CREATE EXTENSION IF NOT EXISTS vector" >/dev/null
|
|
# Redis still holds entries keyed to the database that was just dropped; without
|
|
# this the api serves cached peers that no longer exist and seeding conflicts.
|
|
compose exec -T redis redis-cli FLUSHALL >/dev/null
|
|
# The api entrypoint runs scripts/provision_db.py, so starting it is what applies
|
|
# the Alembic migrations to the new database.
|
|
compose start api deriver >/dev/null
|
|
compose up -d --wait --no-recreate >/dev/null
|
|
|
|
say "seeding ($PROVIDER)"
|
|
seed_py seed
|
|
inject_conclusions
|
|
seed_py verify
|
|
|
|
say "snapshotting to $TEMPLATE"
|
|
stamp_fingerprint
|
|
# api and deriver hold pooled connections; CREATE DATABASE ... TEMPLATE needs
|
|
# the source to have none.
|
|
compose stop api deriver >/dev/null
|
|
disconnect_db "$DB"
|
|
psql_db -c "DROP DATABASE IF EXISTS $TEMPLATE" >/dev/null
|
|
psql_db -c "CREATE DATABASE $TEMPLATE TEMPLATE $DB" >/dev/null
|
|
compose start api deriver >/dev/null
|
|
compose up -d --wait --no-recreate >/dev/null
|
|
say "snapshot ready — reset restores this state without re-deriving"
|
|
}
|
|
|
|
cmd_reset() {
|
|
preflight
|
|
require_running_provider
|
|
db_exists "$TEMPLATE" || die \
|
|
"no template for $PROVIDER mode. Run: sandbox/sandbox.sh seed --provider $PROVIDER"
|
|
|
|
check_fingerprint
|
|
|
|
# Nothing is stopped or restarted. DROP ... WITH (FORCE) evicts the api and
|
|
# deriver connection pools itself, and both reconnect on their next use
|
|
# (db.POOL_PRE_PING is on) — the api on its next request, the deriver on its
|
|
# next poll a quarter-second later.
|
|
#
|
|
# Both log one OperationalError as their in-flight connection dies. That noise
|
|
# is the cost of not paying ~9s of container stop/start on every reset, which
|
|
# would defeat the point. Restarting them instead is the fallback if this ever
|
|
# stops being true.
|
|
#
|
|
# CREATE DATABASE ... TEMPLATE additionally needs the *source* to be
|
|
# connectionless, and check_fingerprint just read from it.
|
|
disconnect_db "$TEMPLATE"
|
|
psql_db -c "DROP DATABASE $DB WITH (FORCE)" >/dev/null
|
|
psql_db -c "CREATE DATABASE $DB TEMPLATE $TEMPLATE" >/dev/null
|
|
compose exec -T redis redis-cli FLUSHALL >/dev/null
|
|
say "reset to $TEMPLATE"
|
|
}
|
|
|
|
cmd_status() {
|
|
compose ps
|
|
echo
|
|
if [ -n "$RUNNING_PROVIDER" ] && [ "$RUNNING_PROVIDER" != "$PROVIDER" ]; then
|
|
echo "provider: $RUNNING_PROVIDER (running) — this command asked for $PROVIDER"
|
|
else
|
|
echo "provider: $PROVIDER"
|
|
fi
|
|
echo "image: $HONCHO_SANDBOX_IMAGE"
|
|
echo "api: http://127.0.0.1:${SANDBOX_API_PORT:-18000}"
|
|
for mode in mock real; do
|
|
if db_exists "${DB}_seeded_${mode}"; then
|
|
echo "template: ${DB}_seeded_${mode} present"
|
|
else
|
|
echo "template: ${DB}_seeded_${mode} absent"
|
|
fi
|
|
done
|
|
}
|
|
|
|
cmd_down() {
|
|
say "tearing down the $PROVIDER stack and its volumes"
|
|
compose down -v
|
|
rm -f "$STATE"
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Snapshot staleness
|
|
#
|
|
# A template that predates a migration, or was built against a different
|
|
# provider, restores silently and wrongly. Both are recorded inside the template
|
|
# itself so reset can refuse rather than mislead.
|
|
# --------------------------------------------------------------------------
|
|
|
|
fingerprint() {
|
|
local alembic
|
|
alembic="$(compose exec -T database psql -tAX -U postgres -d "$DB" \
|
|
-c "SELECT version_num FROM alembic_version" 2>/dev/null | tr -d '[:space:]')"
|
|
local files
|
|
files="$(cat "$HERE/fixture.json" "$HERE/seed.py" "$HERE/inject_conclusions.py" \
|
|
| shasum -a 256 | cut -c1-16)"
|
|
echo "alembic=$alembic fixture=$files provider=$PROVIDER"
|
|
}
|
|
|
|
stamp_fingerprint() {
|
|
compose exec -T database psql -v ON_ERROR_STOP=1 -U postgres -d "$DB" >/dev/null <<SQL
|
|
CREATE TABLE IF NOT EXISTS sandbox_fingerprint (value text PRIMARY KEY);
|
|
TRUNCATE sandbox_fingerprint;
|
|
INSERT INTO sandbox_fingerprint VALUES ('$(fingerprint)');
|
|
SQL
|
|
}
|
|
|
|
check_fingerprint() {
|
|
local want have
|
|
want="$(fingerprint)"
|
|
have="$(compose exec -T database psql -tAX -U postgres -d "$TEMPLATE" \
|
|
-c "SELECT value FROM sandbox_fingerprint" 2>/dev/null | tr -d '\r')"
|
|
|
|
# The live database carries whatever revision the api container migrated it to
|
|
# on boot; the template carries the revision it was seeded at. A difference is
|
|
# exactly the migration drift this guard exists to catch, so it is compared,
|
|
# not normalised away.
|
|
[ "$want" = "$have" ] || die "$(cat <<MSG
|
|
$TEMPLATE is stale and would restore the wrong state.
|
|
|
|
template: ${have:-<no fingerprint>}
|
|
current: $want
|
|
|
|
Reseed: sandbox/sandbox.sh seed --provider $PROVIDER
|
|
MSG
|
|
)"
|
|
}
|
|
|
|
disconnect_db() {
|
|
# Defaults to the live database. CREATE DATABASE ... TEMPLATE also requires the
|
|
# *source* to have no connections, and reading the fingerprint just opened one.
|
|
local target="${1:-$DB}"
|
|
psql_db -c \
|
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$target' AND pid <> pg_backend_pid()" \
|
|
>/dev/null
|
|
}
|
|
|
|
case "$COMMAND" in
|
|
up) cmd_up ;;
|
|
seed) cmd_seed ;;
|
|
reset) cmd_reset ;;
|
|
status) cmd_status ;;
|
|
down) cmd_down ;;
|
|
*) die "unknown command '$COMMAND' (expected up, seed, reset, status, or down)" ;;
|
|
esac
|