跳轉到

Runbook: SIT 環境部署與 CD

SIT (System Integration Test) 跑在獨立的 SIT MacBook Pro(runner 名 linjianguodeMacBook-Pro)上。該機是帶有專用 sit label 的持久化 self-hosted runner;Backend PR CI(test.yml)使用獨立的 Linux cortex-ci runner,CI job 只允許同 repository 的 OWNER PR,且不注入 deployment secrets。 正式/dev stack 在另一台(MacBook-Pro-2),兩者不同機。

日常 SIT 發版:push 到 SIT branch → deploy-sit.yml 在 SIT 機本地 build + migrate + up

SIT DB Rebase:只有需要以dev DB重建SIT資料時,才手動執行 rebase-sit-db.yml。它不會由push自動觸發。 (不經 registry、不經 SSH)。SIT stack 用 docker-compose.sit.yml 完全隔離 (獨立 project name / 容器名 / port / volume / network)。

拓樸一覽

SIT 預設停用自動 strategy 計算(SCHEDULER_STRATEGY_ENABLED=false),避免 低記憶體主機在策略計算階段反覆 OOM。停用涵蓋每日/補跑的策略命中與匹配、 夜間籌碼鏈的策略步驟、Story Strategy 發布與重試、forward-return labels、 每週策略發掘;行情、ETF 持股、技術/籌碼快照與其他排程繼續運行。 既有策略結果保留並可查詢,但不再更新,不應用來驗收新日期的策略結果。

此開關由 scheduler 的計算入口共同檢查,也涵蓋啟動恢復;僅移除 cron 不足以 停用策略。API 容器(agent-api)也讀同一個變數:單檔即時更新遇到 OHLC 修訂 時會在 API 程序內觸發 Story Strategy 重算,同樣受此開關控制。CD 的歷史 Story replay+策略發布配對步驟會暫緩,且不寫入完成 watermark。一般事件補齊與每日 資料工作不受此配對步驟暫緩影響。夜間籌碼鏈在策略步驟前的規則目錄(rule catalog)匯入不屬於策略計算, 停用期間照常執行。

