feat(deploy): visionA Cloud dev / stage docker compose + Caddy/nginx + 部署腳本

新增雲端版部署設定(Phase 0.6 dev + Phase 0.7 stage 分兩套):

dev 環境(docker-compose.dev.yml):
- 5 service all-in-one(postgres + member-center + visionA-backend + frontend + Caddy)
- Caddy 自動 HTTPS for localhost
- .env.dev.example 範本(使用者拷出 .env.dev 後 docker compose up -d)
- Makefile dev-with-mc 9 個 target

stage 環境(docker-compose.stage.yml + docker/Dockerfile.stage):
- multi-stage build(node22 frontend + go1.26 backend × 2 + nginx-alpine runtime)
  最終 image 319 MB,含 nginx + nodejs + tini + bash
- entrypoint.stage.sh 4 process 共命運(nginx + api-server + remote-proxy +
  next.js standalone)用 wait -n + SIGTERM trap
- nginx.stage.conf:白名單 server_name stage-9527.innovedus.com + 444 default_server
  + /healthz 例外(127.0.0.0/8 only)+ /api/ 與 /storage/ 強制 no-store
  + /tunnel/connect WS upgrade + 100M body / 3600s timeout
- 對外 mapping 0.0.0.0:9527:80(公司 host nginx 在外層處理 HTTPS termination
  — Let's Encrypt stage-9527.innovedus.com 自動續簽)
- named volume visiona-data(不用 bind mount,因 stage docker daemon 在 host root
  無 mkdir 權限)

部署腳本(scripts/deploy-stage.sh):
- 仿 edge-ai-platform/scripts/deploy-docker.sh 早期 save/load 模式
- 為什麼不用 internal registry:公司 192.168.0.130:5000 開了 auth、無帳密
- 流程:buildx --load → docker save | gzip → DOCKER_HOST docker load → compose up
- 含 --rollback <tag> / --skip-build / --no-push / --skip-deploy 選項
- timestamp + git SHA tag 留 rollback 餘地

文件(docs/):
- DEV-SETUP.md:dev 環境一鍵起步驟
- SMOKE-TEST.md:手動煙測 checklist(OIDC flow / pairing / tunnel)
- STAGE-DEPLOY.md:stage 完整手冊(架構圖 / 環境前置 / 部署 step / rollback /
  7 種故障排除 / 緊急救回 POC)

.env.stage.example 對齊 backend A1 改造:
- VISIONA_OIDC_CLIENT_SECRET 留空(PKCE-only public client)
- VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET 留空(Phase 1 預留鉤子)
- 所有 secret 用 placeholder(CHANGE_ME_OPENSSL_RAND_HEX_32)

.dockerignore:避免 node_modules / .next / .git 等進 build context

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-01 11:22:44 +08:00
parent 3f0175f1a9
commit eb66a7287a
13 changed files with 2684 additions and 0 deletions

65
.dockerignore Normal file
View File

@ -0,0 +1,65 @@
# visionA — 共用 .dockerignore
#
# 任何 docker / docker buildx build 都會用此檔過濾 build context。
# 目的:縮小傳給 daemon 的 contextvisionA repo 含 local-tool 等子產品總計 GB 級)
#
# 對齊:
# - docker/Dockerfile.stageCOPY 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

78
.env.dev.example Normal file
View File

@ -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 URLOIDC 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

115
.env.stage.example Normal file
View File

@ -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.goA1 後 ClientSecret 變選填、預留 ServiceClient*
# - .autoflow/04-architecture/oidc-tdd.md §13.1
# - .autoflow/progress.md Phase 0.7 → S6OIDC 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 clientpublic PKCE-only — 無 secret
VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
# 留空 → backend 走 PKCE-only modeA1 後支援;見 ADR-013
VISIONA_OIDC_CLIENT_SECRET=
# Service-to-service clientclient_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 sessionOIDC 登入後在 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=
# CookieSecurestage 走 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 backendproxy-client = 透過 internal HTTP 查 remote-proxy
# remote-proxy 端的 SessionStore backendinmemory = 自己持有 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 — 雛形 LocalFShost 的 /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 trailstage 部署不需設定 VISIONA_STATIC_USER_ID。

273
docker-compose.dev.yml Normal file
View File

@ -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 healthydocker compose ps
# 5. 首次啟動完成後,依 docs/DEV-SETUP.md 「OAuth Client 註冊」一節,
# 手動建立 visionA OAuth clientMC 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 SSOOIDC 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 issuervisionA-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 即視為 aliveHTTP 200 驗證留給 caller 端做)
test:
- "CMD"
- "bash"
- "-c"
- "</dev/tcp/127.0.0.1/5050"
interval: 10s
timeout: 5s
retries: 30 # MC 冷啟動 + EF migrate 可能要 60s+
start_period: 30s
networks:
- visiona-dev-net
# ──────────────────────────────────────────────────────────
# Member Center Web — Admin UIRazor MVC
# 用來手動建 tenant + OAuth clientAPI 端目前無法注入 client_secret必須走 UI
# ──────────────────────────────────────────────────────────
member-center-web:
build:
context: ${MEMBER_CENTER_PATH:-../member_center}
dockerfile: src/MemberCenter.Web/Dockerfile
image: visiona/member-center-web:dev
container_name: visiona-dev-member-center-web
restart: unless-stopped
environment:
ConnectionStrings__Default: "Host=postgres;Port=5432;Database=membercenter;Username=postgres;Password=postgres"
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5060"
Auth__Issuer: "http://localhost:5050/"
Auth__AllowInsecureHttp: "true"
ports:
- "${MEMBER_CENTER_WEB_PORT:-5060}:5060"
depends_on:
postgres:
condition: service_healthy
member-center-init:
condition: service_completed_successfully
networks:
- visiona-dev-net
# ──────────────────────────────────────────────────────────
# Member Center init — 一次性 job
# 跑 installer initmigrate schema + 建 admin 帳號 + roles
# ──────────────────────────────────────────────────────────
member-center-init:
build:
context: ${MEMBER_CENTER_PATH:-../member_center}
dockerfile: src/MemberCenter.Installer/Dockerfile
image: visiona/member-center-installer:dev
container_name: visiona-dev-member-center-init
restart: "no" # 一次性
environment:
ConnectionStrings__Default: "Host=postgres;Port=5432;Database=membercenter;Username=postgres;Password=postgres"
ASPNETCORE_ENVIRONMENT: Development
command:
- init
- --no-prompt
- --verbose
- --admin-email
- ${MC_ADMIN_EMAIL:-admin@visiona.local}
- --admin-password
- ${MC_ADMIN_PASSWORD:-Admin12345!}
depends_on:
postgres:
condition: service_healthy
networks:
- visiona-dev-net
# ──────────────────────────────────────────────────────────
# visionA-backend / api-server
# ──────────────────────────────────────────────────────────
visiona-api:
build:
context: ./visionA-backend
dockerfile: docker/Dockerfile.api-server
image: visiona/api-server:dev
container_name: visiona-dev-api
restart: unless-stopped
environment:
VISIONA_HOST: "0.0.0.0"
VISIONA_API_PORT: "3721"
VISIONA_LOG_LEVEL: "${VISIONA_LOG_LEVEL:-info}"
# ──── Auth / OIDCPhase 0.6 起強制走 OIDC────
# OB52026-04-26已從 config 移除 VISIONA_AUTH_TYPE認證一律走 OIDC。
# backend 啟動時若以下 4 個 OIDC env 任何一個是 CHANGE_ME 會直接 fail。
# 第一次 `make dev-up` 後請依 DEV-SETUP.md §5 手動到 MC 註冊 OAuth client
# 把回傳的 client_id / client_secret 寫進 .env.dev 再 `make dev-rebuild visiona-api`。
# VISIONA_STATIC_USER_ID 仍保留作為 pairing 預設 userOIDC 未綁定時的 fallback
VISIONA_STATIC_USER_ID: "${VISIONA_STATIC_USER_ID:-demo-user}"
# 從 backend container 內看 MC 的 issuer / endpoints。
# 注意issuer 必須和 browser 看到的一致http://localhost:5050
# 因此 container 內 /etc/hosts 把 localhost → host gateway
# 這樣 backend container 內 fetch http://localhost:5050 → 走 host port → MC。
# ⚠️ 必須包含 trailing slash — MC discovery 回的 issuer 帶 slash不一致會導致 client 拒絕 init
VISIONA_OIDC_ISSUER_URL: "${VISIONA_OIDC_ISSUER_URL:-http://localhost:5050/}"
VISIONA_OIDC_CLIENT_ID: "${VISIONA_OIDC_CLIENT_ID:-CHANGE_ME}"
VISIONA_OIDC_CLIENT_SECRET: "${VISIONA_OIDC_CLIENT_SECRET:-CHANGE_ME}"
VISIONA_OIDC_REDIRECT_URL: "${VISIONA_OIDC_REDIRECT_URL:-http://localhost:3721/api/auth/callback}"
VISIONA_OIDC_SCOPES: "openid email profile"
VISIONA_FRONTEND_URL: "${VISIONA_FRONTEND_URL:-http://localhost:3000}"
VISIONA_SESSION_SECRET: "${VISIONA_SESSION_SECRET:-please-change-me-32-bytes-random-dev-secret}"
# ──── 既有設定 ────
VISIONA_PROXY_INTERNAL_URL: "http://visiona-proxy:3801"
VISIONA_CORS_ALLOWED_ORIGINS: "${VISIONA_CORS_ALLOWED_ORIGINS:-http://localhost:3000}"
VISIONA_SEED_DEMO_DATA: "${VISIONA_SEED_DEMO_DATA:-true}"
VISIONA_STORAGE_BACKEND: "localfs"
VISIONA_STORAGE_LOCALFS_ROOT: "/app/data/storage"
VISIONA_STORAGE_LOCALFS_BASE_URL: "${VISIONA_STORAGE_BASE_URL:-http://localhost:3721/storage}"
VISIONA_STORAGE_BASE_URL: "${VISIONA_STORAGE_BASE_URL:-http://localhost:3721/storage}"
VISIONA_STORAGE_SIGNING_SECRET: "${VISIONA_STORAGE_SIGNING_SECRET:-dev-signing-secret-change-me}"
VISIONA_MODEL_MAX_SIZE_MB: "100"
ports:
- "${VISIONA_API_PORT:-3721}:3721"
extra_hosts:
# 讓 container 內可以用 http://localhost:5050 連到 host 的 MC port
# 確保 OIDC issuer 在 backend ↔ browser 兩邊看起來一致。
- "localhost:host-gateway"
volumes:
- api-storage:/app/data/storage
depends_on:
visiona-proxy:
condition: service_healthy
member-center:
condition: service_healthy
member-center-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3721/healthz"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
networks:
- visiona-dev-net
# ──────────────────────────────────────────────────────────
# visionA-backend / remote-proxy
# ──────────────────────────────────────────────────────────
visiona-proxy:
build:
context: ./visionA-backend
dockerfile: docker/Dockerfile.remote-proxy
image: visiona/remote-proxy:dev
container_name: visiona-dev-proxy
restart: unless-stopped
environment:
VISIONA_HOST: "0.0.0.0"
VISIONA_TUNNEL_PORT: "3800"
VISIONA_PROXY_INTERNAL_PORT: "3801"
VISIONA_LOG_LEVEL: "${VISIONA_LOG_LEVEL:-info}"
VISIONA_PAIRING_TOKEN: "${VISIONA_PAIRING_TOKEN:-}"
ports:
- "${VISIONA_TUNNEL_PORT:-3800}:3800"
# internal 3801 預設不對外
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3800/healthz"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
networks:
- visiona-dev-net
volumes:
pgdata:
name: visiona-dev-pgdata
api-storage:
name: visiona-dev-api-storage
networks:
visiona-dev-net:
name: visiona-dev-net
driver: bridge

56
docker-compose.stage.yml Normal file
View File

@ -0,0 +1,56 @@
# visionA — stage 環境 docker-compose
#
# 使用情境:
# - 此檔放在 stage host/opt/visiona/docker-compose.stage.yml或由 deploy-stage.sh 從 dev 機透過 DOCKER_HOST 直接執行,--project-directory 指向 dev repo root 也可)
# - .env.stage 同目錄(不進 git由人工放上去
# - image 由 deploy-stage.sh 透過 docker save | DOCKER_HOST docker load 直接打進 stage docker daemon
# (沒有 registry pull — 公司 internal registry 開了 auth 暫不採用;見 deploy-stage.sh "SAVE_LOAD" 區塊)
#
# 部署流程:
# 1. 開發機跑 scripts/deploy-stage.sh
# → buildx --load 進本機 daemon
# → docker save | gzip | DOCKER_HOST=tcp://... docker load
# 2. docker compose -f docker-compose.stage.yml up -d透過 DOCKER_HOST 在 stage 上執行)
#
# Container 對外只開 :9527 → 容器內 nginx :80。
# 公司 host nginx 在外層 termination HTTPSstage-9527.innovedus.com:9527 LE 證書)。
services:
visiona:
# local imagesave/load 模式)— 不帶 registry prefixdocker compose 不會試著去 pull
image: visiona:stage
pull_policy: never
container_name: visiona
restart: unless-stopped
# 公司 host nginx 期望 upstream 是 host:9527 → 這裡 host port 必須是 9527
ports:
- "0.0.0.0:9527:80"
env_file:
- .env.stage
# local-fs 雛形儲存:用 named volume不用 bind mount
# 為什麼不用 bind mount /opt/visiona/data
# - compose 從 dev 機透過 DOCKER_HOST 執行,相對路徑會解成 dev 機路徑
# - stage docker daemon 在 host root 沒 mkdir 權限(試過 bind /opt/visiona 失敗)
# named volume 由 docker 自己管,存在 stage host 的 docker volume 區(通常 /var/lib/docker/volumes/
# 升級到 S3 backend 後可拿掉
volumes:
- visiona-data:/data
# 直接讓 container healthcheck 接管Dockerfile 已定義 /healthz 探測)
# 這裡不重複 healthcheck但補 logging driver 限制磁碟用量
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# 注意:不掛 /etc/localtime公司 stage host 與 container 都用 UTC
# 如需指定時區,改用 env TZ=Asia/TaipeiDockerfile 已裝 tzdata
# top-level named volume — 對應 service 的 visiona-data 引用
volumes:
visiona-data:
driver: local

148
docker/Dockerfile.stage Normal file
View File

@ -0,0 +1,148 @@
# syntax=docker/dockerfile:1.6
#
# visionA — stage 部署映像(單 container 內含 nginx + node + 兩個 Go binary
#
# 設計原則:
# - 仿 edge-ai-platform/docker/Dockerfile 的「nginx as front + 多 process 後端」模式
# - 但 visionA-frontend 採 Next.js `output: "standalone"`(不是 static export
# 因此最終 image 必須帶 node runtime 跑 `node server.js`,不能像 edge-ai 那樣
# 只 COPY out/ 給 nginx serve。權衡見 docs/STAGE-DEPLOY.md §「為什麼帶 node」。
# - Multi-stage buildbuilder 帶完整 toolchainruntime 只留 nginx + node-slim + 兩個 Go static binary
# - 最終 image 預估 ~150 MBnginx-alpine ~50MB + node:lts-alpine ~120MB - overlap
#
# Container 內 process由 entrypoint.stage.sh 啟動):
# - nginx :80 公司 host nginx 反代到此 port
# - node server.js :3000 Next.js standalone server內部
# - api-server :3721 REST + storage
# - remote-proxy :3800/3801 tunnel WS + internal HTTP
#
# Build在 visionA repo 根目錄):
# docker buildx build --platform linux/amd64 \
# -f docker/Dockerfile.stage \
# -t 192.168.0.130:5000/visiona:stage \
# --push .
#
# 本機驗證(不 push
# docker buildx build --platform linux/amd64 \
# -f docker/Dockerfile.stage \
# -t test/visiona:stage --load .
# ============================================================
# Stage 1 — frontend builder
# ============================================================
# 用 Next.js 16 標準的 standalone build產出 .next/standalone/ 與 .next/static/。
FROM node:22-alpine AS frontend-builder
# 啟用 corepack 以使用 repo 鎖定的 pnpm 版本。
# pnpm 9+ 對 lockfile v9 有要求;--frozen-lockfile 確保 CI 與本機一致。
RUN corepack enable
WORKDIR /src
# 先 COPY lockfile 讓依賴 layer 可以被 cache。
COPY visionA-frontend/package.json visionA-frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 複製其餘原始碼後 build。
COPY visionA-frontend/ ./
# Next.js 16 偵測到 output: "standalone" 後會輸出:
# .next/standalone/ → 含 server.js、必要 node_modules
# .next/static/ → 靜態 chunk要單獨 COPY 進 standalone dir
# public/ → 公開靜態資源(要單獨 COPY
RUN pnpm build
# ============================================================
# Stage 2 — backend builderapi-server + remote-proxy
# ============================================================
# CGO_ENABLED=0 → 純 Go static binaryalpine runtime 可直接執行。
FROM golang:1.26-alpine AS backend-builder
RUN apk add --no-cache git ca-certificates
WORKDIR /src
# 先 COPY go.mod / go.sum讓依賴 layer 可被 cache。
COPY visionA-backend/go.mod visionA-backend/go.sum ./
RUN go mod download
COPY visionA-backend/ ./
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN go build -trimpath -ldflags="-s -w" -o /out/api-server ./cmd/api-server && \
go build -trimpath -ldflags="-s -w" -o /out/remote-proxy ./cmd/remote-proxy
# ============================================================
# Stage 3 — runtimenginx + node + 兩個 binary
# ============================================================
# 用 nginx:alpine 為基底,再裝 node + tiniPID 1 reaper
# tini 確保子 process 殘留 / signal forwarding 正確。
FROM nginx:alpine AS runtime
# nodejs — Next.js standalone server runtimealpine 主 repo 當前版本Next.js 16 支援 18-24
# tini — 正確 PID 1處理 signal + zombie reap多 process container 必備)
# bash — entrypoint 用 wait -nalpine 預設 sh 不支援
# curl — healthcheck 用
# ca-certificates / tzdata — 標準 hardening
#
# 注意:不固定 nodejs 版本alpine repo 會隨時間升),如需固定改用 node:lts COPY 過來
RUN apk add --no-cache \
nodejs \
npm \
tini \
bash \
curl \
ca-certificates \
tzdata
# 建立非 root usernginx 子 process 與 node 都用這個)
# nginx 主 process 必須是 root要 bind :80→ master 由 root 跑、worker fall back nginx user
RUN addgroup -S -g 1001 visiona && \
adduser -S -u 1001 -G visiona visiona
# ──────────── nginx 設定 ────────────
# 把預設站台清掉,換成 visionA 的 server block
RUN rm -f /etc/nginx/conf.d/default.conf
COPY docker/nginx.stage.conf /etc/nginx/conf.d/default.conf
# ──────────── frontendNext.js standalone ────────────
# Next.js 16 standalone 結構:
# /var/www/visiona/standalone/server.js
# /var/www/visiona/standalone/node_modules/ ← 必需
# /var/www/visiona/standalone/.next/ ← server 內部需要
# /var/www/visiona/static/ ← 對齊 server.js 預期:./。next/static
# /var/www/visiona/public/ ← 公開資源
#
# server.js 預設讀 ./.next/static 與 ./public 相對於自己的位置,
# 所以 standalone/ 下要再 mkdir .next/static 與 ./public 軟連結(或直接 COPY 進 standalone 內)。
WORKDIR /var/www/visiona
COPY --from=frontend-builder --chown=visiona:visiona /src/.next/standalone/ ./standalone/
COPY --from=frontend-builder --chown=visiona:visiona /src/.next/static/ ./standalone/.next/static/
COPY --from=frontend-builder --chown=visiona:visiona /src/public/ ./standalone/public/
# ──────────── backend binaries ────────────
COPY --from=backend-builder --chown=visiona:visiona /out/api-server /usr/local/bin/api-server
COPY --from=backend-builder --chown=visiona:visiona /out/remote-proxy /usr/local/bin/remote-proxy
RUN chmod +x /usr/local/bin/api-server /usr/local/bin/remote-proxy
# ──────────── 共享資料目錄local-fs storage 雛形用) ────────────
# api-server 預設 VISIONA_STORAGE_LOCALFS_ROOT=/data/storage
# 由 docker-compose volume 掛 host 路徑進來image 預建 dir + chown 確保權限
RUN mkdir -p /data/storage && chown -R visiona:visiona /data
# ──────────── entrypoint ────────────
COPY docker/entrypoint.stage.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# nginx 對外只 listen :80由公司 host nginx 反代 :9527 → container :80。
EXPOSE 80
# Healthcheck — 打 nginx 的 /healthz由 nginx.stage.conf 直接回 200不打到 backend
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://127.0.0.1:80/healthz || exit 1
# tini 當 PID 1把 signal + 子 process 收屍正確化
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]

