feat(02-05): add the repeatable Phase 2 vet, race and PHP gate
- Check root and fonoteka.go with vet, test and race plus TestParitySynthetic - Audit the 154-route corpus and smoke parity:record/replay against loopback - Provision a disposable MariaDB, pin PHP to 127.0.0.1:8423, and self-replay seed, routes and clients Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
368
scripts/check-phase2.sh
Executable file
368
scripts/check-phase2.sh
Executable file
@@ -0,0 +1,368 @@
|
||||
#!/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_urlsafe(24))')"
|
||||
ADMIN_PASS="$(python3 -c 'import secrets; print(secrets.token_urlsafe(18))')"
|
||||
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" mariadbadmin ping -uroot -p"$DB_PASS" --silent >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
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"
|
||||
Reference in New Issue
Block a user