需恢復計算時,將 GitHub Actions Variable SCHEDULER_STRATEGY_ENABLED 設為 true 並沿用既有 SIT 部署流程;手動 compose 可匯出同名變數後重建 scheduler 與 agent-api。容器的環境變數要重建容器才會改變,而且策略 job 是在 scheduler 啟動時才決定要不要註冊,所以改值後兩個容器都要重建才生效。 重新啟用前應確認記憶體容量與資料日期。其他環境未提供此變數時維持原有啟用行為。 重新啟用後的第一次部署,若 SES catch-up 的 replay watermark 尚未記錄,且 Story 已有 歷史資料,CD 會在部署 job 內一次執行選擇性(selective)Story replay(範圍為既有 Story 覆蓋的整段期間)與 full-story-set 策略刷新,耗時且吃記憶體,需先確認容量並預留時間。夜間籌碼鏈在停用期間已把各交易日記為完成,這些日子的 策略命中與匹配不會自動補算(#1964),需另外核對缺漏日期與補算範圍。

機器 角色 服務
MacBook-Pro-2(dev/prod) 開發 + 正式 cortex-*(API 8000、Web 3000、DB 5432)、正式 DB
linjianguodeMacBook-Pro(SIT) 專用 sit self-hosted runner + 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 —
realtime-quote-scheduler cortex-sit-realtime-quote-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 內容,亦接受 base64 或 .p8 路徑;申請流程見 apple-sign-in-setup.zh-TW.md)、 SMTP_USERNAME、SMTP_PASSWORD、CLOUDFLARE_EMAIL_API_TOKEN、TELEGRAM_BOT_TOKEN Variables(非秘密): CORS_ALLOW_ORIGINS(如 https://sit-cortexpro.aoshiken.com,管理後台對外網域)、 APPLE_APP_BUNDLE_ID(原生 iOS/macOS App 的 Bundle ID;無原生 App 可留空)、 OAUTH_CALLBACK_URL、FRONTEND_URL、FRONTEND_URL_WEB(對外 Cloudflare 網域)、 SIT_API_PORT(視 SIT 機佔用調整)、SCHEDULE_SUMMARY_MAIL_ENABLED、SCHEDULE_SUMMARY_MAIL_TRANSPORT、 SCHEDULE_SUMMARY_MAIL_TO、SMTP_HOST、SMTP_PORT、SMTP_SECURITY、SMTP_FROM、 SMTP_TIMEOUT_SECONDS、CLOUDFLARE_EMAIL_ACCOUNT_ID、CLOUDFLARE_EMAIL_FROM、 CLOUDFLARE_EMAIL_TIMEOUT_SECONDS、SCHEDULE_TELEGRAM_ENABLED、TELEGRAM_CHAT_ID、 TELEGRAM_TIMEOUT_SECONDS(可留空)、SCHEDULER_STRATEGY_ENABLED(未設時 SIT 預設 false,見上方說明)、 SIT_API_WORKERS(API 的 uvicorn worker 數,未設=1)、THEME_JOB_RECOVER_STALE_ON_STARTUP (主題任務啟動回收,未設=true;SIT_API_WORKERS 大於 1 時設 false),兩者見〈八、API 多 worker〉。 v0.24.0 首次套用 migration 134 前,操作者須停止全部 writers,使用既有工具完成一次 fresh 全庫加密備份並驗證;金鑰留在 SIT 主機的 0600 私有檔。這是該次部署前的一次性 操作,不是 workflow gate 或 PR merge 條件,也不要求新增備份 Variables;後續例行部署 沿用既有備份制度。完整 release 說明見 v0.24.0-sit-release.zh-TW.md。每日排程信的完整設定與安全啟用流程見 schedule-summary-mail.md。排程執行結果 Telegram 通知的設定與 啟用流程見 schedule-telegram-notify.md。

⚠️ 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 收的是 raw POSTGRES_PASSWORD、app 端會 decode DATABASE_URL,兩者不符會靜默認證失敗。 openssl rand -hex 24 產生的即為安全的純 hex。深層修法(app 端動態組建)追蹤於 #824。
  • runner trust boundary:deployment/maintenance jobs 使用完整 [self-hosted, macOS, X64, sit] labels;Backend PR CI 使用獨立 Linux cortex-ci runner,維持同 repository/OWNER 限制,且不得注入 deployment secrets。
  • 注意:secrets 仍會進到執行中容器的 env(app 需要),這不防主機被完全攻陷—— 它的價值是集中管理/輪換 + 主機不留明文檔。被攻陷的正確反應是「在 GitHub 一次全換、重跑 CD」。

2. 一次性 seed SIT DB(legacy bootstrap only)

此段只供全新SIT volume首次bootstrap。既有SIT若需重新帶入dev DB,禁止再使用 sit_db_seed.sh restore的pg_restore --clean流程;請使用下方「SIT DB Rebase」標準流程, 才會保留會員帳號、持股與觀察清單並提供candidate/rollback保護。

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 SCHEDULE_SUMMARY_MAIL_ENABLED=false  # 完整 provider 設定見 mail runbook
#   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"
export SIT_LOGS_PATH="${HOME}/.cortex/sit/logs"
mkdir -p \
  investment-agent/data/sit/quarterly_xbrl \
  "$SIT_LOGS_PATH" \
  "$RUNTIME_STATE_PATH"

# 兩個 log 路徑都故意放在 checkout 外,理由有兩層:
# (1) GitHub Actions checkout clean 會刪除 workspace 內被 gitignore 的檔案,
#     但不會刪除長時間 backfill checkpoints;
# (2) /app/logs 與 /app/logs/runtime-state 是「巢狀」bind mount,Docker 會在
#     主機上把該巢狀掛載點具體化成真實目錄。外層來源只要在 workspace 內,
#     每次 stack 啟動就會在 checkout 裡留下一個目錄,而它存在期間
#     actions/checkout 清不掉 workspace(rmdir 得到 EACCES)——曾連續讓
#     v0.19.0~v0.21.0 四次 tag 的文件站部署失敗。這兩個 export 即使手動操作
#     也不可省略(compose 的預設值同樣指向 $HOME/.cortex,省略只是拿到同樣
#     的值)。
#
#     ⚠️ 為什麼 runner 刪不掉它,目前**未確定**(#1114)。本文件早期版本寫
#     「由 daemon 以 root 建立」,主機實測推翻該說法:三層皆
#     aoshiken.dev:staff、drwxr-xr-x、flags 欄 '-'、無 ACL,而 runner 就是
#     該使用者。ownership、permission、macOS flags、ACL 皆已排除。停掉容器
#     後即可刪除,但那是相關性不是機制。不要從這段文字推導成因。

# (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

二、SIT DB Rebase(手動、需核准)

適用時機與資料契約

  • 日常程式發版不搬DB;只有明確需要讓SIT採用dev全庫資料時才執行本流程。
  • dev dump保留完整schema與一般資料,但不帶入:refresh_tokens、 oauth_login_states、oauth_exchange_grants、email_otp_challenges、 member_social_accounts與member_email_credentials的table data。
  • SIT會回灌會員帳號、OAuth provider、Email credential、tier/admin/status/profile。
  • SIT持股以完整交易ledger保留:brokers、transactions、 transaction_closures,包含day-trade pair與FIFO closure關係。
  • SIT觀察清單保留watchlist_groups與watchlist_items;若引用ticker不在dev, 只補入必要stocks row,不覆蓋dev既有stock metadata。
  • 同一會員在dev與SIT都有資料時採SIT-wins:candidate內該會員的dev持股與觀察清單 會被SIT版本取代,不做union,避免交易重複計入;dev-only會員資料不變。
  • 所有refresh/session/token失效,切換後使用者必須重新登入;原SIT Email密碼hash與 OAuth identity仍保留。
  • subscription history、user groups與audit/access logs不回灌,但仍存在原封不動的 rollback DB與完整disaster-recovery dump。

一次性GitHub設定

在repository Settings → Environments建立sit-db-rebase,設定required reviewer,並將 deployment branch規則限制為SIT。 只有可核准SIT資料破壞操作的人員能approve此environment。workflow固定checkout SIT、 與日常deploy共用deploy-sit concurrency,兩者不會併發。

DB rebase會重新啟動realtime quote scheduler,因此沿用日常部署的GitHub Actions設定: REALTIME_QUOTE_SOURCE預設為data_hub,此模式須有HUB_API_URL Variable與 HUB_SERVICE_TOKEN Secret;切為finmind時不需要Hub credential。workflow會在停止 任何writer或切換DB前,以worker共用的驗證器檢查來源、HTTP(S) URL與空白credential; 設定錯誤時直接結束。

Step 1:dev主機準備dump

umask 077
mkdir -p "$HOME/.cortex/sit-exports"
chmod 700 "$HOME/.cortex/sit-exports"

dump="$HOME/.cortex/sit-exports/cortex_dev_for_sit_$(date +%Y%m%d_%H%M%S).dump"
investment-agent/scripts/prepare_sit_rebase_dump.sh "$dump"

指令會建立$dump與$dump.manifest,以0600保存,並印出SHA-256。producer會用 pg_restore --list確認六張認證/暫態表沒有TABLE DATA,且會員、交易持股與觀察清單 TABLE DATA都存在。請把畫面上的SHA-256放入變更紀錄,傳檔時不要從log重新拼湊。

Step 2:operator以SSH加密傳到SIT staging

先在SIT主機建立受限目錄:

install -d -m 700 "$HOME/.cortex/sit/imports"

再從dev主機傳送兩個檔案:

scp "$dump" "$dump.manifest" \
  <sit-user>@<sit-host>:.cortex/sit/imports/

不得把dump放進Git workspace、GitHub Actions artifact、release asset或公開object storage。 workflow只接受staging目錄內的basename,不接受URL、/、..、symlink或任意路徑。

Step 3:手動dispatch並核准

gh workflow run rebase-sit-db.yml \
  --ref SIT \
  -f dump_basename="$(basename "$dump")" \
  -f manifest_basename="$(basename "$dump.manifest")" \
  -f dump_sha256='<prepare指令印出的64字元SHA-256>' \
  -f confirmation='REBASE_SIT_DB_KEEP_ACCOUNTS_PORTFOLIOS_WATCHLISTS'

在rebase-sit-db environment approval畫面再次核對dump basename、SHA-256與本次變更單。 核准前先確認SIT主機磁碟能同時容納原DB、candidate DB、輸入dump與完整DR dump;腳本也會 按來源DB bytes加25%安全餘裕fail closed,空間不足不會退回直接drop舊DB。

自動執行順序

  1. 驗證basename、symlink、SHA-256、TOC、PostgreSQL major、磁碟、container identity。 驗證後會複製成run-private 0600輸入檔,後續TOC與restore不再讀取operator staging檔。
  2. 驗證dump來源commit已被目前SIT commit納入,並比對dev dump、現行SIT DB與 SIT checkout三方migration ledger及完整migration檔案內容;不一致即停止。
  3. 建立run-scoped candidate DB,寫入database COMMENT與host ownership marker,再restore dev dump。
  4. candidate migration、collation與token exclusion檢查通過後,在現行SIT仍在線時建立完整DR dump。
  5. 停止API、daily scheduler及realtime quote scheduler writers,匯出最終account-domain bundle及checksums。
  6. 在candidate單一transaction內完成identity mapping、SIT-wins持股/觀察清單replace、ID remap與FK/invariant驗證。
  7. 原cortex_investment rename成run-scoped rollback DB;candidate rename成cortex_investment。
  8. 啟動API與兩個scheduler,並要求API health及兩個scheduler running連續三次成功。

candidate restore/merge失敗時不切換,舊SIT直接重啟。rename後health失敗時,workflow會把 failed candidate移出live名稱、將原DB rename回cortex_investment並重新驗證health。 切換期間live與candidate會先設為ALLOW_CONNECTIONS false再終止既有連線;成功後只重新 開放新live,保留的rollback DB維持禁止連線,避免誤用。

證據、rollback與清理

每次run的restricted evidence位於$HOME/.cortex/sit/rebase-runs/<run-key>/,包含:

  • 輸入dump TOC與SHA驗證結果;
  • 完整SIT DR dump、TOC與SHA-256;
  • account-domain bundle(含credential hash與投資資料,皆0600);
  • identity/domain mapping counts與merge summary;
  • candidate、rollback、failed DB名稱與ownership marker。

不得上傳上述檔案為Actions artifact,也不得把完整email、password hash、provider payload或 交易明細貼到issue/PR。rollback DB與DR dump不自動刪除。確認新SIT穩定後,先列出精確DB名稱、 pg_database_size、dump SHA與保留期限,另取得破壞性操作核准,才能執行DROP DATABASE或刪檔。 禁止用glob或「最新一個」推測清理目標。

三、CD(push 到 SIT 即自動部署)

  • 觸發:merge / push 到 SIT branch → deploy-sit.yml 在 SIT 機執行。 經審查的 dev 版本 merge 至 SIT 後即由正常 CI/CD 自動部署;保留 SIT 既有 hotfix, 不以 force push 覆蓋部署分支,也不另設 rehearsal SHA、人工 timeout 或 receipt gate。
  • v0.24.0 首次含 134 的部署須先安排並完成上述 fresh backup,再觸發會自動部署的 dev → SIT merge/push;不得先觸發部署再補備份。此一次性操作不阻擋程式碼合入 dev, 也不成為 GitHub PR approval/merge check。人工 full-size rehearsal 仍只是可選維運證據。
  • workflow 順序:deployment holds → checkout → config preflight → 保存原服務 running+immutable image IDs → build → origin binding → DB ready → pending 數量 +持久 DB ledger 狀態 → 所有app writers停機與 read-back → migration marker → migrations → schema gates → 新 stack recreate+穩定 health → 記錄成功啟用點 → SES catch-up。本版保留 40 檔 pending 上限。
  • runner 目標固定 [self-hosted, macOS, X64, sit],以 restricted runner group 限制 host-bound workflows。缺 label 時保持 queued,不退回通用 self-hosted labels。
  • migration 使用 LOCK_TIMEOUT=5s;只有 migration 134 的 statement timeout 固定使用 0(不設 statement timeout),同批及其他部署的其餘 migration 檔各使用 600s。 workflow 不設定 job timeout override,沿用 Actions 既有 6 小時預設上限,也不依賴 額外 Actions Variables。 失敗檔案雖未記入 ledger,前面 DDL 仍可能已 commit;重跑前須先稽核實際 schema/partial state。
  • quiesce 前核對 public.schema_migrations 與 host 上的最後成功啟用點 $HOME/.cortex/sit/activated-migration,以 COLLATE "C" 比較完整檔名;超過啟用點 視為已開始 migration,包含未成功啟用的 partial/零 pending 跨 run 重試。 檔案不存在僅首次回退 baseline127;壞檔/不可讀/擁有者或 0600 權限不符/不在 checkout 或 DB ledger 的啟用點會在 quiesce 前拒絕部署。原服務只有在 capture 時 API/daily scheduler都running且image IDs完整有效才可fallback;若既有quote worker存在, 它也必須running且image ID有效。首次部署時只有Docker inventory成功證明quote容器不存在, 才可把舊兩服務stack視為完整rollback來源;inspect或inventory失敗不能當成不存在。
  • 正式 quiesce 尚未開始的失敗/取消不額外停止原 stack;既有 legacy runtime-state 搬移分支若已停止服務,仍須人工讀回/復原。marker 為 false、quiesce 已開始且 完整 fallback 可用時,可以恢復先前 image;成功啟用後的純程式碼部署若為零 pending 且 ledger 未超過啟用點,marker 保持 false,quiesce 或新 stack recreate/health 失敗可自動回退,deploy 仍失敗/取消。 fallback unavailable 或恢復失敗時會再停所有app writers。回復經證明沒有舊quote worker的 legacy stack時,只重啟API與舊daily scheduler;恢復健康後會移除本次失敗前滾所建立的 stopped quote container,讓下次部署仍能證明舊stack的quote worker真正不存在。清理或讀回失敗時 rollback視為失敗並再停全部writers;不會移除已證明屬於舊stack的quote worker。 quiesce 以 Docker inventory 判定 writer:API、daily scheduler、quote worker 經 inventory 成功證明不存在時視為已停止;inventory 或 inspect 失敗仍不能當成已停止。主機在 recreate 中途斷電可能舊容器已刪、新容器未建,重跑可通過 quiesce;原服務不在,該次沒有可回退的舊 stack。
  • marker 為 true、新 stack 尚未通過穩定 health 驗證前,任何 migration、 schema、recreate 或 health 失敗都保持所有app writers停止。禁止只 retag 舊 image, 禁止自動 DROP、rebase 或還原 live DB;依 release runbook 以同次 cutover 的 DB dump+舊 image/config 配對回復。 可處理的 TERM/INT 會先停止 writers,再讀 logs;runner 強制終止或主機故障仍須讀回 實際狀態,不能把 cleanup step 的存在當成服務已停止的證明。
  • 新 stack 通過穩定 health 後才標記 SIT_CUTOVER_ACTIVATED=true。下一個獨立步驟讀取 ledger 的實際 MAX(filename COLLATE "C"),以同目錄 tmp+原子替換、0600 權限 更新啟用點。寫入失敗保留健康新 stack 並令 deploy 失敗;不可提前寫入/刪除啟用點 來繞過回退閘門。人工 DB recovery 也須核對啟用點與配對 images。 後續 SES catch-up 失敗或取消仍保留健康的新API與兩個scheduler,deploy保持失敗或取消,catch-up未完成的 watermark 不寫入;SIT_MIGRATION_STARTED 保持原值,啟用後的 cleanup 不切回舊 image。
  • OAuth schema gate 仍核對 public 的兩張表:完整欄位型別/長度/nullability、 PK/unique/CHECK/FK、ON DELETE CASCADE、expiry/member indexes 與正負 probe; 同名但殘缺的漂移表不能通過。
  • migration 106 新增 oauth_login_states、oauth_exchange_grants,讓 OAuth state 與 exchange grant 可跨 Uvicorn workers 原子消耗。部署或 rollback 會使尚未完成的短期登入 流程失效,使用者只需重新登入;既有 access/refresh token 不受影響。
  • rollback 到舊 image 時不要 drop migration 106 的表:舊程式會忽略它們,保留空表是 安全且可再次前滾的做法。
  • Backend PR CI 在獨立 Linux runner 執行,不共用 SIT 的 Docker runtime。

查看部署結果:gh run list --workflow deploy-sit.yml。


四、Scheduler

SIT 啟用兩個獨立排程程序:

  • cortex-sit-scheduler 執行每日、補齊與story工作;
  • cortex-sit-realtime-quote-scheduler 只在設定的台股盤中窗口輪詢即時報價。

兩者可獨立啟停;DB migration/rebase仍必須同時停止API與兩個scheduler。

⚠️ Rate limit:SIT 與正式 scheduler 雖在不同機,但共用同一組 FinMind/FRED 額度 → 每日與盤中呼叫都會增加。若盤中報價觸發供應商限流,只停quote worker: sitc stop realtime-quote-scheduler;daily pipeline則使用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 logs -f realtime-quote-scheduler  # 盤中報價worker 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-coverage submodule 以 :ro 掛載(只讀輸入),SIT 不會回寫。
  • SIT logs 落在 $HOME/.cortex/sit/logs/(SIT_LOGS_PATH)、runtime state 落在 $HOME/.cortex/sit/runtime-state/(RUNTIME_STATE_PATH),兩者都刻意在 git checkout 之外(見上方 §三的說明);scheduler data 落在 investment-agent/data/sit/,與其他環境分開。

    舊版把 logs 放在 investment-agent/logs/sit/。既有主機上那個目錄會留下 歷史 log,可自行保留或封存;新的寫入一律進 $HOME/.cortex/sit/logs/。

  • SIT 為長壽部署分支,由 CD 監看;正式環境日後用 tag-driven CD(不與此共用)。

疑難排解:屬性正常卻 Permission denied 的目錄

症狀:某個目錄 ls -l 看起來完全正常(你自己擁有、drwxr-xr-x、無 ACL、無 flags),但 mv / rmdir / rm -rf 一律回 Permission denied。CI 上的典型長相是 actions/checkout 失敗:

EACCES: permission denied, rmdir '.../investment-agent/logs/sit/runtime-state'

原因:某個運行中的容器把該路徑當作 bind mount 的目標。Docker Desktop 在 macOS 透過檔案分享層(virtiofs / gRPC-FUSE)把 host 目錄投射進 Linux VM,掛載期間 會釘住對應的 host 目錄,並對 rename(2) / rmdir(2) 回 EACCES 而非 EBUSY。

最誤導人的有兩點,這是它連續擋掉 v0.19.0~v0.21.0 四次 tag 部署、且排查方向 錯誤數小時的原因:

  1. 回的是 EACCES 不是 EBUSY。EBUSY 會直接指向「有東西持有它」;EACCES 把人導向權限方向——而權限完全正常,於是怎麼查都查不出所以然。
  2. mount 表裡查不到它。它不是 host 層級的掛載,mount、lsof 都看不到有 東西持有它。「mount | grep 沒命中」不代表沒有東西持有它。

判斷方法:查它是不是某個運行中容器的 bind mount 目標。

docker ps -q | xargs -I{} docker inspect -f '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' {} | grep <路徑片段>

解法:停掉持有它的容器再操作。

docker stop cortex-sit-api cortex-sit-scheduler cortex-sit-realtime-quote-scheduler
mv <路徑> <封存位置>

deploy-sit.yml 的 Preserve legacy runtime state before checkout 已內建這個 fallback(mv 失敗時自動停容器重試,容器由同一個 job 稍後的 Recreate SIT stack 帶回),日常不需人工介入。

根本修正已完成:兩個 log mount 的來源都已移出 git checkout(見 §一的 export 說明),Docker 不會再在 workspace 內建立掛載點,此情況不應再發生。上述保留作為 辨識特徵——同樣的陷阱會出現在任何 Docker Desktop on macOS 的機器上,不限於這 一個路徑。

實證:2026-07-30 於 SIT 主機(macOS 15.7.7 / Docker 29.5.2)以受控實驗重現—— 同形狀的巢狀 mount 產生的目錄,容器運行中 mv 與 rmdir 皆 Permission denied、 停掉容器後 mv 成功;xattr -l 只有 com.apple.provenance,mount | grep 命中 數 0。完整過程見 issue #1114。

七、只同步 SIT 題材成員

題材 Markdown 更新已部署到 SIT 後,使用 Sync SIT Theme Memberships workflow;不要為了題材資料使用整庫 Rebase SIT Database。此 workflow 固定在 具備專用 sit label 的 self-hosted SIT runner 上執行,並與部署/整庫 rebase 共用 deploy-sit concurrency lock;sit-theme-sync Environment 另限制只有 dev branch 可執行。現行 private-repo GitHub 方案不支援 required reviewers,因此執行授權必須同時留在變更單/issue, 並由操作者輸入與日期完全一致的 confirmation phrase 與已核准筆數。

執行前先在相同 SIT commit 對來源做 dry-run,確認預期的題材、成員與關聯筆數。 以 2026-07-26 snapshot 為例:

gh workflow run sync-sit-theme-memberships.yml \
  --ref dev \
  -f knowledge_as_of=2026-07-26 \
  -f expected_memberships=629 \
  -f expected_relations=69 \
  -f expected_themes=41 \
  -f confirmation=SYNC_SIT_THEME_MEMBERSHIPS_2026-07-26

workflow 在寫入前會確認:

  • 先解析最新 origin/SIT SHA,再於每次 run 專屬目錄 checkout 該精確 commit 與 recursive coverage submodule;不把 volatile 的 live bind mount 當作資料來源;
  • 隔離 checkout 與預期 SIT SHA 相同、coverage submodule 與 SIT gitlink 一致,且 themes/ 沒有額外未追蹤/ignored Markdown;
  • 從 coverage submodule 的精確 commit 建立 archive,送入 API 容器內由非 root runtime user 建立的 run-scoped 暫存目錄,並以逐檔 SHA-256 manifest 驗證內容相同;
  • cortex-sit-api/cortex-sit-postgres 健康,既有 live 題材 mount 存在且唯讀;
  • 容器內同步程式 SHA-256 與 SIT checkout 相同;
  • dry-run 筆數與人工核准的三個 expected inputs 相同;
  • 完整兩張題材表 dump、同日 CSV rollback 檔與 SHA-256 均已產生。

寫入只會替換指定 knowledge_as_of 的 coverage_theme_membership 與 coverage_theme_relation,並使用應用程式的單一 transaction。寫入或後驗證失敗時, runner 會以同日 CSV 自動還原並逐檔比對;成功時則驗證來源/DB 完整列集合、歷史 snapshot、 非題材 coverage 表與 SIT API,最後移除容器暫存來源。為確保 API 確實讀到本次資料, target date 不得早於 DB 目前最新題材日期。每次 run 的備份、來源 manifest、 sync-success.marker 與驗證證據會保存為 30 天 artifact。

八、API 多 worker(#2105)

SIT 的 agent-api 預設跑 1 個 uvicorn worker。worker 數由 GitHub Actions Variable SIT_API_WORKERS 決定:compose 把它轉成 WEB_CONCURRENCY,uvicorn 在啟動指令沒有 --workers 時讀它,程式也讀同一個變數決定下面兩件事:

  • 連線池均分:config/core.yaml 的 database.api(pool_size 10、max_overflow 20)是整個 API 的總額,每個 worker 拿 floor(總額 ÷ worker 數)。2 個 worker 時每個 5+10,API 合計仍是 30 條, 與單 worker 相同。
  • 限流共用 Redis:worker 數大於 1 且 REDIS_URL 有值時,default/strict/theme_analysis 三層 限流的計數改放 Redis,每個 IP/會員在所有 worker 間共用一份額度。單 worker 維持進程內計數。 每個請求因此多一次同步 Redis 來回(2026-11-03 本機以 limits 的 fixed-window hit 各量 5×1,000 次:Redis 每次 wall 0.0475 ms/CPU 0.0285 ms,in-process 0.0011 ms;以 11-02 壓測每請求 17.22 ms loop CPU 換算,多出的 CPU 約占 0.16%;SIT CPU 較慢,實際值以 SIT 重測為準)。Redis 停掉或卡住超過 api.rate_limit.redis_socket_timeout_seconds(0.1 秒)時,該 worker 退回進程內計數, 之後以 1、2、4…秒的間隔重試 Redis。退回期間各 worker 各自計數,額度暫時變成 worker 數倍。 Redis 的 allkeys-lru 在記憶體不足時也可能淘汰限流 key,結果只會更寬鬆。

開啟步驟

  1. 先確認 SIT 上跑的已是含 #2105 的版本(單 worker):docker exec cortex-sit-api printenv WEB_CONCURRENCY 印出 1 才是;舊版沒有這個變數,指令沒有輸出且 exit 1。不要在第一次部署這個版本時就同時切 2 worker: 沒有 pending migration 的部署失敗時,自動回滾(sit_stack_control.sh restore-previous)會用當下的 Variable 重建前一版 image,舊版沒有連線池均分與 Redis 限流,會變成 2 個 worker 各開 10+20 條連線、 限流額度翻倍。(有 migration 的部署失敗時不會自動回滾,writers 維持停止。)
  2. repo Settings → Secrets and variables → Actions → Variables 設 SIT_API_WORKERS=2 與 THEME_JOB_RECOVER_STALE_ON_STARTUP=false。 多 worker 時,任一 worker 當掉重啟的啟動回收,會把另一個 worker 跑超過 60 分鐘的主題任務誤標 failed, 所以要一起關。關閉後,當掉時遺留的任務會維持 running,需要在管理端手動取消。
  3. 重跑 Deploy SIT。部署會在 build 前檢查這兩個值:SIT_API_WORKERS 必須是 1 到 database.api.pool_size(目前 10)之間的整數, 回收開關只接受 true/false/1/0/yes/no/on/off;worker 大於 1 而回收仍開時只發警告(::warning::),不擋部署。
  4. 驗證兩個 worker 都在。/health 是綠的不代表兩個都活著,因為 supervisor 重啟其中一個時, 另一個仍會回應。
docker logs cortex-sit-api 2>&1 | grep -c "Started server process"
docker logs --since 1m cortex-sit-api 2>&1 | grep -o '"pid":[0-9]*' | sort -u

第一行應為 2(重啟後會再增加),第二行應有 2 個不同 pid,且同一行的 db_pool 顯示 pool_size 5/max_overflow 10。

限制與注意

  • worker 數只用 SIT_API_WORKERS 設定,不要在 compose 的 command 加 --workers,否則連線池不會均分、限流也不會改走 Redis。
  • SIT_API_WORKERS 不可大於 database.api.pool_size(目前 10,以 config/core.yaml 為準):每個 worker 至少要分到 1 條連線。這只是連線池切得開的上限,主機記憶體能撐幾個 worker 部署前不會檢查。部署前驗證會擋下超過的值;uvicorn 會先把所有 worker 程序開起來、app 才會拒絕,所以不能靠 app 啟動時的檢查。
  • DB rebase workflow(〈二〉)也會重建 agent-api,並沿用同兩個 Variable。它沒有格式檢查,改 Variable 後先跑一次 Deploy SIT 再跑 rebase。
  • 記憶體:2026-11-01 單 worker 300 VU 壓測期間(壓測 harness resources.csv),cortex-sit-api 373.9–416.7 MiB、VM 可用 1,413–1,662 MiB、主機 swap 已用 2,280–3,051 MiB。 2026-11-03 04:43 閒置時(SSH 唯讀查詢),cortex-sit-api 384 MiB、VM MemAvailable 1,387,700 kB、主機 swap 已用 2,752 MiB/4,096 MiB。 第二個 worker 預估再吃約一個 worker 的量,會加重 swap。
  • 仍是每個 worker 各一份的進程內狀態:單檔即時更新去重(api/routes/stocks.py,同一檔新股票可能被兩個 worker 同時更新, 只多耗 FinMind 次數,OHLC 寫入本身有 DB advisory lock)、空窗更新節流、即時報價串流計數、各類唯讀快取。 Gemini 配額已是 Postgres 共享、response cache 回填已有 Redis 租約,不受影響。
  • 示範用的 admin job API(POST /api/admin/update-data、GET /api/admin/jobs*,api/routes/analyze.py)把 job 狀態存在 進程內 dict,多 worker 時查詢可能落到另一個 worker 而回 404 或漏列。它只模擬進度、不更新資料,且只有管理者使用, 2026-11-03 裁決接受此限制、不改。
  • runtime_metrics 每個 worker 各寫一行 10 秒窗,以 pid 區分。判讀方式見 api-runtime-metrics.md。

回滾

把 SIT_API_WORKERS 改回 1(或刪除)、THEME_JOB_RECOVER_STALE_ON_STARTUP 改回 true,再重跑 Deploy SIT。 程式不需回滾:單 worker 時連線池、限流與舊版行為相同。

網路暴露(pre-public hardening #567、#878)

  • Postgres 不對外:compose 已移除 postgres 的 host ports 映射;DB 只在內部 network(postgres service 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.yaml api.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。

v0.24.0 初次切版容量紀錄

SIT Docker VM 上限已擴至 768 GiB;最近一次實際讀回 PGDATA guest filesystem 可用 528,032,485,376 bytes、Docker.raw backing filesystem 可用 1,797,346,500,608 bytes, 原服務目前健康。這些數值是該次維運紀錄,不是 CD threshold 或 workflow gate;CD 不執行 capacity helper。額外 owned rehearsal 已停止,且其全庫 restore(含 post-data) 仍為 INCOMPLETE,不可稱為 PASS。詳見 v0.24.0 runbook。