115
docker/entrypoint.stage.sh Executable file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# visionA — stage container 內的多 process 啟動腳本
#
# 策略:
# 1. 啟動四個 process 為 background
# - api-server :3721
# - remote-proxy :3800/3801
# - node server.js :3000 (Next.js standalone)
# - nginx :80 (reverse proxy)
# 2. `wait -n` 阻塞,任一 process 結束 → 整個 container 退出(讓 Docker 重啟)
# 3. trap SIGTERM / SIGINT → 優雅關閉所有子 process
#
# 為什麼不用 supervisord
# - supervisord 預設會嘗試重啟死掉的子 process會掩蓋真正的錯誤healthcheck 看起來活著)
# - 在 stage 階段,「任一 process 死 → container die → docker restart unless-stopped」更乾淨
# - 由 Docker 層處理重啟,比 supervisord 內層重啟更容易看 log 與診斷
#
# 為什麼用 bashalpine 預設 sh 不支援 `wait -n`bash 4.3+ feature
# Dockerfile.stage 已 apk add bash。
set -euo pipefail
# ──────────── 環境變數預設值 ────────────
# 這些都可由 .env.stage 覆蓋;這裡只是「即使沒注入也能起來」的 fallback。
: "${VISIONA_API_PORT:=3721}"
: "${VISIONA_TUNNEL_PORT:=3800}"
: "${VISIONA_PROXY_INTERNAL_PORT:=3801}"
: "${VISIONA_PROXY_INTERNAL_URL:=http://127.0.0.1:${VISIONA_PROXY_INTERNAL_PORT}}"
: "${VISIONA_HOST:=0.0.0.0}"
# Next.js standalone server 預設讀 PORT / HOSTNAME
: "${NEXT_PORT:=3000}"
: "${NEXT_HOSTNAME:=127.0.0.1}"
# ──────────── helpers ────────────
log() {
# ISO8601 timestamp 方便 docker logs 排查
printf '[%s] [entrypoint] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"
}
# 子 process pid 暫存
PIDS=()
# 收到 SIGTERM/SIGINT → 廣播給子 process等它們收尾
shutdown() {
log "received signal — shutting down children: ${PIDS[*]:-}"
for pid in "${PIDS[@]:-}"; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/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 HTTPPOC 假設)→ 給它一兩秒
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

