#!/usr/bin/env bash # Repeatable Phase 2 verification: root and app vet/test/race, corpus audit, # CLI synthetic smoke, and a disposable MariaDB-backed PHP self-replay. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="$(cd "$ROOT/../fonoteka.go" && pwd)" PHP_ROOT="${PHP_ROOT:-/media/nvme/dev/golem15/fonoteka}" PHP_TARGET="http://127.0.0.1:8423" ROUTES_PHP="$PHP_ROOT/plugins/golem15/fonoteka/routes.php" if [[ "${1:-}" != "--fresh-php" ]]; then echo "usage: $0 --fresh-php" >&2 exit 2 fi if [[ -n "${PHP_PARITY_TARGET:-}" ]]; then echo "refuse: caller-supplied PHP_PARITY_TARGET is not permitted" >&2 exit 1 fi if ! command -v docker >/dev/null 2>&1; then echo "refuse: docker is required for --fresh-php" >&2 exit 1 fi if ! docker info >/dev/null 2>&1; then echo "refuse: docker daemon is not available" >&2 exit 1 fi if [[ ! -f "$PHP_ROOT/artisan" ]]; then echo "refuse: PHP checkout missing artisan at $PHP_ROOT" >&2 exit 1 fi if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8423'; then echo "refuse: 127.0.0.1:8423 is already in use; the gate starts its own PHP child" >&2 exit 1 fi run_module() { local name="$1" local dir="$2" echo "==> ${name} (${dir})" ( cd "$dir" go vet ./... go test ./... go test -race ./... ) } redact() { sed -E \ -e 's/(client_secret=)[^[:space:]]+/\1[redacted]/g' \ -e 's/(ADMIN_PASSWORD=)[^[:space:]]+/\1[redacted]/g' \ -e 's/(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/[jwt-redacted]/g' \ -e 's/(inv_[A-Za-z0-9]{8,})/[inv-redacted]/g' } echo "==> framework root" run_module "root" "$ROOT" echo "==> app module" run_module "app" "$APP" echo "==> TestParitySynthetic (testcontainers Postgres)" ( cd "$APP" go test ./parity -run TestParitySynthetic -count=1 ) echo "==> corpus audit" CORPUS_ARGS=( --manifest "$APP/parity/manifest.yaml" --require-recorded --require-clients --check-secrets ) if [[ -f "$ROUTES_PHP" ]]; then CORPUS_ARGS+=(--routes "$ROUTES_PHP") fi ( cd "$APP" go run ./parity/check_corpus.go "${CORPUS_ARGS[@]}" ) echo "==> CLI synthetic record/replay smoke" SMOKE_DIR="$(mktemp -d /tmp/summercms-parity-smoke-XXXXXX)" SMOKE_PORT="" SMOKE_PID="" cleanup_smoke() { if [[ -n "${SMOKE_PID:-}" ]]; then kill "$SMOKE_PID" 2>/dev/null || true wait "$SMOKE_PID" 2>/dev/null || true fi rm -rf "$SMOKE_DIR" } python3 - "$SMOKE_DIR" <<'PY' & import http.server, socketserver, sys, pathlib d = pathlib.Path(sys.argv[1]) class H(http.server.BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"data":"ok"}') def log_message(self, *args): pass with socketserver.TCPServer(("127.0.0.1", 0), H) as httpd: port = httpd.server_address[1] (d / "port").write_text(str(port)) httpd.serve_forever() PY SMOKE_PID=$! for _ in $(seq 1 50); do if [[ -f "$SMOKE_DIR/port" ]]; then SMOKE_PORT="$(cat "$SMOKE_DIR/port")" break fi sleep 0.1 done if [[ -z "$SMOKE_PORT" ]]; then cleanup_smoke echo "refuse: synthetic smoke server did not start" >&2 exit 1 fi ( cd "$ROOT" go build -o "$SMOKE_DIR/summer" ./cmd/summer "$SMOKE_DIR/summer" parity:record \ --spec tide/testdata/one-route-spec.yaml \ --target "http://127.0.0.1:${SMOKE_PORT}" \ --output "$SMOKE_DIR/sample.yaml" "$SMOKE_DIR/summer" parity:replay \ --fixtures "$SMOKE_DIR/sample.yaml" \ --target "http://127.0.0.1:${SMOKE_PORT}" ) cleanup_smoke echo "==> fresh PHP self-replay on disposable MariaDB" RUN_ID="$(date +%s)_$$" DB_NAME="fonoteka_parity_${RUN_ID}" DB_USER="parity_${RUN_ID}" DB_PASS="$(python3 -c 'import secrets; print(secrets.token_hex(16))')" ADMIN_PASS="$(python3 -c 'import secrets; print(secrets.token_hex(12))')" CONTAINER="fonoteka-parity-${RUN_ID}" VARS_DIR="$(mktemp -d /tmp/summercms-parity-vars-XXXXXX)" VARS_FILE="$VARS_DIR/vars.yaml" PHP_PID="" SUMMER_BIN="$VARS_DIR/summer" cleanup_php() { if [[ -n "${PHP_PID:-}" ]]; then kill "$PHP_PID" 2>/dev/null || true wait "$PHP_PID" 2>/dev/null || true fi if [[ -n "${CONTAINER:-}" ]]; then docker rm -f "$CONTAINER" >/dev/null 2>&1 || true fi rm -rf "$VARS_DIR" } trap cleanup_php EXIT echo "==> mariadb container $CONTAINER (loopback ephemeral port, db $DB_NAME)" docker run -d --name "$CONTAINER" \ -e MYSQL_ROOT_PASSWORD="$DB_PASS" \ -e MYSQL_USER="$DB_USER" \ -e MYSQL_PASSWORD="$DB_PASS" \ -e MYSQL_DATABASE="$DB_NAME" \ -p 127.0.0.1:0:3306 \ mariadb:11 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci >/dev/null DB_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "3306/tcp") 0).HostPort}}' "$CONTAINER")" if [[ -z "$DB_PORT" ]]; then echo "refuse: could not discover MariaDB host port" >&2 exit 1 fi echo "==> mariadb listening on 127.0.0.1:${DB_PORT}" ready=0 for _ in $(seq 1 90); do if docker exec "$CONTAINER" mariadb -uroot -p"$DB_PASS" -e 'SELECT 1' --silent >/dev/null 2>&1; then ready=1 break fi if docker logs "$CONTAINER" 2>&1 | grep -q 'ready for connections'; then if docker exec "$CONTAINER" mariadb -uroot -p"$DB_PASS" -N -e 'SELECT 1' >/dev/null 2>&1; then ready=1 break fi fi sleep 1 done if [[ "$ready" -ne 1 ]]; then echo "refuse: MariaDB did not become ready" >&2 exit 1 fi php_env() { # Process-local DB env wins over the PHP checkout .env. Never export the # developer database name or checkout credentials. export DB_CONNECTION=mysql export DB_HOST=127.0.0.1 export DB_PORT="$DB_PORT" export DB_DATABASE="$DB_NAME" export DB_USERNAME="$DB_USER" export DB_PASSWORD="$DB_PASS" export CACHE_DRIVER=array export SESSION_DRIVER=array export QUEUE_CONNECTION=sync export SCOUT_DRIVER=null export MAIL_MAILER=array export LOG_CHANNEL=stderr export BROADCAST_DRIVER=log export BROADCAST_ENABLED=false export DISCOGS_TOKEN= export APP_URL="$PHP_TARGET" export ADMIN_EMAIL=admin@parity.test export ADMIN_LOGIN=admin export ADMIN_PASSWORD="$ADMIN_PASS" export ADMIN_FIRST_NAME=Parity export ADMIN_LAST_NAME=Admin } php_artisan() { php_env (cd "$PHP_ROOT" && php artisan "$@") } php_env php_artisan config:clear >/dev/null echo "==> preflight: artisan DB identity and empty schema" ACTIVE_DB="$(php_artisan tinker --no-interaction --execute='echo DB::connection()->getDatabaseName();' | tail -n 1 | tr -d '\r')" if [[ "$ACTIVE_DB" != "$DB_NAME" ]]; then echo "refuse: artisan database is '$ACTIVE_DB', want '$DB_NAME'" >&2 exit 1 fi TABLES="$(php_artisan tinker --no-interaction --execute='echo count(DB::select("SHOW TABLES"));' | tail -n 1 | tr -d '\r')" if [[ "$TABLES" != "0" ]]; then echo "refuse: expected zero application tables before migrations, got $TABLES" >&2 exit 1 fi echo "==> preflight ok: database=$ACTIVE_DB tables=$TABLES" echo "==> php artisan winter:up (admin bootstrap via ADMIN_* env)" php_artisan winter:up >/dev/null echo "==> php artisan fonoteka:oauth-client (secrets redacted)" set +e OAUTH_OUT="$(php_artisan fonoteka:oauth-client "Parity MCP" \ --redirect-uri=http://127.0.0.1:8422/oauth/callback \ --scope=read --scope=write --scope=ai 2>&1)" OAUTH_RC=$? set -e echo "$OAUTH_OUT" | redact if [[ "$OAUTH_RC" -ne 0 ]]; then echo "refuse: fonoteka:oauth-client failed" >&2 exit 1 fi CLIENT_ID="$(printf '%s\n' "$OAUTH_OUT" | sed -n 's/^client_id=//p' | head -1)" CLIENT_SECRET="$(printf '%s\n' "$OAUTH_OUT" | sed -n 's/^client_secret=//p' | head -1)" if [[ -z "$CLIENT_ID" || -z "$CLIENT_SECRET" ]]; then echo "refuse: oauth-client did not print client_id/client_secret" >&2 exit 1 fi umask 077 python3 - "$VARS_FILE" "$CLIENT_ID" "$CLIENT_SECRET" <<'PY' import json, pathlib, sys path, cid, secret = sys.argv[1], sys.argv[2], sys.argv[3] pathlib.Path(path).write_text( f"{json.dumps('oauth:artisan-client')}: {json.dumps(cid)}\n" f"{json.dumps('oauth:artisan-secret')}: {json.dumps(secret)}\n" ) PY chmod 600 "$VARS_FILE" unset CLIENT_SECRET OAUTH_OUT echo "==> php artisan serve --host=127.0.0.1 --port=8423" php_env php "$PHP_ROOT/artisan" serve --host=127.0.0.1 --port=8423 >/dev/null 2>&1 & PHP_PID=$! ready=0 for _ in $(seq 1 60); do if python3 - "$PHP_TARGET" <<'PY' >/dev/null 2>&1 import sys, urllib.request urllib.request.urlopen(sys.argv[1], timeout=1) PY then ready=1 break fi sleep 0.25 done if [[ "$ready" -ne 1 ]]; then echo "refuse: PHP child did not become ready on $PHP_TARGET" >&2 exit 1 fi ( cd "$ROOT" go build -o "$SUMMER_BIN" ./cmd/summer ) replay_seed() { "$SUMMER_BIN" parity:replay \ --fixtures "$APP/parity/fixtures/seed/bootstrap.yaml" \ --target "$PHP_TARGET" \ --vars "$VARS_FILE" } echo "==> seed replay" replay_seed | redact echo "==> 154-route self-replay --self-check --require-recorded" "$SUMMER_BIN" parity:replay \ --manifest "$APP/parity/manifest.yaml" \ --fixtures "$APP/parity/fixtures" \ --target "$PHP_TARGET" \ --vars "$VARS_FILE" \ --self-check true \ --require-recorded true | redact echo "==> reset schema for client flows (fresh seed, not post-route mutation)" php_artisan tinker --no-interaction --execute='foreach (DB::select("SHOW TABLES") as $row) { $name = array_values((array)$row)[0]; DB::statement("SET FOREIGN_KEY_CHECKS=0"); DB::statement("DROP TABLE `$name`"); DB::statement("SET FOREIGN_KEY_CHECKS=1"); }' >/dev/null TABLES="$(php_artisan tinker --no-interaction --execute='echo count(DB::select("SHOW TABLES"));' | tail -n 1 | tr -d '\r')" if [[ "$TABLES" != "0" ]]; then echo "refuse: client-cycle reset left $TABLES tables" >&2 exit 1 fi php_artisan winter:up >/dev/null set +e php_artisan fonoteka:oauth-client "Parity MCP" \ --redirect-uri=http://127.0.0.1:8422/oauth/callback \ --scope=read --scope=write --scope=ai >/dev/null 2>&1 OAUTH_RC=$? set -e if [[ "$OAUTH_RC" -ne 0 ]]; then echo "refuse: fonoteka:oauth-client failed on client-cycle reset" >&2 exit 1 fi : >"$VARS_FILE" chmod 600 "$VARS_FILE" echo "==> client seed replay" replay_seed | redact echo "==> nuxt-browse replay" "$SUMMER_BIN" parity:replay \ --fixtures "$APP/parity/fixtures/nuxt/nuxt-browse.yaml" \ --target "$PHP_TARGET" \ --vars "$VARS_FILE" | redact echo "==> mcp-tools replay" "$SUMMER_BIN" parity:replay \ --fixtures "$APP/parity/fixtures/mcp/mcp-tools.yaml" \ --target "$PHP_TARGET" \ --vars "$VARS_FILE" | redact if [[ -f /tmp/summercms-parity/pkce.vars ]]; then cat /tmp/summercms-parity/pkce.vars >>"$VARS_FILE" chmod 600 "$VARS_FILE" fi echo "==> mcp-oauth replay" "$SUMMER_BIN" parity:replay \ --fixtures "$APP/parity/fixtures/mcp/mcp-oauth.yaml" \ --target "$PHP_TARGET" \ --vars "$VARS_FILE" | redact echo "phase2 check passed"