- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
375 lines
10 KiB
Bash
Executable File
375 lines
10 KiB
Bash
Executable File
#!/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 modules/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"
|