diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a741504 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,65 @@ +# visionA — 共用 .dockerignore +# +# 任何 docker / docker buildx build 都會用此檔過濾 build context。 +# 目的:縮小傳給 daemon 的 context(visionA repo 含 local-tool 等子產品總計 GB 級) +# +# 對齊: +# - docker/Dockerfile.stage(COPY visionA-frontend/, visionA-backend/) +# - visionA-backend/docker/Dockerfile.api-server / Dockerfile.remote-proxy +# +# ⚠️ 注意:規則生效範圍是「build context root」(buildx build . 時即此 repo 根) + +# ────────── 版控 / IDE ────────── +.git +.gitignore +.gitattributes +.github +.vscode +.idea +.DS_Store +Thumbs.db + +# ────────── Autoflow / Claude / 文件(不進 image) ────────── +.autoflow +.claude +docs +README.md +Makefile +docker-compose.dev.yml +.env.dev.example +.env.dev +.env.dev.generated +.env +.env.local +.env.*.local +.env.stage # 絕不可進 image — 含 secrets + +# ────────── 兩個與 stage 無關的子產品(不進 image) ────────── +local-tool +local-agent + +# ────────── visionA-frontend ────────── +visionA-frontend/node_modules +visionA-frontend/.next +visionA-frontend/out +visionA-frontend/dist +visionA-frontend/build +visionA-frontend/coverage +visionA-frontend/.turbo +# IDE / config 雜項可進 image,影響極小,不過濾 + +# ────────── visionA-backend ────────── +visionA-backend/bin +visionA-backend/dist +visionA-backend/data +visionA-backend/coverage.out +visionA-backend/*.test +visionA-backend/.env +visionA-backend/.env.* + +# ────────── 雜物 ────────── +*.log +*.tmp +*.swp +*.swo +graphify-out diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 0000000..911f26e --- /dev/null +++ b/.env.dev.example @@ -0,0 +1,78 @@ +# visionA dev 環境變數範本 +# +# 使用方式: +# cp .env.dev.example .env.dev +# # 編輯 .env.dev,填入 OAuth client_id / client_secret(手動 seed 後產生) +# docker compose -f docker-compose.dev.yml --env-file .env.dev up -d +# +# 詳細步驟:見 docs/DEV-SETUP.md +# +# ⚠️ 不要 commit .env.dev(已在 .gitignore 中排除) + +# ============================================================ +# Member Center +# ============================================================ +# 預設用 ../member_center 路徑 build。如果你的 member_center 在別處,改這個。 +# 例:MEMBER_CENTER_PATH=/Users/me/code/member_center +MEMBER_CENTER_PATH=../member_center + +# Member Center admin 帳號(installer init 會建立) +# 之後可用這組帳密登入 MC admin UI / 拿 admin API token +MC_ADMIN_EMAIL=admin@visiona.local +MC_ADMIN_PASSWORD=Admin12345! + + +# ============================================================ +# visionA OIDC client(必須先在 MC 註冊,詳見 docs/DEV-SETUP.md) +# ============================================================ +# 第一次起來時這兩個值還沒有 → 先用 static auth 跑(VISIONA_AUTH_TYPE=static) +# OAuth client 註冊完後填入這兩個值 + 改 VISIONA_AUTH_TYPE=oidc → docker compose up -d 重啟 +VISIONA_OIDC_CLIENT_ID=CHANGE_ME +VISIONA_OIDC_CLIENT_SECRET=CHANGE_ME + +# auth mode 切換:static(雛形預設)/ oidc(接 MC) +VISIONA_AUTH_TYPE=static + +# OIDC issuer / redirect — 預設值已對齊 MC 的 dev port +# ⚠️ trailing slash 不可省(MC discovery 回的 issuer 帶 slash,否則 client init 會 reject) +VISIONA_OIDC_ISSUER_URL=http://localhost:5050/ +VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback + + +# ============================================================ +# Cookie / session +# ============================================================ +# 至少 32 byte 隨機字串(建議:openssl rand -hex 32) +VISIONA_SESSION_SECRET=please-change-me-32-bytes-random-dev-secret + +# Frontend URL(OIDC callback 完成後 redirect 回的目標) +VISIONA_FRONTEND_URL=http://localhost:3000 + + +# ============================================================ +# 既有 visionA-backend 環境變數(與 visionA-backend/.env.example 一致) +# ============================================================ +VISIONA_LOG_LEVEL=info +VISIONA_API_PORT=3721 +VISIONA_TUNNEL_PORT=3800 +# Phase 0.7 security audit (2026-05-01) 後僅供 dev seed (VISIONA_SEED_DEMO_DATA=true) 與 unit test fixture 用; +# 已從 api.Deps 移除(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md C1); +# stage / prod 不需設定。 +VISIONA_STATIC_USER_ID=demo-user + +VISIONA_CORS_ALLOWED_ORIGINS=http://localhost:3000 +VISIONA_SEED_DEMO_DATA=true + +VISIONA_STORAGE_BASE_URL=http://localhost:3721/storage +# ⚠️ 生產必改:openssl rand -hex 32 +VISIONA_STORAGE_SIGNING_SECRET=dev-signing-secret-change-me-32-bytes + +# Pairing token(雛形仍用 static;留空則動態配發) +VISIONA_PAIRING_TOKEN= + + +# ============================================================ +# 進階:port 衝突時可改 +# ============================================================ +# POSTGRES_PORT=5432 +# MEMBER_CENTER_PORT=5050 diff --git a/.env.stage.example b/.env.stage.example new file mode 100644 index 0000000..7300fd2 --- /dev/null +++ b/.env.stage.example @@ -0,0 +1,115 @@ +# visionA — stage 環境變數範本 +# +# 使用方式: +# 1. 在 stage host 上: +# cp .env.stage.example .env.stage +# nano .env.stage # 填入 secrets(見下方說明) +# 2. .env.stage 與 docker-compose.stage.yml 同目錄 +# 3. ⚠️ 不進 git(.gitignore 已排除) +# +# Secret 產生方式: +# openssl rand -hex 32 +# +# 對齊: +# - visionA-backend/internal/config/config.go(A1 後 ClientSecret 變選填、預留 ServiceClient*) +# - .autoflow/04-architecture/oidc-tdd.md §13.1 +# - .autoflow/progress.md Phase 0.7 → S6(OIDC public PKCE-only client) + +# ============================================================ +# OIDC — Member Center @ stage +# ============================================================ +# Issuer URL — 結尾斜線**必要**(MC discovery 回的 issuer 帶 slash,否則 client init reject) +VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/ + +# Login client(public PKCE-only — 無 secret) +VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e +# 留空 → backend 走 PKCE-only mode(A1 後支援;見 ADR-013) +VISIONA_OIDC_CLIENT_SECRET= + +# Service-to-service client(client_credentials grant) +# Phase 0.7 預留,不啟用;填入也不會被 main.go wire(見 config.go ServiceClientID 註解) +# ⚠️ 兩個值都禁止寫死進 git tracked 檔;只在 stage host 的 .env.stage 才填入真值 +VISIONA_OIDC_SERVICE_CLIENT_ID= +VISIONA_OIDC_SERVICE_CLIENT_SECRET= + +# Callback URL — 必須與 MC 端 client 設定的 redirect_uri 完全一致 +VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback + +# Frontend URL — OIDC callback 完成後 302 回的目標(同 host 同 port) +VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527 + +# ============================================================ +# Cookie session(OIDC 登入後在 browser 端的 session cookie) +# ============================================================ +# Cookie HMAC 簽章金鑰 — **必須換掉** +# 產生:openssl rand -hex 32 +VISIONA_SESSION_SECRET=CHANGE_ME_OPENSSL_RAND_HEX_32 + +# CookieDomain:留空 = host-only cookie(推薦,stage 只有單一 host) +# 若未來要跨子網域共享 session 才設成 .innovedus.com 之類 +VISIONA_SESSION_COOKIE_DOMAIN= + +# CookieSecure:stage 走 HTTPS → 必須 true +VISIONA_SESSION_COOKIE_SECURE=true + +# Session TTL(預設值。如要改,去掉註解填值) +# VISIONA_SESSION_ABSOLUTE_TTL=168h +# VISIONA_SESSION_IDLE_TTL=24h + +# ============================================================ +# Server — port 都對齊 nginx.stage.conf +# ============================================================ +VISIONA_HOST=0.0.0.0 +VISIONA_API_PORT=3721 +VISIONA_TUNNEL_PORT=3800 +VISIONA_PROXY_INTERNAL_PORT=3801 +VISIONA_PROXY_INTERNAL_URL=http://127.0.0.1:3801 + +# api-server 端的 SessionStore backend:proxy-client = 透過 internal HTTP 查 remote-proxy +# remote-proxy 端的 SessionStore backend:inmemory = 自己持有 yamux session +# 兩個 binary 共讀此 .env,但各自只看自己需要的欄位 +VISIONA_SESSION_BACKEND=proxy-client + +# Agent 連 tunnel 用的對外 URL(/api/pairing/exchange 回給 agent) +# 注意 ws→wss、host:port 與對外 HTTPS 一致 +VISIONA_RELAY_PUBLIC_URL=wss://stage-9527.innovedus.com:9527 + +# ============================================================ +# CORS — stage 同 host 同源(frontend 與 backend 都從 :9527 出來),不需放 +# ============================================================ +VISIONA_CORS_ALLOWED_ORIGINS= + +# ============================================================ +# Storage — 雛形 LocalFS(host 的 /opt/visiona/data/ 掛進 container) +# ============================================================ +VISIONA_STORAGE_BACKEND=localfs +VISIONA_STORAGE_LOCALFS_ROOT=/data/storage +# presigned URL 對外可達 base,與公司 host nginx 對外一致 +VISIONA_STORAGE_LOCALFS_BASE_URL=https://stage-9527.innovedus.com:9527/storage +# presigned URL HMAC secret — **必須換掉** +# 產生:openssl rand -hex 32 +VISIONA_STORAGE_SIGNING_SECRET=CHANGE_ME_OPENSSL_RAND_HEX_32 + +# ============================================================ +# Model upload +# ============================================================ +# 模型上傳大小上限(MB)— 注意要與 nginx.stage.conf 的 client_max_body_size 對齊 +# 目前 nginx 設 100M,這裡也 100;要改大兩處要一起改 +VISIONA_MODEL_MAX_SIZE_MB=100 + +# ============================================================ +# Pairing token(雛形:留空 = 動態配發;填值 = 寫死) +# 對齊 .autoflow/02-prd/feature-pairing-token.md +# ============================================================ +VISIONA_PAIRING_TOKEN= + +# ============================================================ +# Misc +# ============================================================ +VISIONA_LOG_LEVEL=info +# stage 不塞 demo data(避免 storage 被假資料污染) +VISIONA_SEED_DEMO_DATA=false + +# Phase 0.7 security audit (2026-05-01) 後 stage/prod 不再讀此值; +# 已從 api.Deps 移除(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md C1)。 +# 留註解作為 audit trail;stage 部署不需設定 VISIONA_STATIC_USER_ID。 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..bc020f7 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,273 @@ +# visionA — 一鍵開發環境(dev all-in-one) +# +# 對應:.autoflow/04-architecture/oidc-tdd.md §12 +# +# 服務拓撲: +# +# browser ──(3000)──▶ frontend (host: pnpm dev,不在 compose 內) +# │ +# ▼ /api/*(fetch with cookie) +# browser ──(3721)──▶ visiona-api ──(internal:3801)──▶ visiona-proxy +# │ ▲ +# │ OIDC redirect / token │ +# ▼ │ +# member-center ──(5432)──▶ postgres │ +# │ │ +# (member-center-init) │ +# local-agent (host: ./local-tool) +# │ +# ▼ +# WS 3800 → visiona-proxy +# +# 使用流程(詳見 docs/DEV-SETUP.md): +# +# 1. 確認 ../member_center 與本 repo 同一層 +# 2. 複製 .env.dev.example 成 .env.dev,視需要調整 +# 3. docker compose -f docker-compose.dev.yml up -d --build +# 4. 等所有 service healthy(docker compose ps) +# 5. 首次啟動完成後,依 docs/DEV-SETUP.md 「OAuth Client 註冊」一節, +# 手動建立 visionA OAuth client(MC admin API) +# 6. 把產出的 client_id / client_secret 寫回 .env.dev → docker compose up -d 重啟 visiona-api +# 7. 另開 terminal: cd visionA-frontend && pnpm dev +# 8. 開瀏覽器 http://localhost:3000 → 點登入 +# +# 一鍵清乾淨:docker compose -f docker-compose.dev.yml down -v +# +# ⚠️ 此檔案僅供 dev 用,不要拿去 production。production 走 visionA-backend/docker/docker-compose.yml + IaC。 + +name: visiona-dev + +services: + # ────────────────────────────────────────────────────────── + # PostgreSQL — 給 Member Center 用 + # ────────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + container_name: visiona-dev-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: membercenter + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d membercenter"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 5s + networks: + - visiona-dev-net + + # ────────────────────────────────────────────────────────── + # Member Center — Innovedus SSO(OIDC provider) + # + # ⚠️ build context 預設 ../member_center;可用 MEMBER_CENTER_PATH 環境變數覆寫 + # ────────────────────────────────────────────────────────── + member-center: + build: + context: ${MEMBER_CENTER_PATH:-../member_center} + dockerfile: src/MemberCenter.Api/Dockerfile + image: visiona/member-center-api:dev + container_name: visiona-dev-member-center + restart: unless-stopped + environment: + # Connection string(雙底線 = ASP.NET Core 慣例的 nested key) + ConnectionStrings__Default: "Host=postgres;Port=5432;Database=membercenter;Username=postgres;Password=postgres" + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: "http://+:5050" + # OIDC issuer:visionA-backend 從 container 內以 http://member-center:5050 連, + # 但 browser 走 localhost。Issuer 必須跟 browser 看到的一致才能驗 id_token。 + # 這裡用 http://localhost:5050 → discovery 與 id_token iss 都是這個值, + # 而 visionA-backend 改 hosts 指 /etc/hosts member-center → 127.0.0.1(見下方 extra_hosts), + # 即可從 container 內也用 http://localhost:5050。 + Auth__Issuer: "http://localhost:5050/" + Auth__AllowInsecureHttp: "true" # dev only — 允許 OIDC 跑 HTTP + Auth__MemberCenterAudience: "member_center_api" + Auth__SendEngineAudience: "send_engine_api" + ports: + - "${MEMBER_CENTER_PORT:-5050}:5050" + depends_on: + postgres: + condition: service_healthy + healthcheck: + # MC image (debian-slim) 沒裝 curl/wget,改用 bash + /dev/tcp 測 TCP 連得上即可 + # (能 connect 5050 即視為 alive;HTTP 200 驗證留給 caller 端做) + test: + - "CMD" + - "bash" + - "-c" + - "/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + fi + done + # 給 30s 讓子 process 收尾,再強殺 + local deadline=$(( $(date +%s) + 30 )) + while [ "$(date +%s)" -lt "$deadline" ]; do + local alive=0 + for pid in "${PIDS[@]:-}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + alive=1 + break + fi + done + [ "$alive" -eq 0 ] && break + sleep 1 + done + for pid in "${PIDS[@]:-}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + log "force killing pid=$pid" + kill -KILL "$pid" 2>/dev/null || true + fi + done + exit 0 +} +trap shutdown TERM INT + +# ──────────── 起 process ──────────── + +log "starting remote-proxy on tunnel:${VISIONA_TUNNEL_PORT} internal:${VISIONA_PROXY_INTERNAL_PORT}" +/usr/local/bin/remote-proxy & +PIDS+=("$!") + +# api-server 啟動會立即去探測 remote-proxy 的 internal HTTP(POC 假設)→ 給它一兩秒 +sleep 1 + +log "starting api-server on :${VISIONA_API_PORT}" +/usr/local/bin/api-server & +PIDS+=("$!") + +log "starting next.js standalone server on ${NEXT_HOSTNAME}:${NEXT_PORT}" +# Next.js 16 standalone 結構: +# /var/www/visiona/standalone/server.js +# /var/www/visiona/standalone/.next/static/ ← Dockerfile 已 COPY 進來 +# /var/www/visiona/standalone/public/ ← Dockerfile 已 COPY 進來 +# server.js 用環境變數 PORT / HOSTNAME 控制 listen address +( + cd /var/www/visiona/standalone + PORT="$NEXT_PORT" HOSTNAME="$NEXT_HOSTNAME" exec node server.js +) & +PIDS+=("$!") + +log "starting nginx (foreground via daemon off)" +nginx -g 'daemon off;' & +PIDS+=("$!") + +log "all process started: pids=${PIDS[*]}" + +# ──────────── 等任一 process 退出 ──────────── +# wait -n 在 bash 4.3+ 支援;alpine 用 apk add bash 後可用。 +# 任一 process 結束 → 整個 container 跟著結束 → docker restart 重起整套 +wait -n +exit_code=$? +log "a child process exited (code=${exit_code}) — terminating container" + +# 觸發 shutdown 把其餘 process 收尾 +shutdown diff --git a/docker/nginx.stage.conf b/docker/nginx.stage.conf new file mode 100644 index 0000000..cd76b1d --- /dev/null +++ b/docker/nginx.stage.conf @@ -0,0 +1,262 @@ +# visionA — stage 環境 nginx 設定 +# +# 角色定位: +# 公司 host nginx ── HTTPS :9527(LE 證書) ──→ container :80(本檔) +# ↓ +# ┌───────────────────────────────────────────────────────────────┐ +# │ container 內 nginx :80 │ +# │ /healthz → 直接回 200(給 docker healthcheck) │ +# │ /api/* → 127.0.0.1:3721 (api-server) │ +# │ /storage/* → 127.0.0.1:3721 (api-server presigned URL) │ +# │ /tunnel/connect→ 127.0.0.1:3800 (remote-proxy WS upgrade) │ +# │ /_next/static/ → 127.0.0.1:3000 (next standalone, 1y cache) │ +# │ / → 127.0.0.1:3000 (next standalone server.js) │ +# └───────────────────────────────────────────────────────────────┘ +# +# 上游 process 由 entrypoint.stage.sh 啟動,全在同一 container loopback。 + +# ────────── upstream 定義 ────────── +# 在 server block 外層 alias,讓 proxy_pass 可重用。 + +upstream visiona_api { + server 127.0.0.1:3721; + keepalive 32; +} + +upstream visiona_tunnel { + server 127.0.0.1:3800; + keepalive 16; +} + +upstream visiona_frontend { + server 127.0.0.1:3000; + keepalive 32; +} + +# WebSocket upgrade map(給 /tunnel/connect 用) +# 在 http context 用,server block 內也可繼承。 +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# ────────── Host header 白名單(M2 — trust boundary) ────────── +# +# 公司 host nginx 已 termination HTTPS(stage-9527.innovedus.com:9527), +# 但 reverse proxy 把原始 Host header 透傳進來。為避免 backend 拿到偽造 Host +# 來組 absolute URL(return_to / email link / cache key),這層強制白名單: +# +# - 任何不符合 server_name 的 host(含直接打 IP、攻擊者偽造 Host)→ 444 close +# - 命中 stage-9527.innovedus.com 的請求才進真正的 server block +# +# 444 = nginx 專屬 status,直接關連線、不回 response,不給攻擊者反饋。 +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # /healthz 例外:Docker healthcheck 從 container 內打 localhost/healthz + # (Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任) + # 限制 source = 127.0.0.0/8 防止外部偽造 Host 跳過白名單 + location = /healthz { + allow 127.0.0.0/8; + allow ::1/128; + deny all; + # 直接內部回 200,不轉到 api-server(避免 api-server 也要實作 /healthz) + return 200 "ok\n"; + add_header Content-Type "text/plain" always; + } + + # 其他任何 host header 不符合白名單 → 444 close(不回 response 給攻擊者反饋) + return 444; +} + +server { + listen 80; + listen [::]:80; + server_name stage-9527.innovedus.com; + + # ============================================================ + # 全域行為 + # ============================================================ + + # 模型上傳上限(PRD §8.4 — Phase 0 為 100 MB)。 + # /api/models/* 走 multipart/form-data,這個值是上限。 + client_max_body_size 100M; + + # 大量 long-lived 連線(tunnel WS、SSE、模型轉檔輪詢)— 拉長 read timeout + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # gzip — 對 JSON / JS / CSS 有效,避免重複壓縮二進位資源 + gzip on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml + image/svg+xml; + + # 安全 headers(nginx 已有公司 host 那層 HTTPS,這層補通用安全) + # X-Frame-Options 預設 SAMEORIGIN 給 iframe 防護(agent / pair view 不嵌 iframe) + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加) + + # ============================================================ + # 健康檢查 — 不打到 backend,docker healthcheck 用 + # ============================================================ + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # ============================================================ + # /api/* → api-server :3721 + # 包含 /api/auth/*(OIDC callback)、/api/devices、/api/models、/api/pairing 等 + # ============================================================ + location /api/ { + proxy_pass http://visiona_api; + proxy_http_version 1.1; + proxy_buffering off; # 模型上傳串流 / SSE friendly + + # ── Proxy headers ── + # X-Forwarded-Proto = https:讓 backend 產的 redirect / cookie Secure 判斷正確 + # (公司 host nginx 已 termination;container 收到的是 HTTP) + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Connection ""; + + # ── Cache 防護(M3) ── + # /api/auth/me 等含 PII;/api/auth/callback 帶 code/state + # 一律禁止任何中間 proxy / browser cache(含 BFCache) + proxy_no_cache 1; + proxy_cache_bypass 1; + add_header Cache-Control "no-store, no-cache, must-revalidate, private" always; + add_header Pragma "no-cache" always; + + # nginx add_header 在 location level 會完全覆蓋 server level(不 merge) + # 因此 server level 的安全 header 必須在這裡 re-add + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } + + # ============================================================ + # /storage/* → api-server :3721 + # presigned URL 走這條(HMAC 簽章 query string、不帶 cookie) + # 雛形 LocalFS backend 的下載端點 + # ============================================================ + location /storage/ { + proxy_pass http://visiona_api; + proxy_http_version 1.1; + proxy_buffering off; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Connection ""; + + # ── Cache 防護(M3 — 保守作法) ── + # presigned URL 含短期 HMAC、理論上不可重複使用,但內容是用戶上傳模型(敏感)。 + # 一律禁止中間 proxy 共用 cache,避免 query string 漏進 cache key 時被旁人取得。 + # 若未來改 S3 backend + presigned 直連,這條 location 會被拆掉,屆時改 backend 自行決定 cache 策略。 + proxy_no_cache 1; + proxy_cache_bypass 1; + add_header Cache-Control "private, no-store" always; + + # nginx location level 的 add_header 會完全覆蓋 server level — re-add 安全 header + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } + + # ============================================================ + # /tunnel/connect — WebSocket upgrade,由 visionA Agent 連入 + # 要 long-lived(24h 心跳級別);拉到 86400s。 + # 注意:path 必須是 /tunnel/connect(不是 /tunnel/)— remote-proxy 只開這個 endpoint + # ============================================================ + location /tunnel/connect { + proxy_pass http://visiona_tunnel; + proxy_http_version 1.1; + + # WebSocket upgrade headers + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # tunnel 連線可長達數小時,且心跳由 yamux 處理;nginx 不要中途斷 + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + + # 不 buffer,避免延遲 WS 訊框 + proxy_buffering off; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + # ============================================================ + # Next.js hashed static assets — 永久 cache + # /_next/static/{hash}.js 等 + # ============================================================ + location /_next/static/ { + proxy_pass http://visiona_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + + # Hash 帶在路徑裡,內容變了路徑就變 → 可以 immutable 1 年 + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + # ============================================================ + # 其他靜態資源(public/ 下的圖片、字型等)— 1 day cache + # ============================================================ + location ~* ^/(favicon\.ico|.*\.(?:png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot))$ { + proxy_pass http://visiona_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + + expires 1d; + add_header Cache-Control "public, max-age=86400" always; + } + + # ============================================================ + # 全部其他請求 → Next.js standalone server (:3000) + # 包含: + # - / (首頁) + # - /login, /register, /account, /clusters, /devices, /devices/[id], + # /devices/pair, /models, /models/[id], /workspace/[deviceId], /settings + # - /_next/data/* (RSC payload) + # - /_next/image (Next image optimizer,雖然 standalone 預設啟用 sharp) + # 不在這裡做 SPA fallback — Next.js server 自己會處理 404 與動態 route + # ============================================================ + location / { + proxy_pass http://visiona_frontend; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Connection ""; + + # SSE / streaming 友善 + proxy_buffering off; + proxy_cache off; + } +} diff --git a/docker/register-oauth-client.sh b/docker/register-oauth-client.sh new file mode 100755 index 0000000..ad6146e --- /dev/null +++ b/docker/register-oauth-client.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# register-oauth-client.sh +# +# ⚠️ 目前狀態:MC 的 password grant 在 Identity user 上拿不到 sub claim 而 500(已知 MC bug) +# 所以這支 script 暫時跑不通。註冊 OAuth client 請走 MC Web UI(見 docs/DEV-SETUP.md)。 +# +# 保留此檔案是為了: +# 1. 等 MC 修好 password flow 後可立刻啟用 +# 2. 留下「應該長什麼樣」的程式碼參考 +# +# 用法(MC 修好後): +# bash docker/register-oauth-client.sh +# +# 需要環境變數(會從 .env.dev / .env 讀,否則用預設): +# MC_BASE_URL 預設 http://localhost:5050 +# MC_ADMIN_EMAIL 預設 admin@visiona.local +# MC_ADMIN_PASSWORD 預設 Admin12345! +# VISIONA_REDIRECT_URI 預設 http://localhost:3721/api/auth/callback +# OUTPUT_FILE 預設 .env.dev.generated(在 repo 根) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ -f "${REPO_ROOT}/.env.dev" ]]; then + # shellcheck disable=SC1091 + set -a; source "${REPO_ROOT}/.env.dev"; set +a +fi + +MC_BASE_URL="${MC_BASE_URL:-http://localhost:5050}" +MC_ADMIN_EMAIL="${MC_ADMIN_EMAIL:-admin@visiona.local}" +MC_ADMIN_PASSWORD="${MC_ADMIN_PASSWORD:-Admin12345!}" +VISIONA_REDIRECT_URI="${VISIONA_OIDC_REDIRECT_URL:-http://localhost:3721/api/auth/callback}" +OUTPUT_FILE="${OUTPUT_FILE:-${REPO_ROOT}/.env.dev.generated}" + +CYAN='\033[0;36m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' + +log() { echo -e "${CYAN}[register]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +err() { echo -e "${RED}[error]${NC} $*" >&2; } + +require() { command -v "$1" >/dev/null 2>&1 || { err "missing tool: $1(brew install $1)"; exit 1; }; } +require curl +require jq + +# ── 1. 拿 admin token(MC password flow,目前有 bug)──────── +log "fetching admin token from ${MC_BASE_URL}/oauth/token..." +TOKEN_RES=$(curl -sS -X POST "${MC_BASE_URL}/oauth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password&username=${MC_ADMIN_EMAIL}&password=${MC_ADMIN_PASSWORD}&scope=openid email profile") || { + err "MC unreachable at ${MC_BASE_URL}. Is docker compose up?"; exit 1; + } + +if ! echo "${TOKEN_RES}" | head -c 1 | grep -q '{'; then + err "MC password grant failed (回應非 JSON,可能 500 error)。" + err "已知 MC bug:Identity user principal 缺 sub claim → OpenIddict reject。" + err "請改走 MC Web admin UI 註冊 OAuth client,詳見 docs/DEV-SETUP.md。" + exit 1 +fi + +ACCESS_TOKEN=$(echo "${TOKEN_RES}" | jq -r '.access_token // empty') +if [[ -z "${ACCESS_TOKEN}" || "${ACCESS_TOKEN}" == "null" ]]; then + err "failed to get admin token. response:"; echo "${TOKEN_RES}" | jq . >&2 || echo "${TOKEN_RES}" >&2 + exit 1 +fi +ok "got admin token (length=${#ACCESS_TOKEN})" + +# ── 2. 建 tenant ─────────────────────────────────────────── +log "ensuring visionA tenant..." +EXISTING_TENANT_ID=$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "${MC_BASE_URL}/admin/tenants" | jq -r '.[] | select(.name == "visionA") | .id // empty' | head -1) + +if [[ -n "${EXISTING_TENANT_ID}" ]]; then + TENANT_ID="${EXISTING_TENANT_ID}" + ok "tenant 'visionA' already exists: ${TENANT_ID}" +else + TENANT_RES=$(curl -sS -X POST "${MC_BASE_URL}/admin/tenants" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"name":"visionA","domains":["visiona.cloud","localhost"],"status":"active"}') + TENANT_ID=$(echo "${TENANT_RES}" | jq -r '.id // empty') + [[ -z "${TENANT_ID}" ]] && { err "tenant create failed:"; echo "${TENANT_RES}" >&2; exit 1; } + ok "created tenant 'visionA': ${TENANT_ID}" +fi + +# ── 3. 建 OAuth client ───────────────────────────────────── +log "ensuring OAuth client..." +EXISTING_CLIENT_ID=$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "${MC_BASE_URL}/admin/oauth-clients" | jq -r '.[] | select(.name == "visionA Cloud") | .client_id // empty' | head -1) + +if [[ -n "${EXISTING_CLIENT_ID}" ]]; then + CLIENT_ID="${EXISTING_CLIENT_ID}" + ok "OAuth client 'visionA Cloud' already exists: ${CLIENT_ID}" +else + CLIENT_RES=$(curl -sS -X POST "${MC_BASE_URL}/admin/oauth-clients" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tenant_id\":\"${TENANT_ID}\",\"name\":\"visionA Cloud\",\"client_type\":\"public\",\"usage\":\"webhook_outbound\",\"redirect_uris\":[\"${VISIONA_REDIRECT_URI}\"]}") + CLIENT_ID=$(echo "${CLIENT_RES}" | jq -r '.clientId // .ClientId // empty') + [[ -z "${CLIENT_ID}" ]] && { err "OAuth client create failed:"; echo "${CLIENT_RES}" >&2; exit 1; } + ok "created OAuth client: ${CLIENT_ID}" +fi + +# ── 4. 寫出 env ──────────────────────────────────────────── +cat > "${OUTPUT_FILE}" < 對應 `.autoflow/04-architecture/oidc-tdd.md` §12 + Phase 0.6 OD1 任務 +> +> 適用對象:剛 clone 下來、第一次想跑起 visionA + Member Center OIDC flow 的開發者 +> +> 預期時間:第一次 build ~10 分鐘;之後重啟 < 30 秒 + +--- + +## TL;DR + +```bash +# 1. 確認 ../member_center 與本 repo 平行擺放 +# visionA/ 與 member_center/ 在同一個 parent dir +ls -d ../member_center # 應該存在 + +# 2. 複製 env 範本 +cp .env.dev.example .env.dev +# 視情況編輯(多半預設值即可) + +# 3. 起所有 service +docker compose -f docker-compose.dev.yml --env-file .env.dev up -d --build +# 第一次:~10 分鐘(dotnet 8 build + go build) +# 之後 :< 30 秒 + +# 4. 等所有 service healthy(< 1 分鐘) +docker compose -f docker-compose.dev.yml ps +# 全部要看到 (healthy) + +# 5. 註冊 visionA OAuth client(手動,~2 分鐘,見下方第 5 節) + +# 6. 把 client_id 寫進 .env.dev → 重啟 api +docker compose -f docker-compose.dev.yml --env-file .env.dev up -d visiona-api + +# 7. 另開 terminal 跑 frontend +cd visionA-frontend && pnpm install && pnpm dev + +# 8. 開 http://localhost:3000 → 點登入 → 完成 OIDC flow +``` + +--- + +## 1. 前置需求 + +| 工具 | 版本 | 安裝 | +|------|------|------| +| Docker Desktop | 最新 | https://docker.com/products/docker-desktop | +| Docker Compose | v2.30+ | 通常隨 Docker Desktop | +| pnpm | 9+ | `brew install pnpm` 或 `npm i -g pnpm` | +| jq | 任意 | `brew install jq`(auto seed script 用)| + +確認: + +```bash +docker --version # >= 24.x +docker compose version # >= v2.30 +node --version # >= 18 LTS(pnpm 需要) +``` + +--- + +## 2. 目錄結構假設 + +``` +parent/ +├── visionA/ ← 你在這(this repo) +│ ├── docker-compose.dev.yml +│ ├── .env.dev.example +│ ├── visionA-backend/ +│ └── visionA-frontend/ +└── member_center/ ← 平行擺放(用 git clone) + └── src/MemberCenter.Api/Dockerfile +``` + +如果你的 `member_center` 在別處: + +```bash +# 編輯 .env.dev +MEMBER_CENTER_PATH=/Users/me/code/member_center +``` + +--- + +## 3. 服務拓撲 + +| Service | Container | Host port | 用途 | +|---------|-----------|-----------|------| +| postgres | visiona-dev-postgres | 5432 | MC 的資料庫 | +| member-center | visiona-dev-member-center | **5050** | MC API(OIDC IdP) | +| member-center-web | visiona-dev-member-center-web | **5060** | MC 後台管理 UI | +| member-center-init | visiona-dev-member-center-init | – | 一次性 job:DB migrate + 建 admin | +| visiona-proxy | visiona-dev-proxy | 3800 (tunnel WS) | local agent 連入 | +| visiona-api | visiona-dev-api | **3721** | visionA 後端 API | +| frontend | (host pnpm dev) | **3000** | Next.js dev server | + +--- + +## 4. 起環境步驟 + +### 4.1 第一次(含 build) + +```bash +cd /path/to/visionA +cp .env.dev.example .env.dev + +# Build 所有 image(耐心等 ~10 分鐘) +docker compose -f docker-compose.dev.yml --env-file .env.dev build + +# 起來 +docker compose -f docker-compose.dev.yml --env-file .env.dev up -d +``` + +### 4.2 確認都健康 + +```bash +docker compose -f docker-compose.dev.yml ps +``` + +預期看到: + +``` +NAME STATUS +visiona-dev-postgres Up (healthy) +visiona-dev-member-center Up (healthy) +visiona-dev-member-center-web Up +visiona-dev-member-center-init Exited (0) ← 正常,一次性 job +visiona-dev-proxy Up (healthy) +visiona-dev-api Up (healthy) +``` + +如果 visiona-api 卡在 unhealthy / restarting → 看 §7 故障排除。 + +### 4.3 確認 OIDC discovery 通 + +```bash +curl -s http://localhost:5050/.well-known/openid-configuration | head -c 200 +``` + +應該看到 JSON,含 `"issuer":"http://localhost:5050/"`。 + +--- + +## 5. 註冊 visionA OAuth Client(**必做**) + +第一次 build 起來時 visionA-api 還沒有合法的 OIDC client_id(env 是 `CHANGE_ME`),點登入會 fail。 +需先在 MC 註冊一個 OAuth client。 + +### 5.1 走 MC Web UI(推薦,目前唯一可行路徑) + +> 為什麼不用 script 自動化:MC password grant 在 Identity user 上有個 bug(`mandatory subject claim was missing`),無法用 admin 帳密拿 token 呼叫 admin API。已於 MC 端開 issue。 + +**步驟**: + +1. **開 MC 後台**:http://localhost:5060 +2. **登入**: + - Email: `admin@visiona.local` + - Password: `Admin12345!` + - (這組密碼來自 `.env.dev` 的 `MC_ADMIN_EMAIL` / `MC_ADMIN_PASSWORD`) +3. **建 Tenant**: + - 進「Tenants」頁 + - 點「Create」/「新增」 + - Name: `visionA` + - Domains: `visiona.cloud, localhost` + - Status: `active` + - 送出 +4. **建 OAuth Client**: + - 進「OAuth Clients」頁 + - 點「Create」 + - Tenant: 選剛剛建的 `visionA` + - Name: `visionA Cloud` + - Client Type: `confidential` + - Usage: `webhook_outbound` + - (MC 目前沒有 `web_app` 類型,雛形借用 `webhook_outbound`,是唯一允許 redirect_uris 的非 client-credentials usage;對應 oidc-tdd §11.2) + - Redirect URIs: `http://localhost:3721/api/auth/callback` + - 送出 +5. **取 client_id / client_secret**: + - 建立成功後,畫面會顯示 client_id(GUID 格式) + - **client_secret**:MC 應該會顯示一次(如果 UI 沒給 secret,是 MC 的另一個 limitation;雛形階段可改成 `client_type=public` 不帶 secret) + +### 5.2 把 client_id / secret 寫進 .env.dev + +```bash +# 編輯 .env.dev +VISIONA_OIDC_CLIENT_ID=<剛剛拿到的 client_id GUID> +VISIONA_OIDC_CLIENT_SECRET=<剛剛拿到的 secret,沒拿到就留空> +``` + +### 5.3 重啟 visiona-api + +```bash +docker compose -f docker-compose.dev.yml --env-file .env.dev up -d visiona-api +docker compose -f docker-compose.dev.yml logs visiona-api --tail 5 +``` + +預期看到: + +``` +{"msg":"OIDC initialized","issuer":"http://localhost:5050/","client_id":"<你的 guid>","redirect_url":"http://localhost:3721/api/auth/callback"} +{"msg":"api-server listening","addr":"0.0.0.0:3721"} +``` + +--- + +## 6. 跑 Frontend + 完成 demo flow + +```bash +# 另開 terminal +cd visionA-frontend +pnpm install +pnpm dev +# → http://localhost:3000 +``` + +**Demo flow**: + +1. 開 http://localhost:3000 +2. 點「登入」按鈕(會跳到 backend /api/auth/login) +3. backend 302 到 MC `/oauth/authorize?...` +4. MC 顯示登入頁 → 用 `admin@visiona.local / Admin12345!` 登入 + (或先在 MC Web UI 建 demo user 帳號) +5. 同意授權後 redirect 回 `http://localhost:3721/api/auth/callback?code=...` +6. backend 換 token、驗 id_token、建 session +7. 302 回 frontend dashboard,cookie 已設 + +--- + +## 7. 故障排除 + +### 7.1 visiona-api 卡 unhealthy / restarting + +最常見:OIDC 沒設好 → backend crash loop。 + +```bash +docker compose -f docker-compose.dev.yml logs visiona-api --tail 30 +``` + +| 錯誤關鍵字 | 原因 | 解法 | +|----------|------|------| +| `discovery fetch failed` | MC 還沒起來 | 等 30 秒再看;或 `docker compose ps member-center` 確認 healthy | +| `issuer URL ... did not match` | issuer trailing slash 不一致 | 確認 `.env.dev` 的 `VISIONA_OIDC_ISSUER_URL=http://localhost:5050/`(**斜線結尾**) | +| `IssuerURL invalid` / `ClientID required` | env 沒帶到 | 確認啟動時用 `--env-file .env.dev` | +| `connection refused` | container 內找不到 host | 不應該發生(compose 已加 `extra_hosts: localhost:host-gateway`) | + +### 7.2 port 衝突 + +| port | 衝突來源 | 解法 | +|------|---------|-----| +| 3721 | 本機跑著 visionA-local(local-tool 那一邊) | 暫停 local-tool,或在 `.env.dev` 把 `VISIONA_API_PORT=3722` 改一下、frontend 端 base URL 也改 | +| 5432 | 本機 PostgreSQL | `.env.dev` 改 `POSTGRES_PORT=5433` | +| 5050 | 已被占用 | `.env.dev` 改 `MEMBER_CENTER_PORT=5051` + `VISIONA_OIDC_ISSUER_URL=http://localhost:5051/` | +| 5060 | 已被占用 | `.env.dev` 改 `MEMBER_CENTER_WEB_PORT=5061` | + +### 7.3 MC build 失敗 / Dockerfile 錯誤 + +如果 `MEMBER_CENTER_PATH` 路徑不對: + +```bash +# 確認 path +ls -d ${MEMBER_CENTER_PATH:-../member_center} + +# 用絕對路徑 +MEMBER_CENTER_PATH=/Users/you/code/member_center docker compose -f docker-compose.dev.yml build member-center +``` + +### 7.4 MC password grant 500 error + +``` +System.InvalidOperationException: The specified principal was rejected +because the mandatory subject claim was missing. +``` + +**已知 MC bug**(Identity user 缺 sub claim)。Phase 0.6 雛形請走 admin Web UI 註冊 OAuth client,不用 password flow。 + +### 7.5 一鍵完全重置 + +```bash +docker compose -f docker-compose.dev.yml down -v +# -v 會刪除 volumes(包括 postgres data + visiona storage) +# 下次 up -d 時會重新跑 member-center-init 建 admin 帳號 +``` + +### 7.6 OIDC flow 執行階段問題 + +> 這節對應跑完 §5(OAuth client 註冊)後,實際走 login flow 卻卡住的情境。 +> 詳細逐步驗證見 `docs/SMOKE-TEST.md`,這裡只列高頻錯誤對照表。 + +| 症狀 | 通常原因 | 解法 | +|------|---------|------| +| 點登入後 callback 噴 `state_mismatch` | `visiona_pending_sid` cookie 沒帶過去 | 確認 `VISIONA_FRONTEND_URL` 與 frontend 真實 URL 一致;不要設 cookie `Domain`;瀏覽器 DevTools → Application → Cookies 看 cookie 是否存進去 | +| callback 噴 `pending_session_not_found` | pending 過 10 分鐘 TTL,或 backend 重啟清掉 in-memory store | 重新點登入;雛形 in-memory 重啟即清空 | +| callback 噴 `id_token_invalid` | iss / aud / nonce 對不上,或 JWKS cache 過時 | 看 backend log `detail` 欄;嘗試 `docker compose restart visiona-api` 清 JWKS cache | +| callback 噴 `token_exchange_failed` | client_secret 不對 / redirect_uri 與 MC 設定不一致 | 比對 `.env.dev` 與 MC admin UI 中 client 設定 | +| `GET /api/auth/me` 永遠 401 | cookie 沒帶 / cookie 簽章對不上 / session 過期 | DevTools 看 request 是否帶 `Cookie:` header;frontend `fetch` 必須 `credentials: 'include'`;`VISIONA_SESSION_SECRET` 改過會讓舊 cookie 失效 → 清 cookie 重登 | +| Pairing token 綁的 user_id 是 `demo-user` 而非 OIDC sub | `VISIONA_AUTH_TYPE` 還是 `static`,或 OB5 沒拔乾淨 | 確認 `.env.dev` 是 `VISIONA_AUTH_TYPE=oidc` 且重啟過 visiona-api;`docker compose logs visiona-api \| grep -i pairing` 看實際 binding | +| 登出後仍能訪問受保護頁面 | `Set-Cookie: visiona_session=; Max-Age=0` 沒設對,或 frontend route guard 沒擋 | DevTools 確認 cookie 真的被清;訪 `/api/auth/me` 不帶 cookie 應 401 | + +--- + +## 8. 常用命令 + +```bash +# 看所有 service log +docker compose -f docker-compose.dev.yml logs -f + +# 看單一 service +docker compose -f docker-compose.dev.yml logs -f visiona-api + +# 重啟單一 service +docker compose -f docker-compose.dev.yml restart visiona-api + +# 進 postgres +docker exec -it visiona-dev-postgres psql -U postgres -d membercenter + +# 看 OpenIddict apps +docker exec -it visiona-dev-postgres psql -U postgres -d membercenter \ + -c 'SELECT "ClientId", "DisplayName", "ClientType", "RedirectUris" FROM "OpenIddictApplications";' + +# 完全停止(保留 data) +docker compose -f docker-compose.dev.yml down + +# 完全停止 + 清乾淨 +docker compose -f docker-compose.dev.yml down -v + +# 重 build(程式碼變了) +docker compose -f docker-compose.dev.yml up -d --build visiona-api +``` + +--- + +## 9. 已知 limitation(Phase 0.6 雛形) + +| Limitation | 影響 | 後續處理 | +|-----------|------|---------| +| MC admin API 無法注入 client_secret | 必須走 Web UI 建 OAuth client | MC 端開 issue:admin API 補 ClientSecret 欄位 | +| MC password grant 缺 sub claim → 500 | seed script 跑不通 | MC 端開 issue:TokenController 補 Subject claim mapping | +| MC 只有 `webhook_outbound` 支援 redirect_uris | usage 命名語意不對(雛形借用) | MC 端開 issue:新增 `web_app` usage(對應 oidc-tdd §11.2) | +| host visiona-local 占 3721 → 跟 docker compose 衝突 | 兩個只能擇一跑 | 預期;docs 已說明 | + +--- + +## 10. 相關文件 + +- `.autoflow/04-architecture/oidc-tdd.md`:OIDC 接入 TDD(§11 MC 端、§12 docker-compose、§13 env) +- `.autoflow/04-architecture/adr/adr-010-oidc-bff.md`:BFF 架構決策 +- `visionA-backend/.env.example`:visionA-backend 完整 env 列表 +- `~/member_center/docs/INSTALL.md`:MC installer 詳細用法 diff --git a/docs/SMOKE-TEST.md b/docs/SMOKE-TEST.md new file mode 100644 index 0000000..7ee09d8 --- /dev/null +++ b/docs/SMOKE-TEST.md @@ -0,0 +1,329 @@ +# visionA Phase 0.6 OIDC 手動煙測 Checklist + +> 適用對象:Phase 0.6 OIDC + Member Center 接入完成後,使用者首次驗收。 +> +> 預期時間:~30 分鐘(含 OAuth client 手動設定 ~10 分鐘) +> +> 上位文件:`.autoflow/04-architecture/oidc-tdd.md`、`.autoflow/04-architecture/adr/adr-010-oidc-bff.md`、`docs/DEV-SETUP.md` +> +> 為什麼是手動而不是自動:MC admin API 目前有兩個 bug(password grant 缺 sub claim → 500、admin API 無法回傳 client_secret),導致 OAuth client 註冊無法用 script 自動化。詳見本文「§故障排除 #1」與「§已知 limitation」。 + +--- + +## 用法 + +每個 checkbox 代表一個獨立可驗證的步驟,**逐項打勾**。 +有 ❌ 不通過的步驟 → 看「§故障排除」對應編號 → 修完再 retry → 才往下走。 +全部 ✅ 走完 = Phase 0.6 OIDC 接入驗收通過。 + +--- + +## 前置條件 + +- [ ] Docker Desktop 已開(`docker info` 不噴錯) +- [ ] visionA + member_center 已 git clone 平行擺放(`ls -d ../member_center` 看得到) +- [ ] 已照 `docs/DEV-SETUP.md` §4 跑過 `docker compose -f docker-compose.dev.yml --env-file .env.dev up -d --build` +- [ ] frontend 依賴已裝:`cd visionA-frontend && pnpm install` +- [ ] frontend dev server 已開:`cd visionA-frontend && pnpm dev`,能看到 http://localhost:3000 顯示登入頁 + +--- + +## 階段 1:基礎服務驗證(5 分鐘) + +驗證 5 個 docker service + frontend 都健在。 + +- [ ] **1.1** `docker compose -f docker-compose.dev.yml ps` → postgres、member-center、member-center-web、visiona-proxy、visiona-api 都顯示 `Up (healthy)`;member-center-init 顯示 `Exited (0)` +- [ ] **1.2** `curl -sS http://localhost:5050/.well-known/openid-configuration | head -c 200` → 回 200 + JSON,內含 `"issuer":"http://localhost:5050/"` +- [ ] **1.3** `curl -sS http://localhost:5050/jwks` → 回 200 + JSON,含 `"keys": [...]` +- [ ] **1.4** `curl -sS http://localhost:3721/healthz` → 回 200(visiona-api 健康) +- [ ] **1.5** `curl -sS http://localhost:3800/healthz` → 回 200(visiona-proxy 健康) +- [ ] **1.6** 開 http://localhost:3000 → 看到 visionA login 頁(含「使用您的 Innovedus 帳號登入」按鈕) +- [ ] **1.7** 開 http://localhost:5060 → 看到 Member Center admin 後台登入頁 + +任一步驟失敗 → §故障排除 #1。 + +--- + +## 階段 2:Member Center 設定(10 分鐘) + +> ⚠️ 因 MC admin API 兩個 bug,此階段必須走 Web UI 手動操作。詳見 §故障排除 #1。 + +### 2.1 登入 MC admin + +- [ ] **2.1.1** 開 http://localhost:5060 +- [ ] **2.1.2** 用 `.env.dev` 中的 `MC_ADMIN_EMAIL` / `MC_ADMIN_PASSWORD` 登入(預設 `admin@visiona.local` / `Admin12345!`) +- [ ] **2.1.3** 進到 admin dashboard 不噴錯 + +### 2.2 建 Tenant + +- [ ] **2.2.1** 進「Tenants」頁 +- [ ] **2.2.2** 點「Create」 +- [ ] **2.2.3** 填入: + - Name:`visionA` + - Domains:`visiona.cloud, localhost` + - Status:`active` +- [ ] **2.2.4** 送出 → 列表出現 `visionA` tenant + +### 2.3 建 OAuth Client + +- [ ] **2.3.1** 進「OAuth Clients」頁 +- [ ] **2.3.2** 點「Create」 +- [ ] **2.3.3** 填入: + - Tenant:`visionA` + - Name:`visionA Cloud` + - Client Type:`confidential` + - Usage:`webhook_outbound`(雛形 workaround,見 §已知 limitation #3) + - Redirect URIs:`http://localhost:3721/api/auth/callback` +- [ ] **2.3.4** 送出 +- [ ] **2.3.5** **複製 client_id**(GUID 格式,例 `a1b2c3d4-...`)→ 暫存 +- [ ] **2.3.6** **複製 client_secret**(如 UI 沒給,見 §故障排除 #1.b 的 fallback)→ 暫存 + +### 2.4 建 Demo User + +- [ ] **2.4.1** 進「Users」頁 +- [ ] **2.4.2** 點「Create」 +- [ ] **2.4.3** 填入: + - Email:`demo@visiona.local` + - Password:`Demo12345!` + - Name:`Demo User`(或自填) + - Tenant:選 `visionA` +- [ ] **2.4.4** 送出 → users 列表出現 demo user + +### 2.5 寫回 .env.dev + 切到 OIDC mode + +- [ ] **2.5.1** 編輯 `.env.dev`,填入 2.3.5 / 2.3.6 拿到的值: + ``` + VISIONA_OIDC_CLIENT_ID= + VISIONA_OIDC_CLIENT_SECRET= + VISIONA_AUTH_TYPE=oidc + ``` +- [ ] **2.5.2** 重啟 visiona-api: + ``` + docker compose -f docker-compose.dev.yml --env-file .env.dev up -d visiona-api + ``` +- [ ] **2.5.3** 看 log: + ``` + docker compose -f docker-compose.dev.yml logs visiona-api --tail 20 + ``` + - 看到 `OIDC initialized issuer=http://localhost:5050/ client_id=<你的 guid>` + - 看到 `api-server listening addr=0.0.0.0:3721` + - 沒有 panic / fatal + +任一步驟失敗 → §故障排除 #2。 + +--- + +## 階段 3:完整 Login Flow(5 分鐘) + +驗證 OAuth Authorization Code + PKCE 走完整 redirect chain。 + +- [ ] **3.1** 開瀏覽器(建議無痕視窗,避免舊 cookie 干擾)→ http://localhost:3000/login +- [ ] **3.2** 看到「使用您的 Innovedus 帳號登入」按鈕 +- [ ] **3.3** 開 DevTools → Network tab(保持「Preserve log」勾選,這樣才看得到 redirect chain) +- [ ] **3.4** 點登入按鈕 → 第一個 request 是 `GET /api/auth/login`,response 302 +- [ ] **3.5** 跟隨 302 → 第二個 request 是 `GET http://localhost:5050/oauth/authorize?...`(MC 顯示登入頁) +- [ ] **3.6** 確認 authorize URL 內含: + - `response_type=code` + - `client_id=<你的 guid>` + - `scope=openid email profile` + - `state=` + - `code_challenge=<...>` + `code_challenge_method=S256` + - `nonce=` + - `redirect_uri=http://localhost:3721/api/auth/callback` +- [ ] **3.7** DevTools → Application → Cookies → http://localhost:3721 → 應已有 `visiona_pending_sid`(HttpOnly) +- [ ] **3.8** MC 登入頁輸入 `demo@visiona.local` / `Demo12345!` → 送出 +- [ ] **3.9**(如有同意授權頁)→ 點「同意」 +- [ ] **3.10** 跟隨 302 → `GET http://localhost:3721/api/auth/callback?code=...&state=...` +- [ ] **3.11** 跟隨 302 → 回到 http://localhost:3000(dashboard / workspace 首頁) +- [ ] **3.12** DevTools → Cookies → http://localhost:3721 → `visiona_session` 出現(HttpOnly);`visiona_pending_sid` 已被清除 +- [ ] **3.13** Header 右上角顯示 demo user 的 email 或 name(visionA-frontend `/account` 應拿得到 user info) + +任一步驟失敗 → §故障排除 #3。 + +--- + +## 階段 4:API 帶 Cookie 驗證(3 分鐘) + +驗證 BFF Pattern:frontend 只用 cookie,所有 API 自動帶 session。 + +- [ ] **4.1** DevTools → Network → 重整任意需登入頁面(如 `/devices`) +- [ ] **4.2** 找到 `GET /api/auth/me` request: + - Request Headers 帶 `Cookie: visiona_session=...` + - Response 200,body 含 `user_id`(OIDC sub,**不應是 `demo-user`**)、`email`、`name`、`expires_at` +- [ ] **4.3** 訪 http://localhost:3000/devices → 頁面正常載入(API 拿到 cookie session) +- [ ] **4.4** 訪 http://localhost:3000/models → 頁面正常載入 +- [ ] **4.5** 訪 http://localhost:3000/account → 頁面正常載入並顯示 user 資訊 +- [ ] **4.6** 開新 tab 直接用 curl(替換 cookie 值): + ``` + curl -sS -b "visiona_session=<從 DevTools 複製>" http://localhost:3721/api/auth/me + ``` + → 200 + JSON +- [ ] **4.7** curl 不帶 cookie: + ``` + curl -sSi http://localhost:3721/api/auth/me + ``` + → 401(middleware 擋下) + +任一步驟失敗 → §故障排除 #4。 + +--- + +## 階段 5:Pairing Token 綁 OIDC user(5 分鐘) + +> 驗證關鍵承諾:**OIDC sub → UserContext → Pairing Token 的 user binding 正確**。 +> 這是 ADR-010 + oidc-tdd §9 的核心驗證點:取代 StaticAuth 後,pairing 不會再綁到 `demo-user`。 + +- [ ] **5.1** 訪 http://localhost:3000/devices/pair +- [ ] **5.2** 找到「產生 Pairing Token」按鈕,點下去 +- [ ] **5.3** 拿到 token(格式類似 `vAc_xxxxx`) +- [ ] **5.4** DevTools → Network → 看 `POST /api/pairing/token` request + - Request 帶 `Cookie: visiona_session=...` + - Response 200 + JSON +- [ ] **5.5** 看 backend log 確認 user binding: + ``` + docker compose -f docker-compose.dev.yml logs visiona-api --tail 50 | grep -i pairing + ``` + - 應看到「PairingStore Create userID=」之類 log(user_id 是 GUID 或 MC 給的 sub,**不是** `demo-user`) +- [ ] **5.6**(可選,如有實作)查 storage:訪 `/account` 或對應頁面,確認該 token 列在「我的 pairing tokens」清單下 +- [ ] **5.7**(可選)拿 token 餵給 local-tool agent → agent 連上 → 確認後端 log 看到 agent 連入時 binding 的 user_id 與 5.5 一致 + +任一步驟失敗 → §故障排除 #5。 + +--- + +## 階段 6:登出 + 重登(3 分鐘) + +- [ ] **6.1** Header → User menu → 點「登出」(或訪 `/account` 找登出按鈕) +- [ ] **6.2** DevTools → Network → 看 `POST /api/auth/logout` → 204 No Content +- [ ] **6.3** DevTools → Cookies → `visiona_session` 已被清除(或值改成空 + Max-Age=0) +- [ ] **6.4** 自動 redirect 到 `/login`(或手動訪 `/login` 確認沒登入狀態) +- [ ] **6.5** 訪 http://localhost:3000/devices → 應 redirect 到 `/login`(或顯示「請先登入」),不應看到 devices 列表 +- [ ] **6.6** curl `GET /api/auth/me` 不帶 cookie → 401 +- [ ] **6.7** 重新點登入按鈕 → 走完 §3 整套 flow → 又能進到 dashboard +- [ ] **6.8** 確認重新登入後 `/api/auth/me` 拿到的 `user_id` 跟之前**一樣**(demo user 還是同一個 OIDC sub) + +任一步驟失敗 → §故障排除 #6。 + +--- + +## 階段 7:跨頁 Refresh / 持久化(2 分鐘) + +驗證 cookie 7 天 + 24h idle 的 session 生命週期。 + +- [ ] **7.1** 登入狀態下重整瀏覽器 → 仍登入(cookie 持久 + `/api/auth/me` 還能拿 user) +- [ ] **7.2** 關掉瀏覽器 tab(**不要清 cookie**)→ 開新 tab → 訪 http://localhost:3000 → 仍登入 +- [ ] **7.3** DevTools → Cookies → `visiona_session` 的 Max-Age 約 604800(7 天) + +--- + +## 故障排除 + +### #1 階段 2 卡住:MC admin 登入失敗、tenant/client 建不起來 + +**a. admin 帳號登不進去** +- 確認 `.env.dev` 的 `MC_ADMIN_EMAIL` / `MC_ADMIN_PASSWORD` 與 docker compose 啟動時一致 +- 看 `docker compose logs member-center-init` → 應有「admin user created」訊息 +- 完全重置:`docker compose -f docker-compose.dev.yml down -v` → 重新 `up -d`(會重跑 init) + +**b. UI 沒顯示 client_secret** +- 已知 MC limitation:admin UI 建 confidential client 時可能不回傳 secret +- **暫時 workaround**:把 client type 改成 `public`(`VISIONA_OIDC_CLIENT_SECRET` 留空字串),visionA-backend 應允許 empty secret 通過(雛形可接受) +- 永久解:MC 補 admin API 回傳 secret 的功能 + +**c. 為什麼整個流程不能 script 自動化** +- MC password grant 在 Identity user 上有 bug:`mandatory subject claim was missing` → 500 +- → 拿不到 admin token → 無法呼叫 admin API → 必須走 Web UI +- 等 MC 修這兩個 bug 後,OD2 可實作 seed script,OT2 可改全自動 e2e + +### #2 visiona-api 看不到 "OIDC initialized" log,或 startup 噴錯 + +| log 關鍵字 | 原因 | 解法 | +|------|------|------| +| `discovery fetch failed` | MC 未起 / port 不對 | 確認 `docker compose ps member-center` healthy;`curl localhost:5050/.well-known/openid-configuration` 通 | +| `issuer URL ... did not match` | issuer trailing slash 問題 | `.env.dev` 的 `VISIONA_OIDC_ISSUER_URL` 必須結尾 `/`,例 `http://localhost:5050/` | +| `IssuerURL invalid` / `ClientID required` | env 沒帶到 | docker compose 命令必須加 `--env-file .env.dev` | +| `auth type "oidc" but ClientID is CHANGE_ME` | 還沒填 client_id | 完成階段 2.5.1 | + +### #3 階段 3 卡住:登入 redirect chain 失敗 + +| 症狀 | 原因 | 解法 | +|------|------|------| +| 點登入按鈕沒反應 | frontend `/api/auth/login` 沒打到 backend | 確認 `VISIONA_FRONTEND_URL=http://localhost:3000`、frontend `NEXT_PUBLIC_API_BASE_URL=http://localhost:3721`(或對應 env) | +| 302 到 MC 後 MC 噴 invalid_client | client_id 不對 / tenant 沒 active | 回 MC admin UI 檢查 client 是否屬於 active tenant | +| MC 登入後回 callback 噴 `state_mismatch` | `visiona_pending_sid` cookie 沒帶過去 | 確認 backend `Set-Cookie` 沒設 `Domain` 限制;瀏覽器在 callback 時應該帶 cookie(DevTools → Application → Cookies 看是否真有存進去) | +| callback 噴 `pending_session_not_found` | pending session 過期(10 分鐘)或 session store 重啟丟資料 | 重新點登入按鈕從頭來;雛形 in-memory store 重啟即清空 | +| callback 噴 `id_token_invalid` | id_token 驗簽失敗(issuer / aud / nonce 對不上)| 看 backend log `detail` 欄;常見:JWKS cache 過時 → 重啟 visiona-api | +| callback 噴 `token_exchange_failed` | client_secret 不對 / redirect_uri 不對 | 比對 `.env.dev` 與 MC admin UI 上的 client 設定 | + +### #4 階段 4 卡住:API 401 / cookie 沒帶 + +- DevTools → Application → Cookies → 確認 `visiona_session` 真的存在 + 沒過期 +- DevTools → Network → 看 request 是否真的帶 `Cookie:` header + - 如果沒帶 → frontend `fetch` 沒設 `credentials: 'include'`,看 OF2 是否真的補上 +- backend log `unauthorized` 帶的 detail: + - `cookie_invalid` → cookie 簽章對不上 → 通常 `VISIONA_SESSION_SECRET` 變過導致舊 cookie 失效 → 清 cookie 重登 + - `session_not_found` → in-memory store 被重啟清掉 → 重登 + - `session_expired` → 真的過期了,重登 + +### #5 階段 5:Pairing token user_id 綁錯(綁到 `demo-user` 而非 OIDC sub) + +**這是 OB5 / OF2 必須驗證的關鍵點**: + +- 看 `docker compose logs visiona-api | grep -i 'pairing\|userID\|user_id'` + - 如果 user_id 是 `demo-user` → OB5 沒拔乾淨 / `VISIONA_AUTH_TYPE` 還是 `static` → 確認階段 2.5.1 真的改成 `oidc` + - 如果 user_id 是空 / `unknown` → middleware 沒注入 UserContext → 看 OB3 middleware 程式碼 + - 如果 user_id 是 GUID(OIDC sub)→ ✅ 正確 +- 必要時開 issue:「OB5 + OF2 完成後 pairing 仍綁到 demo-user,需 hotfix」 + +### #6 階段 6:登出後仍能訪問 + +- DevTools 確認 `visiona_session` cookie 真的被清掉 +- 如果 cookie 還在 → backend `Set-Cookie: visiona_session=; Max-Age=0` 沒設對 +- 訪 `/api/auth/me` 不帶 cookie 仍 200 → middleware bypass 了某些路徑 → 看 router 設定 +- frontend 沒 redirect 到 `/login` → middleware(Next.js)沒檢查 session → 看 OF2/OF3 frontend route guard + +--- + +## 已知 Limitation(Phase 0.6 雛形) + +| # | Limitation | 影響 | 後續處理(Phase 1) | +|---|-----------|------|--------------------| +| 1 | MC admin API 不接受 client_secret 輸入 / 不回傳 client_secret | 必須走 MC Web UI 手動建 OAuth client | MC 補 admin API | +| 2 | MC password grant 在 Identity user 缺 sub claim → 500 | 無法用 admin token script 自動化 seed | MC TokenController 補 Subject claim mapping | +| 3 | MC 只支援 `usage=webhook_outbound` 帶 redirect_uris | 命名語意不對(visionA 借用) | MC 新增 `usage=web_app` | +| 4 | visionA-backend in-memory session store 重啟即消失 | 雛形階段使用者重登 OK;不適合多 instance | Phase 1 換 Redis | +| 5 | host 上的 visiona-local(local-tool)佔 3721 與 docker compose 衝突 | 兩個只能擇一跑 | 預期;docs 已說明 | +| 6 | 沒有 RP-initiated logout(登出 visionA 不會把 MC session 也登出)| 同 browser 下次點登入會自動跳過 MC 登入頁 | Phase 1 補 | +| 7 | 沒有 refresh token rotation | session 24h idle / 7d absolute 後必須重登 | 等 MC 開 refresh,Phase 1 接 | + +--- + +## 自動化進度 + +| 階段 | 自動化狀態 | 對應測試 | +|------|----------|---------| +| 階段 1(基礎服務)| ✅ docker-compose healthcheck 自動 | – | +| 階段 2(OAuth client 設定)| ❌ **手動**(MC bug 阻擋)| Phase 1 待 MC 修 bug | +| 階段 3-7(Login flow / API / Pairing / Logout)| ✅ 用 fake OIDC 自動測(OT1)| `internal/api/handlers/oidc_e2e_test.go`(17 packages 全綠 / 6 個 OIDC e2e cases)| +| 階段 3-7(with 真 MC)| ❌ **手動**(依賴階段 2 手動產 client)| Phase 1 TODO | + +**Phase 1 TODO 清單**: + +1. MC team 修 password grant sub claim bug → unblock seed script +2. MC team 補 admin API 接受 / 回傳 client_secret → unblock OAuth client 自動化 +3. MC team 新增 `usage=web_app` → 移除雛形 workaround +4. visionA OD2:寫 `make dev-seed` 把 §2 全部 script 化 +5. visionA OT2 真 MC e2e:把 OT1 fake OIDC 改 testcontainers 起真 MC,跑 §3-§7 全自動 +6. visionA OB?:補 RP-initiated logout +7. visionA OB?:補 refresh token(等 MC 上 refresh) +8. visionA OB?:in-memory session → Redis(Phase 1 上 staging 必做) + +--- + +## 通過條件(Phase 0.6 OIDC 接入驗收) + +- [ ] 階段 1-7 全綠(每個 checkbox 都打勾) +- [ ] 階段 5 確認 pairing user_id 是 OIDC sub 而非 `demo-user`(**最關鍵**) +- [ ] 已知 limitation 都有 Phase 1 TODO 對應 issue / 任務 + +全部達成 → Phase 0.6 OIDC 接入完成 ✅,可進 Phase 1 規劃。 diff --git a/docs/STAGE-DEPLOY.md b/docs/STAGE-DEPLOY.md new file mode 100644 index 0000000..5dffcfe --- /dev/null +++ b/docs/STAGE-DEPLOY.md @@ -0,0 +1,501 @@ +# visionA — Stage 部署手冊 + +> 版本:Phase 0.7(2026-05-01) +> 對應任務:`.autoflow/progress.md` Phase 0.7 任務 B-1 ~ B-7 +> 對應決策:S1-S8(HTTPS 處理位置、container 多 process、image 來源、OIDC public client 等) + +--- + +## 1. 部署架構(一張圖) + +``` +[ Browser ] + │ + │ HTTPS :9527 (Let's Encrypt 證書,公司自動續簽) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 公司 host nginx(host: 192.168.0.130,stage-9527 域名) │ +│ - HTTPS termination │ +│ - reverse_proxy → http://localhost:9527 │ +└─────────────────────────────────────────────────────────┘ + │ + │ HTTP :9527 (內網) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Docker container: visiona (host:9527 → container:80) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ nginx :80 │ │ +│ │ /healthz → 200 OK │ │ +│ │ /api/* → api-server :3721 │ │ +│ │ /storage/* → api-server :3721 │ │ +│ │ /tunnel/connect→ remote-proxy :3800 (WS) │ │ +│ │ /_next/static/ → next :3000 (1y cache) │ │ +│ │ / → next :3000 (Next.js standalone) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ↓ ↓ ↓ │ +│ next:3000 api-server:3721 remote-proxy:3800/3801 │ +│ (node) (Go binary) (Go binary) │ +└─────────────────────────────────────────────────────────┘ + ▲ + │ wss://stage-9527.innovedus.com:9527/tunnel/connect + │ + [ visionA Agent on user PC ] +``` + +OIDC 流程: + +``` +Browser ──→ /api/auth/login (visionA-backend) + ──→ 302 to MC :7850/connect/authorize?... (PKCE) + ──→ 使用者登入 MC + ──→ 302 back to /api/auth/callback?code=... (visionA-backend) + ──→ token exchange + 建 cookie session + ──→ 302 to / (frontend) +``` + +--- + +## 2. 為什麼是這個架構(關鍵設計) + +### 2.1 為什麼 HTTPS 由公司 host nginx 處理 + +公司 stage host 已有 host-level nginx + Let's Encrypt 自動續簽(`stage-9527.innovedus.com` 證書)。 + +我們重複實作 HTTPS 在 container 內反而: +- 無法續簽(container 內沒 LE client、無 ACME challenge 路徑) +- 與公司其他 service(`portainer`、`fanfan-mysql` 等共用 nginx)不一致 + +→ container 內 nginx 純 HTTP listen :80,由公司 host nginx 反代。 + +### 2.2 為什麼 container 多 process(不是 docker-compose 多 service) + +仿 `edge-ai-platform/docker/Dockerfile`: + +- POC 是這個模式,stage 階段「先讓它跑起來」優先於完美架構 +- 公司 host nginx 只反代到 host:9527 一個 port → 容器內必須再有一層 nginx 分流 +- 雛形階段不需要獨立擴容 frontend / api-server / remote-proxy + +→ 一個 container 內 4 process(nginx + node + api-server + remote-proxy),由 `entrypoint.stage.sh` 起。 + +**權衡**:任一 process 死 → container 整個重啟(不像 docker-compose 個別 restart)。 +但這也代表問題會被快速發現(healthcheck 立刻失敗),符合 stage 階段需求。 + +### 2.3 為什麼帶 node runtime(不是純 nginx serve static) + +`visionA-frontend/next.config.ts` 設 `output: "standalone"`(不是 `output: "export"`)。 + +差別: + +| Config | 產出 | 部署 | +|--------|------|------| +| `export` | `out/` 全靜態 HTML | nginx serve 即可(edge-ai-platform 用這個) | +| `standalone` | `.next/standalone/server.js` + `.next/static/` | 需要 node 跑 server.js | + +Frontend 動態 routes(`/devices/[id]`、`/models/[id]`、`/workspace/[deviceId]`)目前是 +「server component → unwrap params → 給 client component」pattern。改成 export 模式需要: +- 加 `generateStaticParams()` 列出所有可能 ID(動態資源不可能列舉) +- 或改用 placeholder `[_]` 路徑 + nginx fallback 重寫 + +→ stage 階段不動 frontend code(對齊產品線原則:「Phase 0 不發明新 UI」), + container 多帶 node runtime 跑 standalone server。Image 大 ~70MB 可接受。 + +未來若改 S3 storage / 更嚴格的安全 audit,可考慮: +- frontend 走 CDN 靜態 export(需要先補 `generateStaticParams` 或重整 routes) +- 或 frontend 獨立 service(k8s deployment) + +### 2.4 為什麼 image push internal registry(不是 SCP tar) + +公司 `192.168.0.130:5000` 已有 `registry:2` 容器,與其他 service 共用 image registry。 +Stage host 的 docker daemon 已配置 insecure-registries 信任。 + +→ 開發機 buildx push 到 internal registry → stage host docker compose pull 拉下來。 + timestamp tag 留在 registry 給 rollback 用。 + +--- + +## 3. 環境前置(一次性) + +### 3.1 開發機(執行 `deploy-stage.sh` 的機器) + +```bash +# 必裝 +docker --version # 任何 ≥ 20 版本 +docker buildx version # 內建(Docker Desktop)或裝 docker-buildx-plugin +pnpm --version # ≥ 9(不是必需,scripts 在 container 內裝) +git --version + +# 首次 push 到 internal registry 前要把它加進 daemon insecure-registries: +# macOS: Docker Desktop → Settings → Docker Engine +# 加進 daemon.json: +# { +# "insecure-registries": ["192.168.0.130:5000"] +# } +# 然後 Restart Docker Desktop +``` + +### 3.2 Stage host (`192.168.0.130`) + +由公司 IT / DevOps 一次性設定(**你不需要做,這裡列出供參考**): + +```bash +# 1. Docker daemon 已開 :2375 給內網 + insecure-registry 加 192.168.0.130:5000 +# 2. 公司 host nginx 已配 server block: +# server { +# listen 9527 ssl; +# server_name stage-9527.innovedus.com; +# ssl_certificate /etc/letsencrypt/live/stage-9527.innovedus.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/stage-9527.innovedus.com/privkey.pem; +# location / { +# proxy_pass http://localhost:9527; +# proxy_http_version 1.1; +# proxy_set_header Host $host; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection "upgrade"; +# proxy_read_timeout 86400s; # tunnel WS 用 +# client_max_body_size 100M; # 模型上傳 +# } +# } +# (注意:listen 9527 ssl 是公司 host nginx; +# proxy_pass http://localhost:9527 才是反代到我們 container 的 host:9527) +# 3. /opt/visiona/ 目錄存在且 docker daemon 可寫 +# 4. /opt/visiona/data/ 存在(local-fs storage 用) +``` + +如果以上有缺,先請 IT 補上。**第一次部署前請與 IT 確認 nginx 反代規則 + tunnel WS timeout 拉到 86400s**(少了這個 visionA Agent 連線 1 小時就會被切)。 + +--- + +## 4. 第一次部署 step-by-step + +### 4.1 開發機:產 .env.stage 並上傳到 stage host + +```bash +cd /Users/jimchen/visionA + +# 從範本複製 +cp .env.stage.example .env.stage + +# 產 secrets +SESSION_SECRET=$(openssl rand -hex 32) +SIGNING_SECRET=$(openssl rand -hex 32) + +# 編輯,把以下兩個值換成上面產的 +nano .env.stage +# VISIONA_SESSION_SECRET= +# VISIONA_STORAGE_SIGNING_SECRET= + +# .env.stage 已在 .gitignore,**不要 commit** +git status # 確認看不到 .env.stage +``` + +### 4.2 上傳 .env.stage + docker-compose.stage.yml 到 stage host + +兩條路徑任選: + +**選項 A — SCP(如果有 SSH 權限)** + +```bash +# 假設 stage host 走 SSH(如有) +ssh stage-user@192.168.0.130 "mkdir -p /opt/visiona/data" +scp .env.stage docker-compose.stage.yml stage-user@192.168.0.130:/opt/visiona/ +``` + +**選項 B — Portainer / 手動** + +如果你只有 Docker daemon 存取(沒 SSH),請公司 IT 幫忙 mkdir + 把這兩個檔放到 `/opt/visiona/`: + +```bash +# 把檔案內容複製到 stage host +cat .env.stage # 印出來給 IT 貼 +cat docker-compose.stage.yml # 印出來給 IT 貼 +``` + +### 4.3 確認 :9527 端口可用 + +```bash +# 從你的開發機,先確認 :9527 已釋放 +DOCKER_HOST=tcp://192.168.0.130:2375 docker ps --filter publish=9527 +# 應該空(edge-ai-platform 已 stop) + +# 如果還有東西占著: +DOCKER_HOST=tcp://192.168.0.130:2375 docker stop +``` + +### 4.4 跑部署 + +```bash +cd /Users/jimchen/visionA +bash scripts/deploy-stage.sh +``` + +預期輸出(每步驟有 `=== ===` 區隔): + +``` +=== 0/5 Pre-flight checks === +[INFO] Project root : /Users/jimchen/visionA +[INFO] Docker remote : tcp://192.168.0.130:2375 +[INFO] Registry : 192.168.0.130:5000 +... + +=== 1/5 Build (buildx, linux/amd64) === +[+] Building 180.5s ... +[+] Pushing manifest for 192.168.0.130:5000/visiona:stage + +=== 2/5 Verify image in registry === +[INFO] registry 確認有 visiona:stage + +=== 3/5 Remote pull image === +=== 4/5 Deploy via docker compose === +=== 5/5 Verify === +[INFO] container healthy ✓ +[INFO] 對外 healthz ✓ (https://stage-9527.innovedus.com:9527/healthz) +``` + +### 4.5 瀏覽器驗證 + +打開 `https://stage-9527.innovedus.com:9527/`: + +- [ ] 看到 visionA 首頁(不是 nginx default 或 404) +- [ ] 點右上「登入」→ 跳到 MC OIDC 登入頁(`stage-9527.innovedus.com:7850`) +- [ ] 登入後 302 回 visionA `/`,狀態列顯示已登入 +- [ ] DevTools → Application → Cookies 看到 `visiona_session`(HttpOnly + Secure) +- [ ] `https://stage-9527.innovedus.com:9527/healthz` 回 `ok` + +--- + +## 5. 日常更新部署 + +```bash +cd /Users/jimchen/visionA +git pull +bash scripts/deploy-stage.sh +``` + +腳本會: +1. buildx build 新 image,push 到 registry(含 timestamp tag 給 rollback) +2. stage host pull `:stage` tag +3. `docker compose up -d` 重啟 container(restart unless-stopped) +4. 等 healthcheck + +平均 wall-clock:約 3-5 分鐘(取決於有沒有改 dependency)。 + +--- + +## 6. Rollback + +### 6.1 找前版 tag + +```bash +# 列 registry 上的 stage tags +curl -s http://192.168.0.130:5000/v2/visiona/tags/list | python3 -m json.tool +# 或 docker remote 端 +DOCKER_HOST=tcp://192.168.0.130:2375 docker images 192.168.0.130:5000/visiona --format "{{.Tag}}\t{{.CreatedSince}}" +``` + +Tag 格式:`stage-YYYYMMDD-HHMMSS-`,例:`stage-20260501-153045-a1b2c3d` + +### 6.2 Rollback + +```bash +bash scripts/deploy-stage.sh --rollback stage-20260501-153045-a1b2c3d +``` + +腳本會: +1. 把 `:stage-xxx` retag 為 `:stage` +2. push 回 registry +3. stage host docker compose pull + up -d + +如果 retag 推回失敗(registry 設 immutable),改成手動: + +```bash +ssh stage-host +cd /opt/visiona +sed -i 's|visiona:stage|visiona:stage-20260501-153045-a1b2c3d|' docker-compose.stage.yml +docker compose -f docker-compose.stage.yml up -d +# 部署成功後改回來 +sed -i 's|visiona:stage-.*|visiona:stage|' docker-compose.stage.yml +``` + +--- + +## 7. 故障排除 + +### 7.1 對外 https://stage-9527.innovedus.com:9527 不通 + +排除順序(由外而內): + +```bash +# 0. 你網路本身能不能解到 stage-9527.innovedus.com? +nslookup stage-9527.innovedus.com + +# 1. 公司 host nginx 有沒有監聽 :9527? +curl -k -v https://stage-9527.innovedus.com:9527/healthz +# 200 ok → 全鏈路 OK +# 502/504 → host nginx 有接到,但反代到 container 失敗(往下查 2) +# 連線拒絕 → host nginx 沒監聽 :9527(請 IT) + +# 2. container 在不在? +DOCKER_HOST=tcp://192.168.0.130:2375 docker ps --filter name=visiona +# 看 STATUS:Up X minutes (healthy) 才正常 + +# 3. container 內部能不能打通? +DOCKER_HOST=tcp://192.168.0.130:2375 docker exec visiona curl -fsS http://127.0.0.1:80/healthz +# 應回 ok + +# 4. host 能打到 container? +DOCKER_HOST=tcp://192.168.0.130:2375 docker exec visiona wget -qO- http://127.0.0.1:80/healthz +# 應回 ok +``` + +如果 step 1 是「連線拒絕」→ 公司 host nginx 沒接到 :9527,請 IT 加 server block(見 §3.2)。 + +### 7.2 OIDC callback 失敗 + +症狀:登入完跳回 `/api/auth/callback` 顯示 error。 + +檢查清單: + +```bash +# 1. callback URL 三邊一致? +# .env.stage: VISIONA_OIDC_REDIRECT_URL +# MC client 設定: redirect_uris +# 瀏覽器網址列實際看到的: /api/auth/callback?code=... +# 三個必須完全一樣(含結尾斜線、含 :9527) + +# 2. issuer URL 帶結尾斜線? +grep VISIONA_OIDC_ISSUER_URL /opt/visiona/.env.stage +# 必須是 https://stage-9527.innovedus.com:7850/ ← 有結尾斜線 + +# 3. client_secret 配對? +# Public PKCE-only client 必須 VISIONA_OIDC_CLIENT_SECRET 留空 +# Confidential client 必須 VISIONA_OIDC_CLIENT_SECRET 有值 +# 兩邊不對就會 invalid_client + +# 4. 看 backend log +DOCKER_HOST=tcp://192.168.0.130:2375 docker logs visiona 2>&1 | grep -i oidc | tail -20 +``` + +### 7.3 Cookie 沒被前端帶上 + +症狀:登入後 reload 又變未登入。 + +```bash +# DevTools → Network → /api/auth/me request → 看有沒有 Cookie: visiona_session=... +# 沒有 → cookie domain / path 設錯 +# stage 應該 VISIONA_SESSION_COOKIE_DOMAIN=(留空,host-only) +# VISIONA_SESSION_COOKIE_SECURE=true(HTTPS only) +``` + +### 7.4 模型上傳卡在中段 + +症狀:100MB 內可以,超過會 413 或 timeout。 + +兩處 limit 必須對齊: + +```bash +# 1. nginx.stage.conf(在 image 內) +grep client_max_body_size docker/nginx.stage.conf +# client_max_body_size 100M; + +# 2. 公司 host nginx +# 請 IT 確認 server block 內也有 client_max_body_size 100M; +# 沒設 = 預設 1M,會 413 +``` + +### 7.5 visionA Agent 連 tunnel 1 小時就斷 + +```bash +# 公司 host nginx 必須有: +# proxy_read_timeout 86400s; +# proxy_send_timeout 86400s; +# /tunnel/connect 同層 location 內也要有 Upgrade / Connection upgrade headers +# 請 IT 加上 +``` + +### 7.6 Container 啟動就立刻 Exit + +```bash +DOCKER_HOST=tcp://192.168.0.130:2375 docker logs visiona + +# 常見原因: +# - Validate() fail:缺 OIDC env var → 看 stderr 列出哪個變數 +# - VISIONA_SESSION_SECRET < 32 chars → backend panic +# - port 衝突:unable to bind :3721 → 看是不是別的 service 占了 +``` + +### 7.7 緊急救回 POC(visionA 部 fail) + +之前 stop 掉沒 rm,可救: + +```bash +DOCKER_HOST=tcp://192.168.0.130:2375 docker stop visiona +DOCKER_HOST=tcp://192.168.0.130:2375 docker start edge-ai-platform +# :9527 流量回到 POC,先讓使用者繼續用 +``` + +--- + +## 8. 已知限制(Phase 0.7) + +1. **公司 host nginx 是黑盒** — 不在我們 git 控管,更動需要請 IT,文件無法 100% 同步真實狀態 +2. **單 container 多 process** — 4 個 process 共命運(任一掛 → 全重啟)。stage 階段可接受,production 應拆 service +3. **LocalFS storage** — 上傳的模型存在 stage host `/opt/visiona/data/`,container 重建不會丟(有 volume),但跨 host 移轉會丟 +4. **OIDC 只接 login client** — service-to-service `client_credentials` 已留 config 鉤子但未啟用 +5. **Health check 只看 nginx,不看 backend** — `/healthz` 是 nginx 直接回 200,不打 backend;backend 死了 nginx 還活著 healthy 仍是 healthy(但 entrypoint `wait -n` 會抓到 backend 死亡 → container 整個 die → docker restart) +6. **Internal registry 沒設 push 認證** — 任何能 reach `192.168.0.130:5000` 的人都能 push 任何 image。stage 內網 OK,prod 必須加 auth +7. **rollback retag** — `deploy-stage.sh --rollback` 會把 timestamp tag retag 成 `:stage` 推回 registry。若 registry 啟用 immutable tag policy 會失敗,需手動改 compose file(§6.2 結尾有教) + +--- + +## 9. 第一次部署 checklist(給使用者) + +執行 `deploy-stage.sh` 之前,以下事項你需要做: + +- [ ] 在開發機 Docker Desktop daemon.json 加 `"insecure-registries": ["192.168.0.130:5000"]` +- [ ] 確認可以 reach `tcp://192.168.0.130:2375`(`docker -H tcp://192.168.0.130:2375 ps` 不拒連) +- [ ] 確認可以 reach `192.168.0.130:5000`(`curl http://192.168.0.130:5000/v2/_catalog` 回 JSON) +- [ ] 跟公司 IT 確認 stage host nginx 已配 `:9527 → localhost:9527` 反代(含 WS upgrade + 86400s timeout + 100M body size) +- [ ] 跟公司 IT 確認 stage host 上 `/opt/visiona/` 目錄存在 +- [ ] 在開發機產 .env.stage(`cp .env.stage.example .env.stage` + 編輯填 secrets) +- [ ] 把 `.env.stage` + `docker-compose.stage.yml` 上傳到 stage host `/opt/visiona/` +- [ ] 確認 :9527 已釋放(`edge-ai-platform` 已 stop) +- [ ] MC 端 OIDC client `b8093fea1a504a5d8f0e04bee9f78f2e` 的 redirect_uri 是 `https://stage-9527.innovedus.com:9527/api/auth/callback` + +跑: + +```bash +bash scripts/deploy-stage.sh +``` + +成功標準: +- 腳本 step 5/5 顯示 `[INFO] container healthy ✓` + `[INFO] 對外 healthz ✓` +- 瀏覽器打 `https://stage-9527.innovedus.com:9527/` 看到 visionA 登入頁 +- OIDC 流程跑得通 + +--- + +## 10. 相關檔案 + +| 檔案 | 角色 | +|------|------| +| `docker/Dockerfile.stage` | 多 stage build:node + go + nginx 三 builder → nginx-alpine runtime | +| `docker/nginx.stage.conf` | container 內 nginx 反代規則 | +| `docker/entrypoint.stage.sh` | container 啟動腳本(4 process + signal handling) | +| `docker-compose.stage.yml` | stage host 上的 compose(單 service from registry) | +| `.env.stage.example` | 環境變數範本(在 git 裡,可 commit) | +| `.env.stage` | 真實 env 檔(**不在 git 裡**,由人手動放上 stage host) | +| `scripts/deploy-stage.sh` | 一鍵部署腳本(buildx + push + remote compose) | + +對照 dev 環境(不在本文件範圍): + +| 檔案 | 角色 | +|------|------| +| `docker-compose.dev.yml` | local 開發(postgres + member-center + visiona-backend + frontend)| +| `.env.dev.example` | dev 環境範本 | +| `Makefile` | local make targets | + +--- + +> 文件責任人:DevOps Agent +> 最後更新:2026-05-01 +> 變更請同步更新 `.autoflow/progress.md` Phase 0.7 任務狀態 diff --git a/scripts/deploy-stage.sh b/scripts/deploy-stage.sh new file mode 100755 index 0000000..b991e23 --- /dev/null +++ b/scripts/deploy-stage.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# +# visionA — Stage Deploy(save/load 模式,仿 edge-ai-platform/scripts/deploy-docker.sh 早期版本) +# +# 為什麼不用 internal registry: +# 公司 192.168.0.130:5000 registry 開了 auth(401 UNAUTHORIZED),且 visionA dev 端沒帳密。 +# 2026-05-01 改走 buildx --load → docker save | gzip → DOCKER_HOST docker load 模式, +# 完全不依賴 registry。Phase 1 拿到 registry 帳密後可改回 push 模式(grep 本檔的 SAVE_LOAD 區塊)。 +# +# 流程: +# 0. 前置檢查:docker buildx、git status、必要工具 +# 1. buildx build linux/amd64 --load 到本機 docker +# 2. docker save | gzip > /tmp/visiona-stage.tar.gz +# 3. DOCKER_HOST=tcp://192.168.0.130:2375 docker load < /tmp/visiona-stage.tar.gz +# 4. DOCKER_HOST=... docker compose up -d +# 5. healthcheck:curl https://stage-9527.innovedus.com:9527/healthz +# +# 用法: +# bash scripts/deploy-stage.sh # build + deploy +# bash scripts/deploy-stage.sh --skip-build # 不重 build,直接 redeploy 已 load 的 :stage +# bash scripts/deploy-stage.sh --rollback # rollback 到指定 timestamp tag(須仍存在 stage docker) +# bash scripts/deploy-stage.sh --no-push # 只 build 不 save/load(local 驗證用) +# bash scripts/deploy-stage.sh --help + +set -euo pipefail + +# ──────────── 路徑 ──────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DOCKERFILE="$PROJECT_ROOT/docker/Dockerfile.stage" +COMPOSE_FILE="$PROJECT_ROOT/docker-compose.stage.yml" + +# ──────────── stage 設定(以使用者 2026-05-01 提供值為準)──────────── +DOCKER_REMOTE="${DOCKER_HOST:-tcp://192.168.0.130:2375}" +IMAGE_NAME="visiona" +IMAGE_TAG="stage" +# 不再用 internal registry — image 透過 docker save/load 直接搬到 stage docker +# 沒有 REGISTRY prefix 時 docker 會把 image 留在本地 daemon,docker-compose 也以此為基準 +IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}" +SAVE_TARBALL="/tmp/${IMAGE_NAME}-${IMAGE_TAG}.tar.gz" +STAGE_HOST_PATH="/opt/visiona" # stage host 上 compose file + .env 放置目錄 +STAGE_DOMAIN="stage-9527.innovedus.com" +STAGE_PORT="9527" +HEALTHZ_URL="https://${STAGE_DOMAIN}:${STAGE_PORT}/healthz" + +# ──────────── colors ──────────── +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${CYAN}=== $* ===${NC}\n"; } +hint() { echo -e "${BLUE}[HINT]${NC} $*"; } + +show_help() { + cat <<'HELP' +visionA — Stage Deploy + +Usage: + bash scripts/deploy-stage.sh [OPTIONS] + +Options: + --skip-build Skip buildx step, redeploy existing :stage tag + --no-push Build only, do not push to internal registry (local validation) + --rollback Rollback to specified timestamp tag (e.g. stage-20260501-153045) + --skip-deploy Build & push only, do not run remote compose + --help Show this help + +Environment: + DOCKER_HOST Remote Docker daemon (default: tcp://192.168.0.130:2375) + +Examples: + bash scripts/deploy-stage.sh + bash scripts/deploy-stage.sh --skip-build + bash scripts/deploy-stage.sh --rollback stage-20260501-153045 + bash scripts/deploy-stage.sh --no-push # local Dockerfile 驗證 + +After deployment, browse: + https://stage-9527.innovedus.com:9527/ +HELP +} + +# ──────────── parse args ──────────── +SKIP_BUILD=false +NO_PUSH=false +SKIP_DEPLOY=false +ROLLBACK_TAG="" + +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=true; shift ;; + --no-push) NO_PUSH=true; shift ;; + --skip-deploy) SKIP_DEPLOY=true; shift ;; + --rollback) ROLLBACK_TAG="$2"; shift 2 ;; + --help|-h) show_help; exit 0 ;; + *) error "Unknown option: $1 (use --help)" ;; + esac +done + +# rollback mode:跳過 build,直接 deploy 指定 tag +if [ -n "$ROLLBACK_TAG" ]; then + SKIP_BUILD=true + NO_PUSH=true + info "Rollback mode: deploying $REGISTRY/$IMAGE_NAME:$ROLLBACK_TAG" +fi + +# ──────────── pre-flight ──────────── +step "0/5 Pre-flight checks" + +command -v docker >/dev/null 2>&1 || error "docker 未安裝" +docker buildx version >/dev/null 2>&1 || error "docker buildx 未安裝(macOS: 內建;Linux: docker-buildx-plugin)" + +if [ "$SKIP_BUILD" = false ]; then + [ -f "$DOCKERFILE" ] || error "找不到 Dockerfile.stage:$DOCKERFILE" +fi +[ -f "$COMPOSE_FILE" ] || error "找不到 docker-compose.stage.yml:$COMPOSE_FILE" + +# git 檢查(warn only,stage 部署允許 dirty) +if [ "$SKIP_BUILD" = false ] && [ -z "${ROLLBACK_TAG}" ]; then + cd "$PROJECT_ROOT" + if ! git diff-index --quiet HEAD -- 2>/dev/null; then + warn "git working tree 有未 commit 的變更,stage build 仍會繼續" + warn "建議先 commit,否則 image 與 git SHA 對不上難以追蹤" + if [ -t 0 ]; then + read -r -p "繼續嗎? [y/N] " yn + [[ "$yn" =~ ^[Yy]$ ]] || error "取消(請先 commit 或加 --skip-build)" + else + warn "non-interactive shell — 自動繼續" + fi + fi +fi + +GIT_SHA="$(cd "$PROJECT_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +TIMESTAMPED_TAG="${IMAGE_TAG}-${TIMESTAMP}-${GIT_SHA}" + +info "Project root : $PROJECT_ROOT" +info "Docker remote : $DOCKER_REMOTE" +info "Image : $IMAGE_REF" +[ -n "$ROLLBACK_TAG" ] && info "Rollback tag : ${IMAGE_NAME}:${ROLLBACK_TAG}" +[ -z "$ROLLBACK_TAG" ] && info "Timestamp tag : ${IMAGE_NAME}:${TIMESTAMPED_TAG}" +info "Git SHA : $GIT_SHA" + +# ──────────── SAVE_LOAD 區塊:build → save → load ──────────── +# 為什麼這樣做:公司 internal registry 開了 auth,dev 端無帳密 → 改走 docker save/load +# Phase 1 若拿到 registry 帳密,可改回 buildx --push + remote docker pull(grep "SAVE_LOAD") +if [ "$SKIP_BUILD" = false ]; then + step "1/5 Build (buildx, linux/amd64, --load 進本機 daemon)" + + BUILDX_FLAGS=( + --platform linux/amd64 + -f "$DOCKERFILE" + -t "${IMAGE_NAME}:${IMAGE_TAG}" + -t "${IMAGE_NAME}:${TIMESTAMPED_TAG}" + --load + ) + + cd "$PROJECT_ROOT" + docker buildx build "${BUILDX_FLAGS[@]}" . + info "build 完成:本機已有 ${IMAGE_NAME}:${IMAGE_TAG} 與 ${IMAGE_NAME}:${TIMESTAMPED_TAG}" + + if [ "$NO_PUSH" = false ]; then + step "2/5 Save image → 傳到 stage docker(save | load 模式)" + + info "docker save | gzip → ${SAVE_TARBALL}(含 :${IMAGE_TAG} + :${TIMESTAMPED_TAG} 兩個 tag)" + # 同時 save 兩個 tag — load 後 stage docker 會兩個都有,方便 rollback + docker save "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:${TIMESTAMPED_TAG}" \ + | gzip > "$SAVE_TARBALL" + SIZE=$(du -h "$SAVE_TARBALL" | awk '{print $1}') + info "tarball 產出:$SIZE @ $SAVE_TARBALL" + + info "DOCKER_HOST=$DOCKER_REMOTE docker load(透過 TCP 傳輸到 stage daemon)..." + gunzip -c "$SAVE_TARBALL" | DOCKER_HOST="$DOCKER_REMOTE" docker load + info "stage docker daemon 已 load 完成" + + # 清掉 tarball(內含完整 image 內容)— 不該留在 dev 機 /tmp + rm -f "$SAVE_TARBALL" + info "清除本機 tarball:$SAVE_TARBALL" + else + info "skip step 2/5(--no-push)" + fi +else + info "skip 1/5, 2/5(--skip-build)" +fi + +# ──────────── deploy ──────────── +if [ "$SKIP_DEPLOY" = true ] || [ "$NO_PUSH" = true ]; then + info "skip 3/5, 4/5, 5/5(--skip-deploy 或 --no-push)" + info "下一步:在 stage host 上手動執行" + hint " ssh stage-host" + hint " cd $STAGE_HOST_PATH" + hint " docker compose -f docker-compose.stage.yml up -d" + exit 0 +fi + +step "3/5 Tag rollback target(rollback mode only)" + +# rollback mode:把 stage docker 上既存的 timestamp tag 改名成 :stage(save/load 模式不需要 push) +if [ -n "$ROLLBACK_TAG" ]; then + info "rollback:把 stage docker 上的 ${IMAGE_NAME}:${ROLLBACK_TAG} retag 為 ${IMAGE_NAME}:${IMAGE_TAG}" + + if ! DOCKER_HOST="$DOCKER_REMOTE" docker image inspect "${IMAGE_NAME}:${ROLLBACK_TAG}" >/dev/null 2>&1; then + error "rollback tag 不存在於 stage docker:${IMAGE_NAME}:${ROLLBACK_TAG} + 可用 tag list 查看:DOCKER_HOST=$DOCKER_REMOTE docker images $IMAGE_NAME" + fi + + DOCKER_HOST="$DOCKER_REMOTE" docker tag \ + "${IMAGE_NAME}:${ROLLBACK_TAG}" \ + "${IMAGE_NAME}:${IMAGE_TAG}" + info "retag 完成" +else + info "skip 3/5(非 rollback mode)" +fi + +step "4/5 Deploy via docker compose" + +# 假設 stage host 上 /opt/visiona/ 已有 docker-compose.stage.yml + .env.stage +# (首次部署需手動 scp 上去;見 docs/STAGE-DEPLOY.md) +DOCKER_HOST="$DOCKER_REMOTE" docker compose \ + -f "$COMPOSE_FILE" \ + --project-directory "$PROJECT_ROOT" \ + -p visiona-stage \ + up -d --remove-orphans + +info "container 已啟動,等 healthcheck(最多 60s)..." + +# ──────────── verify ──────────── +step "5/5 Verify" + +# 5a. container 狀態 +DOCKER_HOST="$DOCKER_REMOTE" docker ps --filter name=visiona --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +# 5b. 等 healthcheck 變 healthy(最多 60 秒) +deadline=$(( $(date +%s) + 60 )) +healthy=false +while [ "$(date +%s)" -lt "$deadline" ]; do + status=$(DOCKER_HOST="$DOCKER_REMOTE" docker inspect --format='{{.State.Health.Status}}' visiona 2>/dev/null || echo "unknown") + if [ "$status" = "healthy" ]; then + healthy=true + break + fi + sleep 3 +done + +if [ "$healthy" = true ]; then + info "container healthy ✓" +else + warn "container healthcheck 超時(status=$status)" + warn "查看 log: DOCKER_HOST=$DOCKER_REMOTE docker logs visiona --tail 100" +fi + +# 5c. 對外 URL probe(公司 host nginx → container) +if curl -fsS --max-time 10 "$HEALTHZ_URL" >/dev/null 2>&1; then + info "對外 healthz ✓ ($HEALTHZ_URL)" +else + warn "對外 healthz 失敗 — 可能公司 host nginx 還沒接好 :9527 → container" + warn "請檢查公司 IT 那邊 stage-9527.innovedus.com 反代設定" +fi + +echo "" +echo -e "${GREEN}=== Deploy 完成 ===${NC}" +echo "" +info "URL : https://${STAGE_DOMAIN}:${STAGE_PORT}/" +info "Logs: DOCKER_HOST=$DOCKER_REMOTE docker logs -f visiona" +echo "" +hint "Rollback hint:" +hint " bash scripts/deploy-stage.sh --rollback ${TIMESTAMPED_TAG:-<舊 tag>}" +hint "" +hint "緊急救回 POC(visionA 部 fail 時):" +hint " DOCKER_HOST=$DOCKER_REMOTE docker stop visiona" +hint " DOCKER_HOST=$DOCKER_REMOTE docker start edge-ai-platform" +echo ""