跳轉到

Runbook: SIT 環境部署與 CD

SIT (System Integration Test) 跑在獨立的 SIT MacBook Pro(runner 名 linjianguodeMacBook-Pro)上。該機是帶有專用 sit label 的持久化 self-hosted deployment runner;PR CI(test.yml)只在 GitHub-hosted VM 執行,不能接觸此主機。正式/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)。

拓樸一覽

機器 角色 服務
MacBook-Pro-2(dev/prod) 開發 + 正式 cortex-* (8000/8501/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

port 預設值:CD 走 GitHub Actions VariablesSIT_API_PORTSIT_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_KEYDB_PASSWORDFINMIND_TOKENFRED_API_KEY Secrets(建議): GEMINI_API_KEYOPENAI_API_KEYGOOGLE_CLIENT_IDGOOGLE_CLIENT_SECRETAPPLE_CLIENT_IDAPPLE_TEAM_IDAPPLE_KEY_IDAPPLE_PRIVATE_KEY(PEM 內容)、 SMTP_USERNAMESMTP_PASSWORDCLOUDFLARE_EMAIL_API_TOKEN Variables(非秘密): CORS_ALLOW_ORIGINS(如 https://sit-cortexpro.aoshiken.com,管理後台對外網域)、 OAUTH_CALLBACK_URLFRONTEND_URLFRONTEND_URL_WEB(對外 Cloudflare 網域)、 SIT_API_PORT(視 SIT 機佔用調整)、SCHEDULE_SUMMARY_MAIL_ENABLEDSCHEDULE_SUMMARY_MAIL_TRANSPORTSCHEDULE_SUMMARY_MAIL_TOSMTP_HOSTSMTP_PORTSMTP_SECURITYSMTP_FROMSMTP_TIMEOUT_SECONDSCLOUDFLARE_EMAIL_ACCOUNT_IDCLOUDFLARE_EMAIL_FROMCLOUDFLARE_EMAIL_TIMEOUT_SECONDS。每日排程信的完整設定與安全啟用流程見 schedule-summary-mail.md

⚠️ SIT_DB_PORT 不是 CD Variable:compose 的 postgres 不對外發布 host port(內部 網路限定,#567),故 CD 不吃此值;它只影響「手動」在本機開 loopback DB-GUI 綁定時的 port (見下方 §五 網路暴露、.env.sit)。

  • JWT_SECRET_KEYopenssl rand -hex 32 且【與正式不同】;DB_PASSWORDopenssl 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:PR workflows 只能使用 GitHub-hosted VM;所有 SIT deployment/maintenance jobs 必須同時指定 [self-hosted, macOS, X64, sit],且 runner group 只允許受信任 workflows。不得讓 fork/PR 接觸 SIT host、Docker daemon 或 secrets。
  • 注意:secrets 仍會進到執行中容器的 env(app 需要),這不防主機被完全攻陷—— 它的價值是集中管理/輪換 + 主機不留明文檔。被攻陷的正確反應是「在 GitHub 一次全換、重跑 CD」。

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

此段只供全新SIT volume首次bootstrap。既有SIT若需重新帶入dev DB,禁止再使用 sit_db_seed.sh restorepg_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_tokensoauth_login_statesoauth_exchange_grantsemail_otp_challengesmember_social_accountsmember_email_credentials的table data。
  • SIT會回灌會員帳號、OAuth provider、Email credential、tier/admin/status/profile。
  • SIT持股以完整交易ledger保留:brokerstransactionstransaction_closures,包含day-trade pair與FIFO closure關係。
  • SIT觀察清單保留watchlist_groupswatchlist_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,兩者不會併發。

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. 比對dev dump、現行SIT DB與目前SIT branch三方完整migration ledger;不一致即停止。
  3. 建立run-scoped candidate DB,寫入database COMMENT與host ownership marker,再restore dev dump。
  4. candidate migration、collation與token exclusion檢查通過後,在現行SIT仍在線時建立完整DR dump。
  5. 停止API/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並要求連續三次health成功。

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 機 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 限定 public schema,會核對兩表的完整欄位型別/長度/nullability、必要 PK/unique/check/FK constraints、ON DELETE CASCADE 與 expiry/member indexes;同名但殘缺的 漂移表不能通過。
  • runner 目標:SIT host 必須具備專用 sit label;所有 host-bound workflows 釘死 [self-hosted, macOS, X64, sit],並由 restricted runner group 限制可執行的 workflows。 若 label 或 runner group 尚未設定,部署應保持 queued,不得退回通用 self-hosted labels。
  • 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_statesoauth_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-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 不是 EBUSYEBUSY 會直接指向「有東西持有它」;EACCES 把人導向權限方向——而權限完全正常,於是怎麼查都查不出所以然。
  2. mount 表裡查不到它。它不是 host 層級的掛載,mountlsof 都看不到有 東西持有它。「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
mv <路徑> <封存位置>

deploy-sit.ymlPreserve 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 產生的目錄,容器運行中 mvrmdirPermission denied、 停掉容器後 mv 成功;xattr -l 只有 com.apple.provenancemount | 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-apicortex-sit-postgres 健康,既有 live 題材 mount 存在且唯讀;
  • 容器內同步程式 SHA-256 與 SIT checkout 相同;
  • dry-run 筆數與人工核准的三個 expected inputs 相同;
  • 完整兩張題材表 dump、同日 CSV rollback 檔與 SHA-256 均已產生。

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

網路暴露(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: okdatabase: 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