api.yokogawa.tech has a public A record but no pinpoint record on the DC, so it does not resolve from inside the VNet at all. The browser called it by hostname, which means an operator on cicore1 would have loaded the page and had every question fail on DNS - the exact gap Phase 7's gate exists to catch, and one an engineer's laptop cannot see. Caddy now routes /ask under ai.yokogawa.tech to ai-api, inside a route block so import authelia still runs first: forward_auth sorts after handle in the default directive order, and outside a route the handles would be terminal and the gate would never run. Only /ask is routed - the Phase 9 publisher rule is scoped to api.yokogawa.tech and a wider route here would leave it inert. Also fixes the fallback it replaces. The build arg defaults to "", and `?? "https://api.yokogawa.tech"` does not catch an empty string, so the documented real-deployment build resolved the API base to "" and posted /ask at ai-web, which 404s it. verify.sh and deploy.sh now check both the route and whether the built bundle carries the hostname. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.9 KiB
Bash
136 lines
5.9 KiB
Bash
#!/usr/bin/env bash
|
|
# Verify the AI stack on lin001. Read-only: it starts nothing and changes nothing.
|
|
#
|
|
# ./scripts/verify.sh
|
|
#
|
|
# "docker ps showing Up" is not proof of anything. What this checks instead:
|
|
# a 302 to the auth portal on every public hostname, clean logs, pg-ai
|
|
# unreachable from outside its own network, agent_ro genuinely read-only, and
|
|
# no host ports published by anything we added.
|
|
#
|
|
# Exit code is the number of failed checks, so it is usable in CI.
|
|
|
|
set -uo pipefail
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
|
|
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
|
|
head_() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
|
|
|
head_ "Containers"
|
|
for name in pg-ai cube ai-api ai-web langfuse lf-db; do
|
|
if docker ps --format '{{.Names}}' | grep -qx "$name"; then
|
|
state=$(docker inspect -f '{{.State.Status}}' "$name")
|
|
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name")
|
|
if [ "$state" = "running" ] && [ "$health" != "unhealthy" ]; then
|
|
ok "$name running (health: $health)"
|
|
else
|
|
bad "$name state=$state health=$health"
|
|
fi
|
|
else
|
|
bad "$name is not running"
|
|
fi
|
|
done
|
|
|
|
head_ "No published host ports on anything we added"
|
|
# openplc-runtime publishing 502 is the one deliberate exception on this host,
|
|
# and it is not ours. Everything in the AI stack must publish nothing.
|
|
for name in pg-ai cube ai-api ai-web langfuse lf-db; do
|
|
ports=$(docker port "$name" 2>/dev/null || true)
|
|
if [ -z "$ports" ]; then
|
|
ok "$name publishes no host port"
|
|
else
|
|
bad "$name publishes: $ports"
|
|
fi
|
|
done
|
|
|
|
head_ "pg-ai network isolation"
|
|
if docker inspect pg-ai -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw proxy; then
|
|
bad "pg-ai is attached to the proxy network - it must be ai-internal only"
|
|
else
|
|
ok "pg-ai is not on the proxy network"
|
|
fi
|
|
|
|
head_ "Public endpoints - expect 302 to the auth portal"
|
|
for host in lf.yokogawa.tech cube.yokogawa.tech api.yokogawa.tech ai.yokogawa.tech; do
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -I "https://$host" --max-time 10 || echo "000")
|
|
case "$code" in
|
|
302|303) ok "$host -> $code (auth portal)" ;;
|
|
200) bad "$host -> 200 WITHOUT AUTH - check 'import authelia' in ~/Caddyfile" ;;
|
|
000) bad "$host unreachable - DNS A record missing, or Caddy has no certificate" ;;
|
|
*) bad "$host -> $code" ;;
|
|
esac
|
|
done
|
|
|
|
head_ "The operator page and the API share an origin"
|
|
# The browser posts to /ask on ai.yokogawa.tech. If the route is missing, ai-web
|
|
# serves the SPA's 404 and every question fails - with the UI itself looking
|
|
# perfectly healthy. Unauthenticated this must be the auth portal redirect, NOT
|
|
# a 404 from ai-web.
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "https://ai.yokogawa.tech/ask" -H 'Content-Type: application/json' -d '{}' --max-time 10 || echo "000")
|
|
case "$code" in
|
|
302|303) ok "ai.yokogawa.tech/ask -> $code (routed to ai-api, gated)" ;;
|
|
404) bad "ai.yokogawa.tech/ask -> 404 - the /ask route is missing from the Caddy block; ai-web is answering" ;;
|
|
000) bad "ai.yokogawa.tech/ask unreachable" ;;
|
|
*) bad "ai.yokogawa.tech/ask -> $code" ;;
|
|
esac
|
|
# api.yokogawa.tech has no pinpoint DNS record, so it is unresolvable from
|
|
# inside the VNet. A bundle that hard-codes it loads fine here and fails on a
|
|
# control-room PC. Check what was actually built into the image.
|
|
if docker exec ai-web sh -c 'grep -rqs "api\.yokogawa\.tech" /usr/share/nginx/html' 2>/dev/null; then
|
|
bad "the ai-web bundle hard-codes api.yokogawa.tech - cicore1 cannot resolve it; rebuild with VITE_API_BASE empty"
|
|
else
|
|
ok "ai-web bundle carries no cross-origin API hostname"
|
|
fi
|
|
|
|
head_ "agent_ro is read-only"
|
|
if docker exec pg-ai psql -U agent_ro -d plant -tAc 'SELECT count(*) FROM equipment' >/dev/null 2>&1; then
|
|
ok "agent_ro can SELECT"
|
|
else
|
|
bad "agent_ro cannot SELECT"
|
|
fi
|
|
if docker exec pg-ai psql -U agent_ro -d plant -tAc \
|
|
"INSERT INTO equipment (equipment_id) VALUES ('VERIFY-DELETE-ME')" >/dev/null 2>&1; then
|
|
bad "agent_ro CAN INSERT - this is a Phase 1 failure, fix db/003_roles.sql now"
|
|
docker exec pg-ai psql -U postgres -d plant -c \
|
|
"DELETE FROM equipment WHERE equipment_id='VERIFY-DELETE-ME'" >/dev/null 2>&1
|
|
else
|
|
ok "agent_ro INSERT is rejected"
|
|
fi
|
|
|
|
head_ "Reference data"
|
|
missing_eq=$(docker exec pg-ai psql -U postgres -d plant -tAc \
|
|
"SELECT count(*) FROM equipment WHERE coalesce(array_length(aliases,1),0)=0" 2>/dev/null || echo "?")
|
|
missing_tag=$(docker exec pg-ai psql -U postgres -d plant -tAc \
|
|
"SELECT count(*) FROM tags WHERE coalesce(array_length(aliases,1),0)=0" 2>/dev/null || echo "?")
|
|
[ "$missing_eq" = "0" ] && ok "every equipment item has an alias" || bad "$missing_eq equipment items have no alias"
|
|
[ "$missing_tag" = "0" ] && ok "every tag has an alias" || bad "$missing_tag tags have no alias"
|
|
|
|
if docker exec pg-ai psql -U postgres -d plant -tAc \
|
|
"SELECT 1 FROM pg_extension WHERE extname='vector'" 2>/dev/null | grep -q 1; then
|
|
ok "pgvector extension present"
|
|
else
|
|
bad "pgvector extension missing"
|
|
fi
|
|
|
|
head_ "Fixture data"
|
|
if docker exec pg-ai psql -U postgres -d plant -tAc \
|
|
"SELECT 1 FROM information_schema.schemata WHERE schema_name='fixture'" 2>/dev/null | grep -q 1; then
|
|
rows=$(docker exec pg-ai psql -U postgres -d plant -tAc \
|
|
"SELECT count(*) FROM fixture.alarm_history" 2>/dev/null)
|
|
printf ' \033[33m!!\033[0m fixture schema present (%s alarm rows) - answers are TEST DATA, not plant history\n' "$rows"
|
|
fi
|
|
|
|
head_ "Disk"
|
|
df -h / /datadisk | sed 's/^/ /'
|
|
|
|
head_ "Recent errors in the logs"
|
|
for name in pg-ai cube ai-api ai-web; do
|
|
errors=$(docker logs --tail 200 "$name" 2>&1 | grep -icE 'error|fatal|panic' || true)
|
|
[ "${errors:-0}" -eq 0 ] && ok "$name logs clean (last 200 lines)" \
|
|
|| bad "$name has $errors error lines - docker logs --tail 200 $name"
|
|
done
|
|
|
|
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
|
|
exit "$FAIL"
|