Runbook: SIT 環境部署與 CD
SIT (System Integration Test) 跑在獨立的 SIT MacBook Pro(runner 名
linjianguodeMacBook-Pro)上,該機同時是本 repo 唯一的 GitHub Actions self-hosted runner、也是 CI(test.yml) 執行處。正式/dev stack 在另一台 (MacBook-Pro-2),兩者不同機。部署方式:push 到
SITbranch →deploy-sit.yml在 SIT 機本地 build + up (不經 registry、不經 SSH)。SIT stack 用docker-compose.sit.yml完全隔離 (獨立 project name / 容器名 / port / volume / network)。
拓樸一覽
| 機器 | 角色 | 服務 |
|---|---|---|
MacBook-Pro-2(dev/prod) |
開發 + 正式 | cortex-* (8000/8501/5432)、正式 DB |
linjianguodeMacBook-Pro(SIT) |
self-hosted runner + CI + SIT | cortex-sit-*(API 18000;DB 僅內網) |
| SIT 服務 | 容器 | host port |
|---|---|---|
| postgres | cortex-sit-postgres | 未發布(內部網路限定 #567;SIT_DB_PORT 僅供手動 loopback GUI) |
| api | cortex-sit-api | 127.0.0.1:${SIT_API_PORT:-18000}(僅 SIT host) |
| scheduler | cortex-sit-scheduler | — |
port 預設值:CD 走 GitHub Actions Variables(
SIT_API_PORT;SIT_DB_PORT不吃,見 §1 說明——postgres 不對外發布 host port);僅手動操作時可用該機.env.sit覆寫。 請依 SIT 機實際佔用情況設定,避免與該機其他服務衝突。所有 compose 指令一律從 repo root 執行(context: ..才解析得到)。
一、SIT 機一次性前置(在 linjianguodeMacBook-Pro 上做一次)
CD 會自動 checkout + build + up,但兩件事 CD 不會幫你做,需先在 SIT 機手動完成:
1. 設定 secrets / config(GitHub Actions Secrets & Variables)
CD(deploy-sit.yml)從 GitHub Actions Secrets & Variables 注入 env,
不再讀主機上的 .env.sit——SIT 主機硬碟上不會留常駐明文秘密檔,秘密也不綁定
單一機器(換機/搬 VM 較快)。在 repo Settings → Secrets and variables → Actions
建立:
Secrets(加密,必填): JWT_SECRET_KEY、DB_PASSWORD、FINMIND_TOKEN、FRED_API_KEY
Secrets(建議): GEMINI_API_KEY、OPENAI_API_KEY、GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET、
APPLE_CLIENT_ID、APPLE_TEAM_ID、APPLE_KEY_ID、APPLE_PRIVATE_KEY(PEM 內容)
Variables(非秘密): CORS_ALLOW_ORIGINS(如 https://sit-cortexpro.aoshiken.com,管理後台對外網域)、
OAUTH_CALLBACK_URL、FRONTEND_URL、FRONTEND_URL_WEB(對外 Cloudflare 網域)、
SIT_API_PORT(視 SIT 機佔用調整)。
⚠️
SIT_DB_PORT不是 CD Variable:compose 的 postgres 不對外發布 host port(內部 網路限定,#567),故 CD 不吃此值;它只影響「手動」在本機開 loopback DB-GUI 綁定時的 port (見下方 §五 網路暴露、.env.sit)。
JWT_SECRET_KEY用openssl rand -hex 32且【與正式不同】;DB_PASSWORD用openssl rand -hex 24。 ⚠️DB_PASSWORD必須是 alphanumeric/hex(勿含@ : / ? #等特殊字元、勿 URL-encode): compose 以字串插值組DATABASE_URL,特殊字元會破壞連線字串;而 URL-encode 也救不了——postgres 收的是 rawPOSTGRES_PASSWORD、app 端會 decodeDATABASE_URL,兩者不符會靜默認證失敗。openssl rand -hex 24產生的即為安全的純 hex。深層修法(app 端動態組建)追蹤於 #824。- ⚠️ self-hosted runner + secrets:僅信任的
SITbranch push 會跑 CD;不要對 self-hosted runner 開pull_request觸發,否則 fork/PR 可能外洩 secrets。 - 注意:secrets 仍會進到執行中容器的 env(app 需要),這不防主機被完全攻陷—— 它的價值是集中管理/輪換 + 主機不留明文檔。被攻陷的正確反應是「在 GitHub 一次全換、重跑 CD」。
2. 一次性 seed SIT DB(跨機,path A′)
SIT DB volume (sit_postgres_data) 跨 CD 持久,所以 seed 只需做一次。第一次 CD 之前
先把 SIT postgres 起來並灌入資料,否則首次 CD 的 api health check 會等不到(空庫)。
# 先在 SIT 機 clone repo(這份 checkout 與 runner 的 _work 工作目錄不同,互不干擾,
# 純供首次手動 seed 用):
git clone git@github.com:rdaoshiken/Cortex.git && cd Cortex && git checkout SIT
git submodule update --init --recursive
# 手動操作(如首次 seed)時 env 由你自己 export(值就是你在 GitHub 建的 Secrets):
# export JWT_SECRET_KEY=... DB_PASSWORD=... FINMIND_TOKEN=... FRED_API_KEY=...
# export CORS_ALLOW_ORIGINS=https://sit-cortexpro.aoshiken.com SIT_API_PORT=18000
# CD(deploy-sit.yml)則自動從 GitHub Secrets/Variables 注入,不需這步。
# (或把這些值放進 gitignored investment-agent/.env.sit,下面的 sitc() 會自動載入。)
# 定義一個 helper function(比 eval 安全好用):
sitc() {
# Manual convenience: auto-load a gitignored investment-agent/.env.sit if you
# keep one, else rely on the vars exported above. CD never calls this helper.
if [ -f investment-agent/.env.sit ]; then
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml --env-file investment-agent/.env.sit "$@"
else
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml "$@"
fi
}
# 讓 image 內的 non-root runtime user 對齊這台 SIT 主機的使用者,
# 並先建好所有 writable bind-mount 目錄。
export APP_UID="$(id -u)"
export RUNTIME_STATE_PATH="${HOME}/.cortex/sit/runtime-state"
mkdir -p \
investment-agent/logs/sit \
investment-agent/data/sit/quarterly_xbrl \
"$RUNTIME_STATE_PATH"
# RUNTIME_STATE_PATH 故意放在 checkout 外;GitHub Actions checkout clean
# 會刪除 workspace 內被 gitignore 的檔案,但不會刪除長時間 backfill checkpoints。
# (a) 在 SIT 機:先只起 postgres(空庫)
sitc up -d postgres
# (b) 在正式機 MacBook-Pro-2:dump source 資料(排除衍生表,約數百 MB)
investment-agent/scripts/sit_db_seed.sh dump /tmp/cortex_sit_seed.dump
# (c) 傳到 SIT 機
scp /tmp/cortex_sit_seed.dump <user>@linjianguodeMacBook-Pro.local:/tmp/
# (d) 在 SIT 機:restore
investment-agent/scripts/sit_db_seed.sh restore /tmp/cortex_sit_seed.dump
⚠️ 安全:SIT 的
JWT_SECRET_KEY(GitHub Secret)必須與正式不同,否則正式 DB dump 內含的 有效 session JWT 可同時打 SIT 與正式。dump 也會帶入 auth/subscription 個資(皆你本人)。衍生表(snapshots/indicators/messages)restore 後為空,由 SIT scheduler 重算 (每日 15:30 TW)。需要立即有衍生資料時,在
cortex-sit-api內手動跑對應 backfill (見investment-agent/backend/scripts/)。
替代:path B — 乾淨 schema(不跨機搬資料)
不想搬 ~800M 時,在 SIT 機建空庫但 schema 正確:
SB=investment-agent/backend/database/schema_baseline.sql
docker exec -i cortex-sit-postgres psql -U cortex -d cortex_investment < "$SB"
# 再依 schema_baseline 的 -- MIGRATION: manifest 套用比 baseline 新的 migrations
二、CD(push 到 SIT 即自動部署)
- 觸發:merge / push 到
SITbranch →deploy-sit.yml在 SIT 機 self-hosted runner 跑: pre-flight env check → build → API loopback-origin gate → start/health-check DB → migration 106 → schema gate → up -d → health check → image prune。origin、migration 或 schema gate 失敗時 workflow 立即停止,不會用可繞過 Tunnel 或缺表的新 API 取代現行版本。 schema gate 限定publicschema,會核對兩表的完整欄位型別/長度/nullability、必要 PK/unique/check/FK constraints、ON DELETE CASCADE與 expiry/member indexes;同名但殘缺的 漂移表不能通過。 - runner 目標:目前僅一台 runner,
[self-hosted, macOS, X64]不會誤派。日後若新增第二台 runner,請給 SIT runner 加獨有 label(如sit)並在 workflow 釘死。 - CD 不會重建 DB——只重新部署程式碼到既有的 SIT DB(volume 持久)。DB seed 是上面的
一次性手動步驟。CD 只會以
ON_ERROR_STOP=1套用 additive / idempotent 的106_oauth_flow_grants.sql,不修改既有 member、方案或 session 資料。 migration session 設有 5 秒 lock timeout 與 60 秒 statement timeout;若被長 transaction 擋住會 fail closed、保留舊 API,應先查 PostgreSQL activity/locks 後重跑部署,不要繞過 gate。 - 新 stack 若 compose recreate 失敗、API health 不通,或 scheduler 未連續存活三次檢查,workflow 會用 build 前保存的 image ID 自動重建上一版 API/scheduler,並再次確認兩者;第一次部署 沒有上一版 image 時會明確標示無法自動 rollback。診斷 logs 即使讀取失敗也不會跳過 rollback。
- migration 106 新增
oauth_login_states、oauth_exchange_grants,讓 OAuth state 與 exchange grant 可跨 Uvicorn workers 原子消耗。部署或 rollback 會使尚未完成的短期登入 流程失效,使用者只需重新登入;既有 access/refresh token 不受影響。 - rollback 到舊 image 時不要 drop migration 106 的表:舊程式會忽略它們,保留空表是 安全且可再次前滾的做法。
- 與
test.yml(CI) 共用同一台 runner;同時跑只是 build 變慢,不會撞名/撞 port (test 容器為 run-id scoped、不綁 host port)。
查看部署結果:gh run list --workflow deploy-sit.yml。
三、Scheduler
SIT 啟用 cortex-sit-scheduler(每日 15:30 TW 跑 pipeline,重算衍生表)。
⚠️ Rate limit:SIT 與正式 scheduler 雖在不同機,但共用同一組 FinMind/FRED 額度 → 每日呼叫翻倍。若觸發供應商限流,停掉 SIT scheduler:
sitc stop scheduler。
四、常用運維(在 SIT 機)
# 同前面定義的 helper(放進 ~/.zshrc 更方便):
sitc() {
# Manual convenience: auto-load a gitignored investment-agent/.env.sit if you
# keep one, else rely on the vars exported above. CD never calls this helper.
if [ -f investment-agent/.env.sit ]; then
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml --env-file investment-agent/.env.sit "$@"
else
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml "$@"
fi
}
sitc logs -f agent-api # SIT API log
sitc restart agent-api # 重啟單一服務
sitc down # 停 SIT(保留 DB volume)
sitc down -v # 停 SIT 並刪 DB(慎用;清掉 sit_postgres_data)
docker exec -it cortex-sit-postgres psql -U cortex cortex_investment
五、已知限制
- Chroma 向量庫(
knowledge.chroma.persist_directory: data/chroma)在 SIT 為 container-local、每次 up 重建(baked image、無 mount)。日後若要持久化,請用 獨立 host 路徑,不要與任何其他 stack 共用。 data/my-tw-coveragesubmodule 以:ro掛載(只讀輸入),SIT 不會回寫。- SIT logs 落在
investment-agent/logs/sit/、scheduler data 落在investment-agent/data/sit/,與其他環境分開。 SIT為長壽部署分支,由 CD 監看;正式環境日後用 tag-driven CD(不與此共用)。
網路暴露(pre-public hardening #567、#878)
- Postgres 不對外:compose 已移除 postgres 的 host
ports映射;DB 只在內部 network(postgresservice DNS)可達。若要在 SIT 本機用 GUI 連 DB,改綁 loopback:ports: ["127.0.0.1:${SIT_DB_PORT:-15433}:5432"],切勿綁0.0.0.0。 - API origin 僅限 SIT host:compose 將 API 發布為
127.0.0.1:${SIT_API_PORT:-18000}:8000;host 上的 cloudflared origin 必須填實際 port, 例如預設值使用http://127.0.0.1:18000。不要填localhost(可能解析到未綁定的 IPv6::1),也不要填 SIT LAN IP。路由器不得轉發 TCP 18000。 - Streamlit 已移除:無認證、不能帶 OAuth token,不得暴露。前端用 web/mobile OAuth app。
- 對外開放前:deployment env 需設
CORS_ALLOW_ORIGINS=https://<公開域名>,否則瀏覽器前端被 CORS 擋。 - 速率限制:每 IP 限流已開(
config/core.yamlapi.rate_limit);超限回 429。
API origin 封閉驗證
每次修改 compose、Tunnel ingress 或 SIT_API_PORT 後,都要完成三方向驗證;任一結果不符
就保留 Cloudflare Access,不得公開 SIT:
在 SIT MacBook Pro 本機執行(自訂 SIT_API_PORT 時替換 18000):
PORT=18000
curl -fsS "http://127.0.0.1:${PORT}/health"
先在 SIT MacBook Pro 確認實際連到測試 LAN 的 Wi-Fi/Ethernet IP,將該值帶到另一台
LAN 機器。若有啟用 WARP/VPN,不得使用 utun* 或 VPN IP;另以 macOS
「系統設定 → 網路」交叉確認使用中的實體介面與 IP:
networksetup -getinfo "Wi-Fi"
# 使用有線網路時,將 Wi-Fi 換成系統顯示的 Ethernet service 名稱。
在另一台 LAN 機器執行;先替換保留的範例 IP。命令必須印出 PASS,若 TCP 連線成功
就是 origin 仍可繞過:
PORT=18000
SIT_LAN_IP=192.0.2.1
case "$SIT_LAN_IP" in
""|127.*|192.0.2.1)
echo "ERROR: replace SIT_LAN_IP with the value read from the SIT host" >&2
exit 2
;;
esac
command -v nc >/dev/null || {
echo "ERROR: netcat (nc) is required for the TCP reachability probe" >&2
exit 2
}
if nc -z -w 5 "$SIT_LAN_IP" "$PORT"; then
echo "FAIL: SIT API TCP origin is reachable from LAN"
exit 1
else
echo "PASS: SIT API TCP origin is not reachable from LAN"
fi
Cloudflare Access Email policy 尚未移除時,使用已登入 Access 的瀏覽器 DevTools 確認
GET https://<SIT_API_HOSTNAME>/health 回傳 HTTP 200,且 JSON 包含 status: ok 與
database: connected。若 Access 已配置 Service Token policy,也可從外部網路執行:
: "${CF_ACCESS_CLIENT_ID:?set Cloudflare Access service-token client ID}"
: "${CF_ACCESS_CLIENT_SECRET:?set Cloudflare Access service-token secret}"
command -v jq >/dev/null || {
echo "ERROR: jq is required to validate the health JSON" >&2
exit 2
}
HEALTH_BODY="$(mktemp)"
trap 'rm -f "$HEALTH_BODY"' EXIT
if ! HTTP_STATUS="$(curl -sS -o "$HEALTH_BODY" -w '%{http_code}' \
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
"https://<SIT_API_HOSTNAME>/health")"; then
echo "FAIL: Tunnel health request did not complete" >&2
exit 1
fi
if [ "$HTTP_STATUS" != 200 ] ||
! jq -e '.status == "ok" and .database == "connected"' "$HEALTH_BODY" >/dev/null; then
echo "FAIL: Tunnel did not return HTTP 200 with status=ok and database=connected" >&2
exit 1
fi
rm -f "$HEALTH_BODY"
trap - EXIT
echo "PASS: Cloudflare Tunnel reached the healthy SIT API origin"
解除 Email policy 後,立即從外部網路執行獨立的無 credential smoke;302 登入 redirect、 Access HTML、401/403 或非預期 JSON 都算失敗,必須立即恢復 Access policy:
command -v jq >/dev/null || {
echo "ERROR: jq is required to validate the health JSON" >&2
exit 2
}
HEALTH_BODY="$(mktemp)"
trap 'rm -f "$HEALTH_BODY"' EXIT
if ! HTTP_STATUS="$(curl -sS -o "$HEALTH_BODY" -w '%{http_code}' \
"https://<SIT_API_HOSTNAME>/health")"; then
echo "FAIL: restore Access policy; public health request did not complete" >&2
exit 1
fi
if [ "$HTTP_STATUS" != 200 ] ||
! jq -e '.status == "ok" and .database == "connected"' "$HEALTH_BODY" >/dev/null; then
echo "FAIL: restore the Cloudflare Access policy immediately" >&2
exit 1
fi
rm -f "$HEALTH_BODY"
trap - EXIT
echo "PASS: public Tunnel health check reached the SIT API origin"
在 SIT host 另確認 Docker 只列出 loopback binding:
docker compose -p cortex-sit -f investment-agent/docker-compose.sit.yml port agent-api 8000
# 預期:127.0.0.1:18000(或設定的 SIT_API_PORT)
若 Tunnel 驗證失敗,先把 cloudflared origin 改回同一個 loopback port;不要為了恢復服務
把 Compose 改回 wildcard binding。Rollback 到舊 image 不會還原 Compose port 設定,重新
up -d 前仍須確認 resolved config 維持 host_ip: 127.0.0.1。