#!/usr/bin/env bash # Deploy the AI stack to yau-sls-poc-lin001. # # ./scripts/deploy.sh [phase1|phase2|api|web|all] # # Run this ON lin001, from a checkout at ~/ai. It is deliberately additive and # deliberately noisy: this host is shared and live, it runs customer-facing # demos, and openplc-runtime on it is the PLC for the demo plant. # # WHAT THIS SCRIPT WILL NOT DO, because these interrupt other people or need a # human decision: # - restart Caddy or Authelia (it prints the command and stops) # - edit ~/authelia/configuration.yml (root-owned; see authelia/access-rules.md) # - touch openplc-runtime, cicore1 or imh # - create DNS records (ask Dan) set -euo pipefail TARGET="${1:-all}" REPO="$HOME/ai" COMPOSE="$HOME/ai-compose.yml" LANGFUSE_COMPOSE="$HOME/langfuse-compose.yml" STAMP="$(date +%Y%m%d)" say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } warn() { printf '\033[33m!! %s\033[0m\n' "$*"; } die() { printf '\033[31mxx %s\033[0m\n' "$*" >&2; exit 1; } # --- Preflight: the checks that have actually caught problems here ---------- preflight() { say "Preflight" # Root has hit 100% before and killed Grafana. Growing data goes on /datadisk. local root_used datadisk_used root_used=$(df --output=pcent / | tail -1 | tr -dc '0-9') datadisk_used=$(df --output=pcent /datadisk | tail -1 | tr -dc '0-9') echo " / ${root_used}% used" echo " /datadisk ${datadisk_used}% used" [ "$root_used" -lt 85 ] || die "/ is ${root_used}% full - stop and clear space first" [ "$datadisk_used" -lt 85 ] || warn "/datadisk is ${datadisk_used}% full - InfluxDB is the usual cause" docker network inspect proxy >/dev/null 2>&1 || die "the external 'proxy' network is missing" # The one deliberate published-port exception on this host. If it is not # running, the demo plant is down and that is more urgent than this deploy. docker ps --format '{{.Names}}' | grep -qx openplc-runtime \ || warn "openplc-runtime is NOT running - the demo plant is down" for envfile in "$HOME/ai/pg-ai.env" "$HOME/ai/api.env"; do [ -f "$envfile" ] || die "missing $envfile - create it 0600, see .env.example" local mode mode=$(stat -c '%a' "$envfile") [ "$mode" = "600" ] || die "$envfile is mode $mode, must be 600" done mkdir -p /datadisk/pg-ai /datadisk/ai-docs /datadisk/langfuse/db } # --- Sync the repo into place ------------------------------------------------ sync_files() { say "Syncing compose files and application code" # Compose files live in ~ by house convention; the repo is the source of them. cp -v "$REPO/compose/ai-compose.yml" "$COMPOSE" cp -v "$REPO/compose/langfuse-compose.yml" "$LANGFUSE_COMPOSE" mkdir -p "$HOME/ai/cube" rsync -a --delete "$REPO/cube/model/" "$HOME/ai/cube/model/" } # --- Phase 1: pg-ai, schema, roles, seed, fixtures -------------------------- phase1() { say "Phase 1 - pg-ai" docker compose -f "$COMPOSE" up -d pg-ai echo " waiting for pg-ai to report healthy" for _ in $(seq 1 30); do [ "$(docker inspect -f '{{.State.Health.Status}}' pg-ai)" = "healthy" ] && break sleep 2 done [ "$(docker inspect -f '{{.State.Health.Status}}' pg-ai)" = "healthy" ] \ || die "pg-ai did not become healthy - check docker logs pg-ai" say "Applying schema, roles and seed data" docker cp "$REPO/db" pg-ai:/tmp/db docker exec -e PGPASSWORD_FILE=/dev/null pg-ai \ psql -U postgres -d plant -v ON_ERROR_STOP=1 -f /tmp/db/001_schema.sql docker exec pg-ai \ psql -U postgres -d plant -v ON_ERROR_STOP=1 -f /tmp/db/003_roles.sql # Aliases are pipe-separated in the CSVs; split them on load. docker exec -i pg-ai psql -U postgres -d plant -v ON_ERROR_STOP=1 <<'PSQL' CREATE TEMP TABLE eq_stage (equipment_id TEXT, display_name TEXT, aliases TEXT, equipment_type TEXT, unit_name TEXT, description TEXT); \copy eq_stage FROM '/tmp/db/seed/equipment.csv' WITH (FORMAT csv, HEADER true) INSERT INTO equipment SELECT equipment_id, display_name, string_to_array(aliases,'|'), equipment_type, unit_name, description FROM eq_stage ON CONFLICT (equipment_id) DO UPDATE SET display_name=EXCLUDED.display_name, aliases=EXCLUDED.aliases, equipment_type=EXCLUDED.equipment_type, unit_name=EXCLUDED.unit_name, description=EXCLUDED.description; CREATE TEMP TABLE tag_stage (tag_id TEXT, equipment_id TEXT, display_name TEXT, aliases TEXT, signal_type TEXT, engineering_unit TEXT, range_low DOUBLE PRECISION, range_high DOUBLE PRECISION, alarm_setpoint_hi DOUBLE PRECISION, alarm_setpoint_lo DOUBLE PRECISION, trip_setpoint DOUBLE PRECISION, description TEXT); \copy tag_stage FROM '/tmp/db/seed/tags.csv' WITH (FORMAT csv, HEADER true) INSERT INTO tags SELECT tag_id, equipment_id, display_name, string_to_array(aliases,'|'), signal_type, engineering_unit, range_low, range_high, alarm_setpoint_hi, alarm_setpoint_lo, trip_setpoint, description FROM tag_stage ON CONFLICT (tag_id) DO UPDATE SET equipment_id=EXCLUDED.equipment_id, display_name=EXCLUDED.display_name, aliases=EXCLUDED.aliases, signal_type=EXCLUDED.signal_type, engineering_unit=EXCLUDED.engineering_unit, range_low=EXCLUDED.range_low, range_high=EXCLUDED.range_high, alarm_setpoint_hi=EXCLUDED.alarm_setpoint_hi, alarm_setpoint_lo=EXCLUDED.alarm_setpoint_lo, trip_setpoint=EXCLUDED.trip_setpoint, description=EXCLUDED.description; PSQL # Fixtures last, and only while imh is pending. if grep -q '^USE_FIXTURES=true' "$HOME/ai/api.env"; then warn "USE_FIXTURES=true - loading GENERATED fixture data, not plant history" docker exec pg-ai psql -U postgres -d plant -v ON_ERROR_STOP=1 -f /tmp/db/002_fixtures.sql else say "USE_FIXTURES is not true - skipping fixtures, Cube should point at imh" fi docker exec pg-ai rm -rf /tmp/db } # --- Phase 2: Langfuse ------------------------------------------------------- phase2() { say "Phase 2 - Langfuse" [ -f "$HOME/ai/langfuse.env" ] || die "missing ~/ai/langfuse.env (0600)" docker compose -f "$LANGFUSE_COMPOSE" up -d manual_steps "lf.yokogawa.tech" } # --- Application containers -------------------------------------------------- deploy_api() { say "Building and starting cube and ai-api" docker compose -f "$COMPOSE" up -d --build cube ai-api manual_steps "cube.yokogawa.tech and api.yokogawa.tech" } deploy_web() { say "Building and starting ai-web" docker compose -f "$COMPOSE" up -d --build ai-web manual_steps "ai.yokogawa.tech" warn "Azure hairpin: cicore1 cannot reach the public IP from inside the VNet." warn "The DC needs a pinpoint record ai.yokogawa.tech -> 10.0.0.17. Ask Dan." } # --- The parts a human must do ----------------------------------------------- manual_steps() { local hostnames="$1" cat < 20.211.144.151. Ask Dan; DNS is not managed here. Caddy cannot issue a certificate without it. 2. Append the block from caddy/ai-routes.caddy to ~/Caddyfile. Keep 'import authelia'. Omitting it silently makes the service public. cp ~/Caddyfile ~/Caddyfile.bak-ai-${STAMP} docker exec caddy caddy reload --config /etc/caddy/Caddyfile 3. Add the hostname to the HTTPS_UserAccess two_factor rule. See authelia/access-rules.md. Root-owned - use sudo, back up first. sudo cp ~/authelia/configuration.yml ~/authelia/configuration.yml.bak-ai-${STAMP} 4. ANNOUNCE, then restart Authelia. It logs out every active user on the host, including anyone mid-demo. docker compose -f ~/authelia-compose.yml restart authelia 5. Verify. 'Up' is not proof. ./scripts/verify.sh ------------------------------------------------------------------ EOM } case "$TARGET" in phase1) preflight; sync_files; phase1 ;; phase2) preflight; sync_files; phase2 ;; api) preflight; sync_files; deploy_api ;; web) preflight; sync_files; deploy_web ;; all) preflight; sync_files; phase1; phase2; deploy_api; deploy_web ;; *) die "unknown target '$TARGET' - use phase1|phase2|api|web|all" ;; esac say "Done. Now run ./scripts/verify.sh - docker ps showing Up is not proof."