262
docker/nginx.stage.conf Normal file
View File

@ -0,0 +1,262 @@
# visionA — stage 環境 nginx 設定
#
# 角色定位:
# 公司 host nginx ── HTTPS :9527LE 證書) ──→ 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 HTTPSstage-9527.innovedus.com:9527
# 但 reverse proxy 把原始 Host header 透傳進來。為避免 backend 拿到偽造 Host
# 來組 absolute URLreturn_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;
# 安全 headersnginx 已有公司 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;
# 注意:不在這層加 HSTSHTTPS termination 在公司 host nginx由那層加
# ============================================================
# 健康檢查 — 不打到 backenddocker 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 已 terminationcontainer 收到的是 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-lived24h 心跳級別);拉到 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;
}
}

118
docker/register-oauth-client.sh Executable file
View File

@ -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: $1brew install $1"; exit 1; }; }
require curl
require jq
# ── 1. 拿 admin tokenMC 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 bugIdentity 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}" <<ENV
# Generated by docker/register-oauth-client.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
VISIONA_OIDC_CLIENT_ID=${CLIENT_ID}
VISIONA_OIDC_CLIENT_SECRET=
VISIONA_OIDC_ISSUER_URL=http://localhost:5050/
VISIONA_OIDC_REDIRECT_URL=${VISIONA_REDIRECT_URI}
ENV
ok "wrote ${OUTPUT_FILE}"
echo
echo "下一步:把 ${OUTPUT_FILE} 內容合併進 .env.dev → docker compose up -d visiona-api"
echo "Demo 帳號:${MC_ADMIN_EMAIL} / ${MC_ADMIN_PASSWORD}"

