Runbook: SIT 環境部署與 CD
SIT (System Integration Test) 跑在獨立的 SIT MacBook Pro(runner 名
linjianguodeMacBook-Pro)上。該機是帶有專用sitlabel 的持久化 self-hosted deployment runner;PR CI(test.yml)只在 GitHub-hosted VM 執行,不能接觸此主機。正式/dev stack 在另一台(MacBook-Pro-2),兩者不同機。日常 SIT 發版:push 到
SITbranch →deploy-sit.yml在 SIT 機本地 build + migrate + upSIT 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 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 內容)、
SMTP_USERNAME、SMTP_PASSWORD、CLOUDFLARE_EMAIL_API_TOKEN
Variables(非秘密): CORS_ALLOW_ORIGINS(如 https://sit-cortexpro.aoshiken.com,管理後台對外網域)、
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-summary-mail.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 收的是 rawPOSTGRES_PASSWORD、app 端會 decodeDATABASE_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 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, 只補入必要stocksrow,不覆蓋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。
自動執行順序
- 驗證basename、symlink、SHA-256、TOC、PostgreSQL major、磁碟、container identity。
驗證後會複製成run-private
0600輸入檔,後續TOC與restore不再讀取operator staging檔。 - 比對dev dump、現行SIT DB與目前
SITbranch三方完整migration ledger;不一致即停止。 - 建立run-scoped candidate DB,寫入database COMMENT與host ownership marker,再restore dev dump。
- candidate migration、collation與token exclusion檢查通過後,在現行SIT仍在線時建立完整DR dump。
- 停止API/scheduler writers,匯出最終account-domain bundle及checksums。
- 在candidate單一transaction內完成identity mapping、SIT-wins持股/觀察清單replace、ID remap與FK/invariant驗證。
- 原
cortex_investmentrename成run-scoped rollback DB;candidate rename成cortex_investment。 - 啟動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 到
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 目標:SIT host 必須具備專用
sitlabel;所有 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_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 落在
$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 部署、且排查方向 錯誤數小時的原因:
- 回的是
EACCES不是EBUSY。EBUSY會直接指向「有東西持有它」;EACCES把人導向權限方向——而權限完全正常,於是怎麼查都查不出所以然。 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
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/SITSHA,再於每次 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。
網路暴露(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。