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:
parent
3f0175f1a9
commit
eb66a7287a
65
.dockerignore
Normal file
65
.dockerignore
Normal file
@ -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
|
||||||
78
.env.dev.example
Normal file
78
.env.dev.example
Normal 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 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
|
||||||
115
.env.stage.example
Normal file
115
.env.stage.example
Normal 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.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。
|
||||||
273
docker-compose.dev.yml
Normal file
273
docker-compose.dev.yml
Normal 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 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/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 UI(Razor MVC)
|
||||||
|
# 用來手動建 tenant + OAuth client(API 端目前無法注入 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 init(migrate 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 / OIDC(Phase 0.6 起強制走 OIDC)────
|
||||||
|
# OB5(2026-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 預設 user(OIDC 未綁定時的 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
56
docker-compose.stage.yml
Normal 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 HTTPS(stage-9527.innovedus.com:9527 LE 證書)。
|
||||||
|
|
||||||
|
services:
|
||||||
|
visiona:
|
||||||
|
# local image(save/load 模式)— 不帶 registry prefix,docker 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/Taipei(Dockerfile 已裝 tzdata)
|
||||||
|
|
||||||
|
# top-level named volume — 對應 service 的 visiona-data 引用
|
||||||
|
volumes:
|
||||||
|
visiona-data:
|
||||||
|
driver: local
|
||||||
148
docker/Dockerfile.stage
Normal file
148
docker/Dockerfile.stage
Normal 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 build:builder 帶完整 toolchain,runtime 只留 nginx + node-slim + 兩個 Go static binary
|
||||||
|
# - 最終 image 預估 ~150 MB(nginx-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 builder(api-server + remote-proxy)
|
||||||
|
# ============================================================
|
||||||
|
# CGO_ENABLED=0 → 純 Go static binary,alpine 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 — runtime(nginx + node + 兩個 binary)
|
||||||
|
# ============================================================
|
||||||
|
# 用 nginx:alpine 為基底,再裝 node + tini(PID 1 reaper)。
|
||||||
|
# tini 確保子 process 殘留 / signal forwarding 正確。
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# nodejs — Next.js standalone server runtime(alpine 主 repo 當前版本,Next.js 16 支援 18-24)
|
||||||
|
# tini — 正確 PID 1,處理 signal + zombie reap(多 process container 必備)
|
||||||
|
# bash — entrypoint 用 wait -n,alpine 預設 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 user(nginx 子 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
|
||||||
|
|
||||||
|
# ──────────── frontend(Next.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
115
docker/entrypoint.stage.sh
Executable 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 與診斷
|
||||||
|
#
|
||||||
|
# 為什麼用 bash:alpine 預設 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 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
|
||||||
262
docker/nginx.stage.conf
Normal file
262
docker/nginx.stage.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
docker/register-oauth-client.sh
Executable file
118
docker/register-oauth-client.sh
Executable 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: $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}" <<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
347
docs/DEV-SETUP.md
Normal 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 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 詳細用法
|
||||||
329
docs/SMOKE-TEST.md
Normal file
329
docs/SMOKE-TEST.md
Normal 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 目前有兩個 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=<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 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=<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: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=<OIDC sub>」之類 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 規劃。
|
||||||
501
docs/STAGE-DEPLOY.md
Normal file
501
docs/STAGE-DEPLOY.md
Normal file
@ -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=<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 新 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-<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
|
||||||
|
# 看 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 任務狀態
|
||||||
277
scripts/deploy-stage.sh
Executable file
277
scripts/deploy-stage.sh
Executable file
@ -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 <tag> # 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 <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 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 ""
|
||||||
Loading…
x
Reference in New Issue
Block a user