347
docs/DEV-SETUP.md Normal file
View File

@ -0,0 +1,347 @@
# visionA Dev 環境設置(一鍵 docker compose
> 對應 `.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 LTSpnpm 需要)
```
---
## 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 APIOIDC IdP |
| member-center-web | visiona-dev-member-center-web | **5060** | MC 後台管理 UI |
| member-center-init | visiona-dev-member-center-init | | 一次性 jobDB 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_idenv 是 `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_idGUID 格式)
- **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 dashboardcookie 已設
---
## 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-locallocal-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 執行階段問題
> 這節對應跑完 §5OAuth 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:` headerfrontend `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. 已知 limitationPhase 0.6 雛形)
| Limitation | 影響 | 後續處理 |
|-----------|------|---------|
| MC admin API 無法注入 client_secret | 必須走 Web UI 建 OAuth client | MC 端開 issueadmin API 補 ClientSecret 欄位 |
| MC password grant 缺 sub claim → 500 | seed script 跑不通 | MC 端開 issueTokenController 補 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 詳細用法

329
docs/SMOKE-TEST.md Normal file
View File

@ -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 目前有兩個 bugpassword 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` → 回 200visiona-api 健康)
- [ ] **1.5** `curl -sS http://localhost:3800/healthz` → 回 200visiona-proxy 健康)
- [ ] **1.6** 開 http://localhost:3000 → 看到 visionA login 頁(含「使用您的 Innovedus 帳號登入」按鈕)
- [ ] **1.7** 開 http://localhost:5060 → 看到 Member Center admin 後台登入頁
任一步驟失敗 → §故障排除 #1
---
## 階段 2Member 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=<client_id>
VISIONA_OIDC_CLIENT_SECRET=<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 Flow5 分鐘)
驗證 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=<random>`
- `code_challenge=<...>` + `code_challenge_method=S256`
- `nonce=<random>`
- `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:3000dashboard / workspace 首頁)
- [ ] **3.12** DevTools → Cookies → http://localhost:3721 → `visiona_session` 出現HttpOnly`visiona_pending_sid` 已被清除
- [ ] **3.13** Header 右上角顯示 demo user 的 email 或 namevisionA-frontend `/account` 應拿得到 user info
任一步驟失敗 → §故障排除 #3
---
## 階段 4API 帶 Cookie 驗證3 分鐘)
驗證 BFF Patternfrontend 只用 cookie所有 API 自動帶 session。
- [ ] **4.1** DevTools → Network → 重整任意需登入頁面(如 `/devices`
- [ ] **4.2** 找到 `GET /api/auth/me` request
- Request Headers 帶 `Cookie: visiona_session=...`
- Response 200body 含 `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
```
→ 401middleware 擋下)
任一步驟失敗 → §故障排除 #4
---
## 階段 5Pairing Token 綁 OIDC user5 分鐘)
> 驗證關鍵承諾:**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=<OIDC sub>」之類 loguser_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 約 6048007 天)
---
## 故障排除
### #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 limitationadmin 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 scriptOT2 可改全自動 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 時應該帶 cookieDevTools → 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 階段 5Pairing 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 是 GUIDOIDC 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` → middlewareNext.js沒檢查 session → 看 OF2/OF3 frontend route guard
---
## 已知 LimitationPhase 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-locallocal-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 開 refreshPhase 1 接 |
---
## 自動化進度
| 階段 | 自動化狀態 | 對應測試 |
|------|----------|---------|
| 階段 1基礎服務| ✅ docker-compose healthcheck 自動 | |
| 階段 2OAuth client 設定)| ❌ **手動**MC bug 阻擋)| Phase 1 待 MC 修 bug |
| 階段 3-7Login flow / API / Pairing / Logout| ✅ 用 fake OIDC 自動測OT1| `internal/api/handlers/oidc_e2e_test.go`17 packages 全綠 / 6 個 OIDC e2e cases|
| 階段 3-7with 真 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 → RedisPhase 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 規劃。

501
docs/STAGE-DEPLOY.md Normal file
View File

@ -0,0 +1,501 @@
# visionA — Stage 部署手冊
> 版本Phase 0.72026-05-01
> 對應任務:`.autoflow/progress.md` Phase 0.7 任務 B-1 ~ B-7
> 對應決策S1-S8HTTPS 處理位置、container 多 process、image 來源、OIDC public client 等)
---
## 1. 部署架構(一張圖)
```
[ Browser ]
│ HTTPS :9527 (Let's Encrypt 證書,公司自動續簽)
┌─────────────────────────────────────────────────────────┐
│ 公司 host nginxhost: 192.168.0.130stage-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 processnginx + 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 獨立 servicek8s 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=<paste $SESSION_SECRET>
# VISIONA_STORAGE_SIGNING_SECRET=<paste $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 <container-name>
```
### 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 新 imagepush 到 registry含 timestamp tag 給 rollback
2. stage host pull `:stage` tag
3. `docker compose up -d` 重啟 containerrestart 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-<git_sha>`,例:`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
# 看 STATUSUp 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=trueHTTPS 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 緊急救回 POCvisionA 部 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不打 backendbackend 死了 nginx 還活著 healthy 仍是 healthy但 entrypoint `wait -n` 會抓到 backend 死亡 → container 整個 die → docker restart
6. **Internal registry 沒設 push 認證** — 任何能 reach `192.168.0.130:5000` 的人都能 push 任何 image。stage 內網 OKprod 必須加 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 buildnode + 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 任務狀態

277
scripts/deploy-stage.sh Executable file
View File

@ -0,0 +1,277 @@
#!/usr/bin/env bash
#
# visionA — Stage Deploysave/load 模式,仿 edge-ai-platform/scripts/deploy-docker.sh 早期版本)
#
# 為什麼不用 internal registry
# 公司 192.168.0.130:5000 registry 開了 auth401 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. healthcheckcurl 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 <tag> # rollback 到指定 timestamp tag須仍存在 stage docker
# bash scripts/deploy-stage.sh --no-push # 只 build 不 save/loadlocal 驗證用)
# 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 留在本地 daemondocker-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 <tag> 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 onlystage 部署允許 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 開了 authdev 端無帳密 → 改走 docker save/load
# Phase 1 若拿到 registry 帳密,可改回 buildx --push + remote docker pullgrep "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 dockersave | 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 targetrollback mode only"
# rollback mode把 stage docker 上既存的 timestamp tag 改名成 :stagesave/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 "緊急救回 POCvisionA 部 fail 時):"
hint " DOCKER_HOST=$DOCKER_REMOTE docker stop visiona"
hint " DOCKER_HOST=$DOCKER_REMOTE docker start edge-ai-platform"
echo ""