FR-167 · 設計文件 v1 · 2026-10-29(D1~D31 定案;35 張卡、8 批)

設計文件:D1~D31 與各部件設計

升級指令絕不出事:在資料庫複本上升、每步有時限、失敗自動退回;更新站在 Cloudflare、發版在決策者電腦蓋章;更新中心一頁通知、下載、按更新;Agent 先一台再其餘

這份把討論稿 31 項定案展開到「照做就能交」。兩層:第一層改現有的 guidantai,升級在資料庫複本上做、每步有時限、失敗或斷電自動退回,現在那份資料庫從頭到尾沒動過;第二層加一個更新站(Cloudflare Worker+R2)、一個發布 CLI(決策者電腦,一正一備印章)、主系統一頁更新中心(通知、下載、驗簽名、立即或排程、更新紀錄)、主機兩個 systemd 單元看更新單呼叫升級指令。版本只有一種、不自動裝、授權過期仍可升同主版號最新版。35 張卡 8 批,依賴與同批撞檔由 handoff/cards.py 程式驗過。

D1~D31 定案 35 張卡、8 批 跨 7 repo:BE/FE/jedi-remote-agent/evidence-agent/License Center/update-center(新)/test 開發期無人跑;測試期首腦操作 190 陷阱:進度檔只有 guidantai 寫;主系統對更新單目錄只有寫權 陷阱:guidantai/install.sh 超標,只准一行呼叫,邏輯進 lib/

狀態:v1 定案,待開卡|日期:2026-10-29|母卡:待建|前作:FR-065(install.sh --upgrade、guidantai)、FR-066(Agent 安裝包)、FR-062(授權中心、Ed25519 簽章、kid 公鑰表)、FR-064(簽章驗證)、FR-134(弱點資料更新頁:更新中心的版面與 chunk 上傳的範本)、FR-166(Agent outbox:結果送不出先存著已做掉) 討論稿(選項推演、業界對照、排除方案):discussion.html|雛形:prototype/index.html(10 頁) 開發 branch:BE/FE/jedi-python-package/evidence-agent/license_center 2026-10-29 查皆在 feature/FR-139;update-center 是新 repo(已建、branch main,P2-1 從它長出);runner 開工 git branch --show-current 再確認,不對就停下問,不切

🔴 這份是設計決策,不是現況。 內文的行號、檔名、數字停在 2026-10-29 定稿當時,之後實作改動的結果不回頭改寫這裡。上線後的畫面與 API 現況看 SPEC 頁與 FINAL-SPEC。

變更紀錄

日期 變更 對應
2026-10-29 初版設計定案(D1~D31),35 張卡 8 批;拆開發期(小弟本機驗)與測試期(190);套件走 beta 版;R2 既有 bucket 分主版號資料夾 母卡 CM-3241,子卡 CM-3242~CM-3276
2026-10-30 jedi-remote-agent beta 版號改 1.10.0b1(1.9.0 已被占用,正式版為 1.10.0);開發期 34 張卡驗收通過,落地與原文不同處(資料庫在 named volume、清單只讀 canonical)記在 handoff/fr167-STATE.md §3,本文不回改 CM-3244、CM-3262、CM-3263

1. 需求背景與端到端

現在每出一版,客戶要自己把 2.9GB 的出貨包搬上主機、照手冊敲 install.sh --upgrade;它直接在現在那份資料庫上套 migration,失敗靠事前備份還原,還原是寫入動作、本身會失敗。客戶在國外時這條路走不通。決策者的要求:按下確認之後全自動、看得到進度、不可以卡住、卡住也要能復原、資料絕對不掉、不靠人到現場、不自動裝、客戶主機上不多常駐程式不開新的埠。

做完之後

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant CI as 188 CI
    participant US as 更新站(Worker+R2)
    participant DM as 決策者電腦
    participant BE as 客戶主系統
    participant H as 客戶主機 guidantai
    participant AG as Agent
    CI->>US: 出包→上傳 R2→登記草稿(CI token 只能到這)
    DM->>US: guidant-release publish:核內部先裝→蓋章→已發布
    BE->>US: 每天 03:00 出示授權檔問有沒有新版
    US-->>BE: 已發布清單(決策者簽章、90 天有效)
    BE->>BE: 驗章、版號>現行、主版號相同→通知系統管理員
    Note over BE: 管理員按「下載」→背景下載、驗雜湊→已就緒
    BE->>H: 有權限的人按立即/排程→寫 upgrade-order.json
    H->>H: systemd path 看到單→guidantai upgrade --from-order
    H->>H: 先換成新版的自己(D30)→預檢→停接新工作等做完→停服務
    H->>H: 複製資料庫→在複本上載映像、套 migration→健康檢查
    alt 任一步失敗/超時/斷電
        H->>H: 自動退回:丟複本、接回原份、起舊映像
    else 全過
        H->>H: 換接:複本轉正、原份留作升級前快照、前門最後換
    end
    H-->>BE: 進度檔 upgrade-progress.json(維護頁與更新中心都讀它)
    BE-->>AG: 心跳回 target_version(先 1 台,成功再其餘)
    AG->>BE: 下載 Agent 包→寫單→主機 systemd 觸發 install.sh upgrade→回報結果
圖 1 — 端到端:四個地方、一條流程,私鑰只在決策者電腦

做完之後長這樣(對照雛形 10 頁):儀表板冒出「有新版」橫幅(⓪);更新中心看版本說明、預檢、按下載(①);下載中服務照常(①b);就緒後選立即或排程(①c);停機期間打開原網址是維護頁,每 3 秒更新步驟與時限(②);失敗自動退回並給診斷包(③);成功後 Agent 先一台再其餘(④,僅在確認視窗勾了「接著更新 Agent」時自動跟進);跨版兩段(⑤);例外狀態各一張卡(⑥);更新站後台只給原廠看(⑦)。

2. 分工概述(30 秒版)

期 做什麼 產出 完成怎麼判定(決策者檢查法)
一 升級變安全(P1,11 張) guidantai upgrade/recover:先換自己、預檢、停接新工作等做完、停服務、複製資料庫、複本上升級、健康檢查、換接、自動退回、開機檢查、清理;維護頁;出包產 release.json;CI 跨版排練 IT 現在就敲的那道指令變安全 在 190(現跑 1.26.0)灌 POC 資料庫備份,一道指令直升新版成功;放一支會失敗的 migration → 自動退回,資料筆數與升級前一致;升到一半 kill -9 主機重開 → 開機自動退回;停機期間打開原網址看到進度
二 更新站(P2,6 張) Cloudflare TS Worker+D1+R2、六張表、客戶端三端點、後台、發布 CLI(一正一備)、CI 登記草稿、印樣進產品、授權檔加主版號 我們發一版,客戶查得到 跑一次 tag pipeline,後台出現草稿;自己電腦跑 guidant-release publish 變已發布;用 DEV 授權檔打 check 回應裡有新版且簽章驗得過;用過期授權檔也回;拒絕名單的不回
三 更新中心(P3,9 張) 能力點、更新模組、每日檢查、通知、下載續傳、驗章、離線上傳、更新單、systemd 單元、排程、取消、重試、跨版兩段、更新中心頁、橫幅 管理員不必搬檔、不必登入主機 DEV 指向本機更新站:收到通知→按下載→就緒→按立即,更新中心與維護頁都看到進度跑到完成;再試排程到點自己跑;沒有「系統更新」權限的帳號看不到按鈕;拔網路改離線上傳一樣就緒
四 Agent 跟進(P4,5 張) 心跳契約、rollouts 表、分批、Agent 下載、Agent 升級指令比照主系統、Agent 區面板 Agent 不必逐台手動升 三台 Agent:主系統升完先一台變新版,其餘兩台接著升;故意讓第一台失敗,其餘兩台不會被推
五 收尾(P5,4 張) 手冊、SPEC、e2e、套件發版與釘版 文件齊 手冊有「什麼情況要敲 recover」;e2e 綠

依賴:一與二可以同時開(批 1 混排);三要等一的骨架(P1-1)與二的更新站(P2-1);四要等三的更新單;五最後。決策者功課(都不卡開發期,測試期前備妥即可):①更新站網域(建議 update.<公司網域>,加進 Cloudflare 那個帳號的 zone);R2 bucket 已有,Worker/D1/Access 由首腦用 wrangler 建;②在自己電腦跑 guidant-release init-keys 產一正一備,備用封存,PROD 印樣交給結案卡 P5-5;③R2 的 API token(只給該 bucket 物件讀寫)與更新站 CI token 填進 GitLab CI 變數(masked、不勾 Protect),不進 repo。CRA 兩題先不管,不列功課。

3. 決策定案(D1~D31)

定案一字內容以討論稿 callout 原文為準,本表為設計文件形狀的收斂。排序按主題不按編號。

編號 定案 落在哪
升級指令
D13 客戶機上由 guidantai upgrade 換版,主系統容器不碰 docker P1-1、§5.1
D30 升級指令開跑第一步從已驗包取新版 guidantai+lib/ 換掉自己再跑;換自己失敗視同預檢不過;畫面不顯示指令版本 P1-1 self_replace.sh
D18 在資料庫複本上升級,現在那份不動;證據檔不複製;設定與憑證一起複製 P1-4、§5.2
D19 跑到一半的工作等做完,永遠不強制停;時段結束還沒完就這次不更新 P1-3c、P1-3b
D25 服務停掉之後沒有取消鈕;每步時限、總上限 60 分,到了自動判失敗退回 P1-1 runner、§5.1 時限表
D14 開放使用前一律退回;開放使用後不退回,發修正版 P1-6、P1-7
D17 必經版本只有三種情況才設;由 migration 必經標記自動算 P1-9a
D20/D21 不做救援頁、不做常駐更新助手;執行者就是 guidantai upgrade,由 systemd 觸發 P3-2c
更新中心
D22 先通知,管理員確認後才下載 P3-2b、P3-3
D23 離線更新:管理員向原廠索取單一離線檔自己上傳,從驗簽名起同一條路 P3-2b
D24 記錄是誰按下載、誰按更新 product_update_jobs
D26 沒有「敲指令」:有「系統更新」權限的人在畫面上按;主系統只寫更新單 P3-1、P3-2c
D15/D28 下載要管理員確認、更新要有權限的人按、可立即可排程;任何版本都不自動裝 P3-2b、P3-2c
D10 每天檢查一次、管理員可關;關了更新中心一直提醒 P3-2b
D16 Agent 跟進可選:「更新到 X」確認視窗勾「主系統更新完成後,接著更新 Agent」(預設勾)就先推 1 台、正常再推其餘、任一台失敗其餘暫停;不勾就與主系統分開,之後在 Agent 區自己按,可排程、可稍後提醒 7 天。「必須更新」只在新版 min_agent_version 高於現行 Agent 時出現 P4-2
版本與授權
D27 版本只有一種;安全修正就是發下一版;只維護最新版;CRA 兩題待顧問 §5.6、P5-1
D7 授權過期仍可升同主版號最新版(唯讀、功能鎖);換主版號要重新授權 P2-1 判定、P2-5 欄位
D29 每一版的更新包至少保留 10 年 P2-1 retain_until
D8 不做差異更新;每版整包、不做只換單顆容器 —
更新站與發版
D1 更新包放 S3 相容私有空間,用 Cloudflare R2 P2-1、P2-4
D2 更新站與授權中心分開,互不持有對方憑證;只複製公鑰表 P2-1
D2a 更新站跑 Cloudflare Worker,先用免費方案;語言選 TypeScript(Python Worker 仍是測試版;決策者 2026-10-29 改判) P2-1
D3 發版最後一步在決策者電腦蓋章;印章一正一備、備用封存、跟授權中心印章分開;CI 只能登記草稿;換鑰用舊鑰背書、藏在一次對章通過的更新裡 P2-3、P2-6
D6 不做頻道;草稿可指定給內部機器(STG、POC)先裝 P2-2
D9 客戶只回報授權編號、機器指紋、現行版號、最近一次升級結果 P2-1 instance_version_reports
D11 客戶出示本機授權檔、更新站用授權中心公鑰驗 P2-1
D12 拒絕名單第一版決策者在後台手動加 P2-2
D31 發布清單 90 天有效,過期重新蓋章;不做「回應 24 小時內」 P2-3 簽、P3-2b 驗

4. 現況接入點盤點

零件 現況(2026-10-29 實查) 本案動作 卡
scripts/installer/guidantai 1,562 行 bash,root,裝機時複製到 /usr/local/bin/;子命令 status/start/stop/restart/logs/uninstall/rotate-credentials/agent-check/diag/setup-token/bugsink-*;派送在 1480–1562 extend,超標只准加派送行:加 upgrade、recover,邏輯全進新開的 scripts/installer/lib/ P1-1
scripts/installer/install.sh 3,021 行;run_upgrade 2853–2890 依序呼叫 upgrade_check_preconditions/upgrade_backup_database/load_images/upgrade_apply_migrations/upgrade_switch_version/install_ops_cli;備份失敗即中止、直接對現在那份套 migration extend:run_upgrade 改呼叫 lib/upgrade_runner.sh,既有各步驟函式搬進對應 lib 檔;install_ops_cli 連 lib/ 一起裝 P1-1
scripts/init/migrate.sh 298 行;比對 schema_migrations 已套清單依序補套;只印 log 不回進度 extend:每支印一行 PROGRESS\t<n>/<total>\t<檔名>,不寫進度檔(進度檔只有 runner 寫) P1-5
scripts/build/build_bundle.sh 408–430 產 bundle-info.txt,「適用起始版號:1.0.0 以上」寫死;bundle-manifest.sha256 是 install.sh 驗 digest 的來源 extend:多產 release.json(未簽);可直升起始版由 scripts/sql/manifest.tsv 必經標記算;包內多放 Agent compose 交付包 P1-9a
.gitlab-ci.yml package job 193–232:sbom → build_bundle → push_harbor;包留在 188 不上傳 extend:加 publish_draft.sh(R2 上傳+登記草稿);加 rehearse-upgrade job P2-4、P1-9b
docker/production/docker-compose.yml guidant-db pgdata 寫死路徑;guidant-fe 是唯一對外入口;restart: unless-stopped 靠 docker 開機起;不另立 systemd unit(FR-065 D10) extend:pgdata 改變數(升級時指複本);fe 多掛 upgrade/ 唯讀;本案加三個 systemd 單元(更新單 path+service、開機檢查 oneshot),由 install 裝 P1-4、P3-2c、P1-7
FE nginx.onprem.conf 單一 location 反代 /api/1.0;後端掛掉回 502;沒有 error_page extend:error_page 502 503 送 maintenance.html;/upgrade-progress.json alias 到唯讀掛載 P1-8
jedi-remote-agent 心跳 agent_enrollment_service.py 371 收 agent_version、602 比 allowed_targets_version;RemoteAgentAdapters 已有 heartbeat_extras(vulndb、profile、tool_schemas、ldap_config 都掛這) extend:加 dispatch_gate、update_extra 兩個 port;收 upgrade_result P1-3a
core/plugins/remote_agent.py+_agent_vulndb.py 宿主接線形狀:心跳附 vulndb {bundle_date, sha256, size}、下載端點 GET /agents/vulndb/bundle 走資料面 reuse 形狀:_agent_update.py 照抄,心跳附 update {target_version, sha256, size}、下載端點 GET /agents/update/bundle P4-2
evidence-agent core/vulndb_sync.py、core/enroll.py 355–372 observer 形狀:心跳回應有新資料就背景下載、續傳、驗 sha;enroll.py 一行掛一個 observer;outbox.drain 已在(FR-166) reuse 形狀:update_sync.py 照 vulndb_sync.py;Agent 送不出的結果已由 FR-166 outbox 處理,本案不開卡 P4-3
evidence-agent deploy/install.sh cmd_upgrade 1153 已經會「改由新版交付包內的安裝程式執行升級」(GUIDANT_AGENT_UPGRADE_DELEGATED)——D30 的形狀 Agent 端早有 extend:補每步時限、進度檔、自動退回;兩份 install.sh 只加一行,邏輯進 agent/install-lib/ P4-5
app/source_scan_vulndb/、FE views/vulndb/ 弱點資料更新頁:status/check-update/auto-update/check-interval/chunk 上傳三端點/history;FE VulndbManage.vue+VulndbUpdateHistory.vue 共 338 行 reuse 形狀:更新中心的 BE service、chunk 上傳、FE 版面都照它 P3-2a、P3-2b、P3-3
core/scheduler.py 714、core/scheduler_agent.py 排程已拆檔先例:scheduler.py 一行呼叫,job 在 scheduler_agent.py(add_job(... "cron", hour=2 ...)) reuse 形狀:scheduler_update.py P3-2b
core/host_capabilities.py、2026-10-10-fr134-vulndb-settings-page.sql system.vulndb.read/update 兩顆+選單路由綁定+角色授權,一支 migration reuse 形狀:system.product-update.read/update P3-1
jedi-license-runtime common/public_keys.py PUBLIC_KEYS 以 kid 查 Ed25519 公鑰;支援新舊雙鑰過渡 reuse 機制:發布印樣另一張表 /etc/guidant/release-keys/<kid>.pub,kid 規則同 P2-6、P3-2b
License Center modules/licensing payload 有 modules、expires_at、expiry_policy;沒有主版號 extend:加 product_major(預設 1);更新站把缺欄視為 1 P2-5
FR-064 sign_manifest.sh、LC POST /api/internal/sign-manifest 出貨包 manifest 送授權中心簽 不用它簽發布清單(D3:發布印章跟授權印章分開、在決策者電腦離線);只 reuse Ed25519 封包格式與 kid 規則 P2-3
通知 app/notification/service/notification_service.py;公告模組 jedi-bulletin reuse:有新版、已就緒、更新結果走通知;30 分與 5 分前全站提醒走公告或 toast(P3-2c 先 grep 定一個) P3-2b、P3-2c、P3-4
STG/POC 1.26.0,跟主線同版;189 留有 1.23.1 映像 跨版排練在 190 e2e 目標做,不碰 STG/POC P1-9b

5. 詳細設計

5.1 升級指令:步驟、時限、進度檔(P1)

guidantai upgrade <包 | --from-order> 由 lib/upgrade_runner.sh 逐步呼叫,每步一個 lib 檔、一個時限、一個進度檔狀態。runner 是唯一寫進度檔的地方。

步 lib 檔 做什麼 時限 失敗時
0 self_replace.sh 驗包雜湊與 release.json 簽章 → 把包內 guidantai+lib/ 裝到 /usr/local/bin → exec 新版帶同樣參數(環境變數 GUIDANT_UPGRADE_REEXEC=1 防迴圈) 每 GB 60 秒、下限 300 秒(從大包抽維運指令要讀完整包;已解開目錄 60 秒) 不動任何東西,頂層記 skipped(第 0~2 步失敗都不動系統)
1 upgrade_preflight.sh 空間(包+複本×1.2+映像+2GB 保留)、版本路徑對 min_base_version、資料庫連得上、停機預估(複製時間=資料夾大小÷實測速率×3) 2 分 列出不過項目,什麼都不動
2 upgrade_drain.sh 寫 upgrade/draining 旗標 → 主系統停接新工作(§5.3)→ 每 10 秒 psql 查進行中數 → 0 才過 30 分提醒;--deadline 到就放棄 刪旗標、恢復接單、進度檔 skipped
3 upgrade_db_copy.sh docker compose stop api worker socketio → stop db → cp -a pgdata pgdata-next → 同時複製 .env/憑證目錄 預估×3 刪 pgdata-next、起舊版
4 upgrade_apply_on_copy.sh docker load 新映像並比對 bundle-manifest.sha256(5 分)→ 以 GUIDANT_PGDATA_DIR=pgdata-next 起 db → init image migrate 模式 → tail PROGRESS 行轉寫進度檔 載映像 5 分;單支 migration 10 分無新進度 退回(§5.2)
5 upgrade_health.sh 以複本起 api/worker(fe 不動)→ 180 秒 healthcheck → 實打登入、/api/1.0/version、三支主要 GET 5 分 退回
6 upgrade_switch.sh pgdata → pgdata-prev-<舊版>,pgdata-next → pgdata;寫 GUIDANT_VERSION;最後重建 fe;進度檔 succeeded 2 分 這一步失敗仍退回(換接前都可退)
7 upgrade_cleanup.sh 刪更早的 pgdata-prev-* 與映像,只留上一版;更新包留到下次成功;刪更新單 — 只記 warning

總上限 60 分(步 3~6)。進度檔 <資料目錄>/upgrade/upgrade-progress.json:

{"job_id":"20261031-2200-a1b2","from":"1.24.1","to":"1.27.0","path":["1.27.0"],
 "triggered_by":{"kind":"order","user":"wang.dm","mode":"scheduled","at":"2026-10-29T09:25:00+08:00"},
 "started_at":"2026-10-31T22:00:04+08:00","status":"running",
 "steps":[{"n":1,"name":"preflight","status":"ok","started":"…","ended":"…","detail":"空間 42GB 需 8GB"},
          {"n":4,"name":"apply_on_copy","status":"running","started":"…","limit_s":600,"progress":"migration 31/47 2026-10-14-fr162-mapping-index.sql","last_progress_at":"…"}],
 "db":{"current":"pgdata","copy":"pgdata-next"},"prev_images":["guidant-ai-be:1.24.1","…"],
 "result":null,"error":null,"rolled_back_at":null}

欄位只增不改名;維護頁、更新中心、開機檢查、診斷包四邊都讀這一份。

開機檢查:guidant-upgrade-boot-check.service(oneshot,After=docker.service)跑 guidantai upgrade --boot-check:讀進度檔,status=running 且步 3~6 → 呼叫 rollback;其他狀態不動。guidantai recover 走同一支 rollback,給 docker 當掉自動退回沒跑的情況。

5.2 資料不掉:複本、退回、換接(P1-4/P1-6/P1-7)

  • 服務停掉(步 3)到換接(步 6)之間沒有任何寫入進現在那份;退回=刪 pgdata-next、以 GUIDANT_PGDATA_DIR=pgdata 起舊映像、健康檢查。沒有還原動作。
  • 換接後 pgdata-prev-<舊版> 是升級前快照,只給災難救援、不是回滾(D14);下次升級成功時清掉。
  • 證據檔(SeaweedFS)不複製:升級過程禁止改寫既有檔,守衛是 migration 與 init image 不掛 SeaweedFS 卷;真要改檔的版本標成特殊版另案。
  • 設定檔與憑證目錄步 3 一起 cp -a,退回時一起換回。
  • 進行中的背景工作不會被中斷:步 2 等它做完才停服務(D19)。
  • Agent 送不出的結果:FR-166 的 outbox 已在(core/enroll.py 每次心跳 outbox.drain()),本案不另做。

5.3 停接新工作(P1-3b/P1-3a)

旗標檔 <資料目錄>/upgrade/draining(主系統唯讀掛載看得到)。core/maintenance_gate.py 一個函式 is_draining();三個消費者:worker 撿單前判一次、core/plugins/remote_agent.py 把它接進 jedi-remote-agent 的 dispatch_gate port(心跳不帶 pending 任務、結果照收)、更新中心 status 回 draining: true 給前端顯示「正在等 N 件做完」。不新開端點、不改任務狀態機。

5.4 更新站(P2)

Repo update-center(新;本機 ~/Projects/Billows/Audit-Manager/update-center、遠端 gitlab.com/auditmanager/update-center、branch main;.claude/workshop.toml 登記 update-center)。TypeScript Worker(wrangler 4,Node 22;決策者 2026-10-29 選 TS,Python Worker 仍是測試版)、D1(SQLite)六張表、R2 一個 bucket。發布 CLI 同 repo 用 TS 寫(Node 內建 Ed25519 與 PKCS8 加密私鑰),一個 repo 一套工具鏈。

Cloudflare 既有資源(決策者帳號,2026-10-29 確認):帳號 ID 6b1da966062f9093782aefa99c1e68d1;R2 bucket guidant-ai-release 已建好(S3 端點 https://6b1da966062f9093782aefa99c1e68d1.r2.cloudflarestorage.com/guidant-ai-release)。Worker、D1、Access 由 P2-1/P2-2 的 wrangler.toml 宣告、首腦部署時建。開發期用 Cloudflare 自動給的 *.raymond-jedicotech.workers.dev,正式網域出貨前再定(P5-5 檢查項)。API 權杖(D1/Workers/R2/Access 編輯)與 R2 S3 金鑰已由決策者備妥、權限已驗,放 update-center/.env(不入版控)。後台 Access 政策只放行決策者信箱(值在 wrangler.toml 的 ACCESS_ALLOWED_EMAILS 變數,首腦部署時填;信箱不是祕密但不進文件)。

bucket 內一個 bucket 不分、物件 key 分主版號資料夾:v<主版號>/<完整版號>/guidant-ai-<完整版號>.tar.gz+同目錄 release.json、.sha256,例如 v1/1.27.0/guidant-ai-1.27.0.tar.gz、v2/2.0.0/…。理由:主版號是授權邊界(D7),同一層資料夾就是「這把授權拿得到的全部」,後台與人工查檔都一眼分清;CRA 十年保留(D29)要整個 v1/ 一起設保留規則。key 只在更新站內部拼(download-url 回預簽網址),客戶端與 CI 腳本都不寫死路徑:CI 把包交給 register-draft 時只給版號,上傳目標 key 由 Worker 算好回給它。換主版號不用改任何客戶端程式。

端點 誰打 驗什麼 回什麼
POST /v1/check 客戶主系統每 6 小時與啟動時 body 帶本機授權檔(授權中心簽的,kid 查公鑰表 WebCrypto 驗)、機器指紋、現行版號 合法安裝 ∧ 不在拒絕名單 ∧ 主版號相同 ∧ 版號>現行 → 已發布清單(決策者簽過的 release.json+簽章+kid)與必經路徑 path[];對該指紋開了草稿的也回;否則 none。順手寫 instance_version_reports
POST /v1/download-url 客戶主系統按下載後 同上+目標版號 R2 預簽網址 15 分鐘;寫 release_download_log
POST /v1/report 客戶主系統升級結束 同上+結果 204
POST /admin/releases/register-draft CI(scoped token) token 只能到這 建草稿列
POST /admin/releases/<v>/publish guidant-release CLI(決策者 Access 身分) 帶簽好的清單、kid;內部先裝全成功 狀態 → published
POST /admin/releases/<v>/{withdraw,internal-targets}、/admin/denylist、GET /admin/keys 後台頁(Cloudflare Access) Access JWT —

表:product_releases(version、major、min_base_version、sha256、size、r2_key(v<major>/<version>/…,Worker 填)、status draft/published/withdrawn、internal_fingerprints[]、has_security_fix、signed_manifest、kid、retain_until)、instance_version_reports(license_id、fingerprint、current_version、last_seen、last_upgrade_result;無客戶名稱)、release_download_log、license_denylist(license_id、reason、added_by、at)、signing_keys(kid、pubkey、status primary/backup/revoked)、ci_tokens。

客戶端信任(P3-2b package_verifier.py):只信 /etc/guidant/release-keys/*.pub;清單簽章驗過 → issued_at+90d 未過(D31)→ version > 現行 → major 相同 → 下載後 sha256 等於清單。更新站回什麼都不信任。

發布 CLI(P2-3,決策者電腦):guidant-release init-keys(產 primary+backup,私鑰檔 passphrase 加密 600,印備用封存說明)、publish <版>(取草稿、核 internal_fingerprints 全部回報成功、填 issued_at、蓋章、送回)、withdraw <版>、rotate-key(新鑰進包內 release-keys/、清單用舊鑰與新鑰各簽;舊鑰再簽一版後作廢)。passphrase 由決策者輸入,不進檔、不進 log、不給任何 session。

5.5 更新中心(P3)

BE 模組 product_update(DDD 四層;照 app/source_scan_vulndb 的 settings service 形狀)。能力點 system.product-update.read(看)/update(下載、立即、排程、取消、重試、上傳、Agent 區)。

端點 做什麼
GET /system/product-update/status 目前版本、最近檢查、可用新版(含 path)、下載狀態、就緒、預檢結果、draining、進度檔內容、Agent rollout 摘要
POST …/check 立即檢查(打更新站)
POST …/download/DELETE …/download 開始背景下載(續傳)/取消
POST …/uploads、PUT …/uploads/<id>/chunks/<n>、POST …/uploads/<id>/complete 離線上傳(向原廠索取單一離線檔,一次上傳),照 vulndb chunk 三端點
POST …/apply `{mode: now scheduled, at}`
DELETE …/apply 服務停掉前取消:刪更新單
POST …/retry 退回後重試:從預檢重跑,不重下載
POST …/diag 寫 type=diag 更新單,主機產診斷包;GET …/diag/<id> 下載
GET …/history product_update_jobs 分頁
POST …/agents/{pause,resume,push} Agent 跟進(P4-2)

更新單(P3-2c):<資料目錄>/upgrade/orders/<job_id>.json {job_id, version|path[], mode, not_before, requested_by, type: upgrade|diag}。主系統容器對 upgrade/orders/ 只有寫權(compose 掛 :rw 但目錄 chmod 730、chown root:guidant),對 upgrade/ 其餘唯讀。主機 guidant-upgrade-order.path(PathChanged=)觸發 guidant-upgrade-order.service → guidantai upgrade --from-order <檔>:立即就跑;排程則 systemd-run --on-calendar 排一次性 timer。執行前重驗包與簽章(過期或被刪就寫 skipped 並通知)。

每日檢查(P3-2b):core/scheduler_update.py 每 6 小時一次(CHECK_INTERVAL_HOURS),主系統啟動時另跑一次,update_site_client.check();連不到安靜略過、status 顯示「最近一次檢查失敗」;關閉開關存系統設定(唯一開關,D10)。

畫面狀態:status 回應把「單已寫、時間已到、主機還沒為這張單寫進度檔」換成 submitted(已送單等主機),只換回應、DB 仍是 scheduled,取消與重複按的擋法照舊認它。失敗終態三種:skipped(這次沒開始,系統沒動)、rolled_back(已自動退回)、進度檔 result=rollback_failed(退回也失敗,畫面紅色並顯示 sudo guidantai recover、停止輪詢)。

資料表 product_update_jobs:from/to/path/status(checking/downloading/ready/scheduled/running/succeeded/rolled_back/skipped/cancelled)/downloaded_by(login+nickname+at)/applied_by/cancelled_by/source(online/offline)/not_before/started/ended/error/job_id。結束後從進度檔同步結果。審計欄位照規範回 *_name。

FE(P3-3、P3-4):views/product-update/UpdateCenter.vue(照 VulndbManage.vue 版面)、UpdateHistory.vue、ReadyDialog.vue(立即/排程)、NewVersionBanner.vue;AgentRolloutPanel.vue(P4-4)。沒有 update 能力點只看不到按鈕。分段用 SegmentButtons、不自訂字級、無 border-left。

5.6 版本與授權(D7/D27)

  • 版本只有一種;release.json 多一個 has_security_fix 讓更新說明多「影響範圍」「建議動作」兩段,其餘行為一樣。
  • 授權檔 product_major(P2-5,缺欄視為 1)。更新站 check 只比主版號、不看 expires_at。產品內授權過期的行為(唯讀、模組鎖)不變,本案不碰 common/authz/license.py。
  • CRA 文件(P5-1)照 D27 寫;CRA 兩題決策者裁示先不管,之後有答案只影響 P5-1 措辭。

5.7 Agent 跟進(P4)

  • 是否自動跟進由管理員在「更新到 X」確認視窗決定(勾選「主系統更新完成後,接著更新 Agent」,預設勾);不勾則 Agent 與主系統分開,管理員之後在 Agent 區自己按(立即、排程,或稍後提醒 7 天)。「必須更新」標示只在新版 min_agent_version 高於該台現行版時出現。
  • 勾選時,主系統開放使用後 agent_rollout_service 建 agent_update_rollouts(agent_id、target_version、batch、status pending/dispatched/succeeded/failed/paused、source following/manual/agent-only):batch 1 一台(最近心跳最新的那台);成功 → 其餘 batch 2 全部 dispatched;失敗 → 其餘 paused。
  • 心跳:_agent_update.py 的 update_extra 只對 dispatched 的 Agent 回 {target_version, sha256, size};Agent 回 upgrade_result 推進狀態。
  • 主動通知:下發列轉成 dispatched 且交易提交後,主系統背景呼叫 Agent 的 POST /update/notify(同一道 mTLS+綁 op 短效 JWT,5 秒逾時)叫它立刻心跳;連不到只記 warning、退回等下一次心跳,不擋推送 API 與排程。版本約束:Agent 主線版號 ≤ 主系統現行版,否則不派。
  • Agent 側 core/update_sync.py(照 vulndb_sync.py):下載 GET /agents/update/bundle(資料面 JWT、續傳、驗 sha)→ 寫 agent-upgrade-order.json → 主機 guidant-agent-upgrade-order.path 觸發 install.sh upgrade <包>(P4-5 加時限、進度檔、自動退回)→ 結果隨心跳回。Agent 不連外網、不連更新站、不多常駐程式。
  • 只升 Agent:主系統已升、某幾台落後 → 更新中心「推選定」建 manual rollout;Agent 單獨修正版(更新站 release.json 標 agent_only)→ 主系統下載存著不升自己,建 agent-only rollout。第一版先做前者,後者欄位先留。

5.8 安全規則對照

規則 怎麼守
對外服務無私鑰 更新站只有公鑰表與 R2 讀權;私鑰只在決策者電腦(P2-3)
客戶端不信任更新站 清單簽章、kid、90 天、版號遞增、主版號、sha256 五道(P3-2b)
主系統不碰 docker 容器無 docker socket;只寫更新單;目錄只有寫權(P3-2c)
主機不多門 不開新埠、不多常駐程式;systemd path/timer/oneshot 三個單元都是本機事件觸發(P3-2c、P1-7)
上傳解析 離線上傳 chunk 大小上限、完成時先驗簽章再解包、驗不過即刪(P3-2b;照 .claude/rules/security/upload.md)
祕密 CI 的 R2/更新站 token 走 CI 變數不入 repo;passphrase 不進檔;診斷包遮罩(P2-4、P2-3、P1-10)
log 進度檔與診斷包不含密碼與金鑰(grep 守衛)
只回報必要資訊 check 只送授權編號、指紋、版號、結果(P2-1)

6. 拆分(35 張實作卡、8 批)

批(Stage)=工作坊同時開幾個小弟一起跑的那一組:同一批不改同一個既有檔、不依賴對方產出、不搶同一外部資源。階段(P1~P5)只是分組標籤。底表在 handoff/cards.py,兩個斷言已過:依賴都在更早的批;同批同 repo 無兩張卡改同一既有檔。

登記型檔先佔位:scripts/installer/guidantai 與 install.sh 由 P1-1 一次開好 lib/ 全部步驟空殼與子命令派送,後面十張 P1 卡只填自己的 lib 檔;config/app_modules.py 與 di_containers/ 由 P3-2a 一次登記;evidence-agent core/enroll.py 只有 P4-3 碰;core/plugins/remote_agent.py 兩張卡各加一行但在不同批(P1-3b 批 3、P4-2 批 6)。

repo 分布:BE 23、FE 4、update-center(新) 3、evidence-agent 2、jedi 套件 1、License Center 1、test 1。環境卡 E-1(批 2):P1-3a 改完心跳契約直接發 beta 版(1.10.0b1,決策者授權小弟在工作坊可 push 自己的 ws branch、套件可發 beta),E-1 把主 repo 釘到 beta 並只 poetry update jedi-remote-agent;結案卡 P5-5 發正式版、改釘正式版。開發期與測試期分開:每張卡的「驗收」只寫小弟在本機/DEV 8003/本機 docker/wrangler dev 就驗得完的事,工作坊無人時不會卡;要 190、pipeline、真 Cloudflare、PROD 印樣的情境寫在「測試期(190)」欄,由首腦操作、決策者看畫面(§7)。套件異動(發版前統一確認):jedi-remote-agent(心跳契約兩個 port);License Center(授權檔加 product_major)。新 repo update-center(已建):開卡時登記 .claude/workshop.toml 與 Notion「Repo」選項。

批 階段 卡 repo reuse/extend/new+理由 改哪些既有檔 新開哪些檔 依賴 驗收(開發期,小弟本機就能驗) 測試期(190,首腦操作)
1 P1 P1-1 升級骨架:guidantai 加 upgrade/recover 子命令、先換成新版的自己(D30)、lib/ 步驟空殼、進度檔讀寫、每步時限框架 BE extend:guidantai 派送段加兩個子命令、install.sh 的 run_upgrade 改成逐步呼叫 lib/upgrade_runner.sh,兩支都超標只准一行呼叫。登記型檔集中:這張卡一次開好 scripts/installer/lib/ 下所有步驟檔的空殼(preflight/drain/db_copy/apply_on_copy/health/switch/rollback/boot_check/cleanup/order/release_keys/systemd_units),後面的卡只填自己的檔 scripts/installer/guidantai 1562(派送兩行、source lib)、scripts/installer/install.sh 3021(run_upgrade 改呼叫 runner;install_ops_cli 連 lib/ 一起裝) scripts/installer/lib/upgrade_runner.sh(step/timeout/寫進度)、scripts/installer/lib/progress.sh(upgrade-progress.json 讀寫,只有這裡寫)、scripts/installer/lib/self_replace.sh(D30:從已驗包取 guidantai+lib 換掉 /usr/local/bin 後 exec)、scripts/installer/lib/upgrade_{preflight,drain,db_copy,apply_on_copy,health,switch,rollback,boot_check,cleanup,order}.sh(空殼)、scripts/installer/lib/{release_keys,systemd_units}.sh(空殼) — lib/ 每支 bash -n+shellcheck 綠;bats:self_replace 對一個假包目錄能換掉 --root 指定的暫存 bin 目錄並 exec、簽章驗不過時暫存目錄一個位元組都沒變;runner 對一支 sleep 5 的假步驟給 2 秒時限 → 非零、進度檔 status=timeout;進度檔格式照 design §5.1(jq 驗欄位);install.sh 與 guidantai 行數不增加 190:sudo guidantai upgrade <包> 先把自己換成包內版本(guidantai --version 是新版)再往下跑
1 P1 P1-3a jedi-remote-agent 心跳契約一次改完:dispatch_gate port(停派新任務)、下發 target_version 與 Agent 包資訊、收 Agent 升級結果欄 jedi 套件 extend:RemoteAgentAdapters 加 dispatch_gate(None=照常)與 update_extra;心跳收 upgrade_result 照 heartbeat_console.py 白名單收法;不動任務邏輯 jedi_remote_agent/app/service/agent_enrollment_service.py(心跳回應組裝、收欄位)、jedi_remote_agent/plugin/adapters.py(兩個 port) — dispatch_gate 回 False 時心跳不帶 pending 任務但照收結果(test);回應含 update: {target_version, bundle_sha256, size} 或 null;收到 upgrade_result 寫 remote_agents.last_upgrade_*;既有 tests 綠;版號 bump 到 1.10.0b1 並 poetry publish -r nexus(決策者授權發 beta),回寫寫明版號 —
1 P1 P1-8 前門維護頁:nginx 後端連不上改送 maintenance.html,頁面每 3 秒讀唯讀掛進來的進度檔畫步驟與時限(雛形 ②) FE extend:nginx.onprem.conf 加 error_page 502 503 =503 /maintenance.html 與 /upgrade-progress.json alias;頁面純 HTML+少量 JS,不用框架、不接 API nginx.onprem.conf public/maintenance.html — 停掉 api 容器打首頁得 503 與維護頁;放一份假進度檔,頁面每 3 秒更新步驟、耗時、剩餘時限;沒有任何按鈕;grep border-left 0;e2e conf 不變 —
1 P1 P1-9a 出包:產未簽的 release.json(版號、主版號、雜湊、大小、可直升起始版、是否含安全修正)、可直升起始版由 migration 必經標記自動算、包內帶 Agent compose 交付包 BE extend:build_bundle.sh 的 bundle-info 段加 release.json;必經標記讀 scripts/sql/manifest.tsv 新欄(D17 三種情況由人標、腳本算) scripts/build/build_bundle.sh 408–430、scripts/sql/manifest.tsv(加 min_base 欄說明列) scripts/build/release_json.sh — 本機對一個假的 bundle 目錄跑 release_json.sh:欄位齊、sha256 與 .sha256 檔一致;manifest.tsv 標一支必經後 min_base_version 變那版、沒標時是 1.0.0;bundle-info.txt 的「1.0.0 以上」改成算出來的值;shellcheck 綠。不在 188 出包 188 跑 build_bundle 後包內有 release.json,sha256 與 .sha256 檔一致,包內有 Agent compose 交付包
1 P2 P2-1 更新站 repo 骨架:TypeScript Worker(wrangler 4)、D1 六張表、客戶端三端點(check/download-url/report)、用授權中心公鑰表驗授權檔(WebCrypto)、主版號與拒絕名單判定、R2 物件 key 分主版號資料夾(v//…)、真授權檔守衛測試 update-center(新) new(新 repo,TS):模組分法照 License Center(routes/service/repo);驗章用 WebCrypto 原生 Ed25519;授權檔格式與 kid 機制 reuse jedi-license-runtime public_keys.py 的定義(公鑰表複製成 TS 常數,守衛測試用授權中心真簽的檔對照);型別用 @cloudflare/workers-types;測試用 vitest+@cloudflare/vitest-pool-workers update-center/(package.json、tsconfig.json、wrangler.toml 綁既有 bucket guidant-ai-release與 D1、src/index.ts、src/routes/client.ts、src/releases.ts(含 r2_key 拼法v//<檔名>,只在這裡拼)、src/licenses.ts、src/denylist.ts、migrations/0001_init.sql、test/、.env.example、README.md) — 本機 wrangler dev:帶 DEV 真授權檔打 check 回已發布清單+簽章;過期授權檔照回;主版號不同回 none;拒絕名單回 403;偽造授權檔 401;download-url 回 R2 簽名網址 15 分有效;report 寫 instance_version_reports 不含客戶名;守衛測試用授權中心真簽出來的檔,驗不過擋部署 —
1 P2 P2-5 授權檔 payload 加 product_major(預設 1);簽發畫面與 CLI 帶欄位 License Center extend:modules/licensing 的 payload 組裝加一欄;既有照不補簽,更新站把缺欄視為 1 src/license_center/modules/licensing/service.py、src/license_center/modules/licensing/models.py、alembic migration — 新簽的照 payload 有 product_major: 1;舊照驗章照過;LC 畫面顯示欄位 —
1 P3 P3-1 能力點 system.product-update.read/update、更新中心選單路由、預設角色授權(照 FR-134 vulndb 頁 migration) BE extend:host_capabilities.py 加兩顆;migration 照 2026-10-10-fr134-vulndb-settings-page.sql 形狀 core/host_capabilities.py 199 附近 scripts/sql/2026-xx-xx-fr167-product-update-capabilities.sql — DEV 套後 capabilities 有兩顆、ui_routes 有 product-update、系統管理員角色帶 update;FR-132 守衛測試綠;回報「出貨基線待重產」 —
2 P1 E-1 環境卡:主 repo 改釘 jedi-remote-agent beta 版(P1-3a 發的 1.10.0b1),poetry update 只指定這支 BE reuse:poetry update jedi-remote-agent 指定套件名(不裸跑);不再走 path 安裝與 assume-unchanged,因為小弟已可發 beta pyproject.toml(釘 jedi-remote-agent==1.10.0b1)、poetry.lock P1-3a pip show jedi-remote-agent 版本 1.10.0b1、Location 在 .venv;python -c 'from jedi_remote_agent.plugin.adapters import RemoteAgentAdapters;print(RemoteAgentAdapters.__dataclass_fields__.keys())' 看得到 dispatch_gate;BE 8003 起得來;兩檔都 commit —
2 P1 P1-2 預檢:空間(包+複本+映像+保留)、版本路徑對 release.json 的可直升起始版、資料庫連得上、驗 release.json 簽章與包雜湊、可清舊映像提示 BE extend:upgrade_check_preconditions 既有檢查搬進 lib/upgrade_preflight.sh 再加項;簽章驗證用 openssl pkeyutl Ed25519 對 /etc/guidant/release-keys/<kid>.pub scripts/installer/lib/upgrade_preflight.sh(P1-1 空殼) P1-1、P1-9a bats 用假的 df 與假包:空間不足印需要多少與可清多少、回非零;min_base_version 高於現行版時印路徑並拒絕;改一位元組的 release.json 用 openssl pkeyutl 驗章失敗;全過時進度檔 preflight=ok 含停機預估;shellcheck 綠 190:空間不足印需要多少與可清多少、回非零不動;從 1.25.1 升需要必經 1.26.0 時印路徑並拒絕
2 P1 P1-3c 停接新工作、等做完:寫 draining 旗標檔、psql 輪詢進行中數、30 分提醒寫進度檔、時段結束放棄並清旗標(D19) BE extend:填 lib/upgrade_drain.sh;計數用 psql 直查 background_jobs 與 Agent 任務表,不另開端點 scripts/installer/lib/upgrade_drain.sh(P1-1 空殼) P1-1 對 DEV 庫(localhost:5432):塞一筆 running 的 background_jobs 時不往下走;--deadline 到了回特定碼、旗標檔已刪;0 筆立刻過;30 分提醒用 --notice-after 3 秒縮時驗;shellcheck 綠 —
2 P2 P2-2 更新站後台:Cloudflare Access 守門的管理端點(發行版列表/撤回/指定內部先裝/拒絕名單/印章表)與簡單後台頁(雛形 ⑦);CI 專用 token 只能登記草稿 update-center(新) extend:同 repo 加 admin 路由(TS);後台頁純 HTML+少量 JS,不用框架 update-center/src/index.ts(掛 admin 路由) update-center/src/routes/admin.ts、update-center/public/admin.html P2-1 沒過 Access 的請求 403;CI token 打 register-draft 成功、打 publish 403;指定 STG 指紋先裝後,用 STG 授權檔打 check 看得到草稿、用別家看不到;撤回後 check 不再回 —
2 P2 P2-3 發布 CLI guidant-release(決策者電腦):init-keys 一正一備(passphrase)、publish <版>(取草稿、核內部先裝結果、蓋章、送回)、withdraw、rotate-key(舊鑰背書新鑰) update-center(新) new(CLI,TS,同 repo cli/,Node 22 內建 node:crypto):Ed25519 簽章封包格式與 kid 規則照授權中心同一套,不另創;私鑰檔用 PKCS8+AES-256 passphrase 加密、600;passphrase 從 TTY 讀,不收參數不讀 env update-center/cli/guidant-release.ts(bin:guidant-release)、update-center/cli/README.md P2-1 init-keys 產兩組、備用印出封存說明、私鑰檔 600;publish 1.28.0 在內部先裝未全成功時拒絕;成功後更新站狀態變已發布且簽章可用 .pub 驗過;rotate-key 產的新清單用舊鑰與新鑰各簽一份;passphrase 不進任何檔與 log —
2 P3 P3-2a 主系統更新模組骨架:product_update_jobs 表、domain/infra/app service、狀態與紀錄端點(讀進度檔+表)、模組登記、DI BE new(新模組)照 app/source_scan_vulndb 的 settings service 形狀;讀進度檔用唯讀掛載;登記型檔集中:config/app_modules.py 與 di_containers/ 這張卡一次登記完 config/app_modules.py、di_containers/__init__.py(或對應容器) scripts/sql/2026-xx-xx-fr167-product-update-jobs.sql、domain/product_update/*、infra/product_update/*、app/product_update/service/product_update_app_service.py、api/product_update/routes/product_update_route.py、api/product_update/serializers/* P3-1 DEV 套 migration 後表在、RLS 四指令;GET /system/product-update/status 回目前版本、最近檢查、進度檔內容;GET .../history 分頁;無能力點 403;守衛測試綠 —
3 P1 P1-3b 主系統接 draining:core/maintenance_gate 讀旗標檔、worker 不撿新單、心跳不派新任務(接 P1-3a 的 dispatch_gate) BE new(30 行)+extend:worker 撿單處加一個判斷;core/plugins/remote_agent.py 接 port 一行 core/plugins/remote_agent.py(一行)、app/background_job/(撿單處一個判斷) core/maintenance_gate.py E-1、P1-3a 放旗標檔後:worker 不撿 queued 單(既有 running 照跑完)、Agent 心跳回應不帶 pending 任務但結果照收;拿掉旗標 10 秒內恢復;test 兩條 —
3 P1 P1-4 停服務、複製資料庫到複本、讓升級接複本跑:lib/upgrade_db_copy、compose 資料庫目錄改吃變數、fe 掛進度檔唯讀 BE extend:docker-compose.yml 的 pgdata 路徑改 ${GUIDANT_PGDATA_DIR:-…},升級時以環境變數指到複本;複製用 cp -a,時限=預估三倍;證據檔不複製(D18) scripts/installer/lib/upgrade_db_copy.sh(P1-1 空殼)、docker/production/docker-compose.yml(pgdata 變數、fe 掛 upgrade/ 唯讀) P1-1 本機 docker:對一個假 pgdata 目錄跑 db_copy → pgdata-next/ 存在、來源 mtime 不變;複製中 kill -9 重跑能清掉半套複本;時限 1 秒對大目錄 → 放棄、來源不動;docker compose -f docker/production/docker-compose.yml config 用 GUIDANT_PGDATA_DIR 指到複本目錄、fe 的 upgrade/ 掛載是 :ro 190:跑完 pgdata-next/ 存在且舊目錄 mtime 不變;複製中 kill -9 重跑能清半套複本;時限到自動放棄回舊版
3 P1 P1-5 在複本上升級:載映像驗雜湊、init image migrate 模式對複本、migrate.sh 每支印進度行、runner 轉寫進度檔、單支 10 分無進度自動判卡 BE extend:migrate.sh 每支 printf 'PROGRESS\t%d/%d\t%s'(它不寫進度檔,只印);runner tail 轉寫 scripts/installer/lib/upgrade_apply_on_copy.sh(P1-1 空殼)、scripts/init/migrate.sh 298 P1-1 本機 docker postgres:migrate.sh 每支印 PROGRESS\t%d/%d\t%s(既有輸出不變);runner 把它轉成進度檔 migration 3/5 <檔名>;塞一支 pg_sleep(30) 的測試 migration、--stall-timeout 10 秒 → 判卡回非零;載映像用假 tar 驗雜湊不符就拒 190:真包真庫,進度檔逐支更新;塞 pg_sleep(700) 的 migration 10 分後判卡
3 P2 P2-4 CI 出包後登記草稿:先打更新站 register-draft 拿到 R2 目標 key,再上傳包、release.json、.sha256(CI token;bucket guidant-ai-release) BE extend:package job 加一步;R2 上傳用 aws cli S3 相容端點(CI 變數 R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/UPDATE_SITE_CI_TOKEN,不入 repo);上傳 key 由更新站回,腳本不自己拼主版號資料夾 .gitlab-ci.yml package job scripts/build/publish_draft.sh P1-9a、P2-2 glab ci lint 綠;publish_draft.sh 對本機 wrangler dev 與假 R2(--r2-endpoint 指向本機 MinIO 或 wrangler dev 的 r2 模擬)跑通;沒給 token 時立刻紅、訊息說缺哪個 CI 變數;repo 內 grep 不到任何 token。不跑 pipeline 跑一次 tag pipeline:R2 有 releases/<版>/ 兩檔、更新站後台出現草稿;token 缺時 job 紅且訊息清楚
3 P2 P2-6 發布印樣進產品:DEV 公鑰檔放 scripts/installer/release-keys/、install 與 upgrade 都裝到 /etc/guidant/release-keys/、換鑰時新印樣隨包進來(PROD 印樣與缺鑰守門留給 P5-5) BE extend:填 lib/release_keys.sh;有什麼 .pub 就裝什麼;「PROD 鑰缺就紅」的守門不在這張做(決策者尚未 init-keys),P5-5 補 scripts/installer/lib/release_keys.sh(P1-1 空殼) scripts/installer/release-keys/<dev-kid>.pub(用 P2-3 的 CLI 產一組 DEV 鑰,私鑰留本機 600、不入 repo) P1-1、P2-3 bats:對暫存 root 跑 release_keys.sh → 目錄有檔、權限 644;包內多一把新 kid 時裝完多一把、舊的保留;私鑰 grep 不到進 repo;shellcheck 綠 190:新裝後 /etc/guidant/release-keys/ 有檔 644;升級包帶新 kid 時裝完多一把、舊的保留
3 P3 P3-2b 發現新版與下載:更新站 client、每日排程(抽 core/scheduler_update.py)、立即檢查端點、通知管理員、背景下載續傳、驗簽章與雜湊、取消下載、離線上傳(chunked 照 vulndb) BE extend:排程照 scheduler_agent.py 形狀新檔、scheduler.py 一行;上傳 reuse vulndb 的 chunk 三端點做法;通知 reuse notification_service;驗章 reuse jedi-license-runtime public_keys 的 kid 查表+/etc/guidant/release-keys core/scheduler.py 714(一行)、app/product_update/service/product_update_app_service.py、api/product_update/routes/product_update_route.py core/scheduler_update.py、app/product_update/service/update_site_client.py、app/product_update/service/package_downloader.py、app/product_update/service/package_verifier.py P3-2a DEV 指向本機 wrangler dev:每日 03:00 與「立即檢查」都寫 last_check、有新版發通知;下載中斷重啟續傳;改一位元組驗章失敗並刪檔;離線上傳同一條驗證路;過期清單(issued_at+90d)拒收;版號 ≤ 現行拒收 —
3 P3 P3-3 更新中心頁:目前版本、最新版本、版本說明、預檢、下載、下載中、已就緒(立即/排程/取消)、離線上傳、更新紀錄、失敗原因(雛形 ①、①b、①c、③、⑥) FE reuse:頁面結構照 views/vulndb/VulndbManage.vue+VulndbUpdateHistory.vue;上傳元件 reuse vulndb 的 chunk 上傳;api.js 加 PRODUCT_UPDATE_*;能力點進 capabilities.js src/config/api/api.js、src/config/capabilities.js、src/config/router/index.js、i18n src/views/product-update/UpdateCenter.vue、src/views/product-update/UpdateHistory.vue、src/views/product-update/ReadyDialog.vue P3-2a 雛形 ①~③、⑥ 逐頁對照實打;沒有 update 能力點只看不到按鈕;grep border-left 0;分段用 SegmentButtons;不自訂字級 —
4 P1 P1-6 啟動新版、健康檢查、開放使用:新版接複本起、180 秒+登入與主要 API 實打、換接(複本轉正、舊份留作升級前快照)、前門最後才換 BE extend:health reuse 既有 180 秒 healthcheck 加實打;switch 把現有 upgrade_switch_version 改成「先換 api/worker、最後 fe」 scripts/installer/lib/upgrade_health.sh(P1-1 空殼)、scripts/installer/lib/upgrade_switch.sh(P1-1 空殼) P1-4 本機 docker:health 對一個回 500 的假 api 容器 → 非零不換接;對回 200 的假容器 → 過;switch 對假目錄:複本轉正、原份改名 pgdata-prev-<版> 且 mtime 不變;compose 啟動順序 api/worker 先、fe 最後(看 runner 寫的進度檔步驟時間);shellcheck 綠 190:故意讓登入 API 壞掉 → 不換接;換接後 pgdata/ 是原複本、pgdata-prev-<舊版>/ 是原本那份且 mtime 不變;維護頁撐到 fe 重建前一刻
4 P3 P3-2c 更新單:立即/排程/取消/重試端點寫 upgrade-order.json;systemd path+service 單元隨 install 裝;guidantai upgrade --from-order 讀單;排程用 systemd-run 到點;執行前重驗包;30 分前與 5 分前提醒寫給所有使用者 BE extend:端點進 P3-2a 的 service;主機側填 lib/upgrade_order.sh 與 lib/systemd_units.sh;提醒 reuse 既有公告或 toast 機制(先 grep 公告模組) app/product_update/service/product_update_app_service.py、api/product_update/routes/product_update_route.py、scripts/installer/lib/upgrade_order.sh(P1-1 空殼)、scripts/installer/lib/systemd_units.sh(P1-1 空殼) scripts/installer/systemd/guidant-upgrade-order.path、scripts/installer/systemd/guidant-upgrade-order.service P3-2a、P1-1 DEV 8003:四個端點寫/刪 upgrade/orders/*.json,schema 照 design §5.5;排程單帶 not_before;upgrade_order.sh --dry-run <單> 讀單、重驗包(假包改一位元組 → 拒)、對照序列;systemd 單元檔用 systemd-analyze verify(在本機 docker ubuntu 容器內跑)綠;提醒走既有公告機制(寫哪支、回寫列);shellcheck 綠 190:按立即→5 秒內 path 單元觸發;選排程到點才跑;取消→單刪除;單上包驗不過→不跑並通知;容器對 upgrade/orders/ 只有寫權
4 P3 P3-4 有新版橫幅(儀表板,只給系統管理員)、更新前 30 分與 5 分全站提醒、「這版先不提醒」(雛形 ⓪) FE reuse:全站橫幅用既有公告/toast 元件;狀態來自 P3-2a 的 status 端點 src/layout/(頂欄或橫幅掛點)、i18n src/components/product-update/NewVersionBanner.vue P3-2a 雛形 ⓪;按「這版先不提醒」後同版不再跳、新版再跳;排程前 30 分所有登入者看得到提醒 —
5 P1 P1-10 收尾與診斷:保留上一版映像、更新包保留到下次成功、清掉更早的;diag 併進度檔與更新單並遮罩;下載診斷包走更新單(type=diag) BE extend:填 lib/upgrade_cleanup.sh;cmd_diag 收集清單加兩個檔(guidantai 超標,只加路徑列) scripts/installer/lib/upgrade_cleanup.sh(P1-1 空殼)、scripts/installer/guidantai(cmd_diag 收集清單兩行) P1-6 本機 docker:三個版本的假映像跑 cleanup 只剩兩個;更新包保留規則(最新成功那包留、更早刪)用假檔驗;cmd_diag 收集清單含進度檔與更新單且 grep 守衛抓不到密碼字樣;shellcheck 綠 190:升級成功兩次後只剩新版與上一版映像;更新單 type=diag 觸發後 upgrade/diag/ 出現 zip
5 P1 P1-7 自動退回、開機檢查、guidantai recover:丟棄複本、改接原份、啟動保留的舊映像、健康檢查;systemd oneshot 開機讀進度檔;recover 走同一支 BE extend:填 lib/upgrade_rollback.sh、lib/upgrade_boot_check.sh;recover 子命令(P1-1 已派送)呼叫同一支 rollback scripts/installer/lib/upgrade_rollback.sh(P1-1 空殼)、scripts/installer/lib/upgrade_boot_check.sh(P1-1 空殼) scripts/installer/systemd/guidant-upgrade-boot-check.service P1-4、P1-6 本機 docker:進度檔停在任一未完成步驟時跑 boot_check → 呼叫 rollback:複本刪、原份接回、假的舊版容器起、進度檔 rolled_back_at 有值;進度檔 succeeded 時 boot_check 什麼都不做;guidantai recover 走同一支(diff 兩條路徑呼叫同一函式);shellcheck 綠 190:第 ⑦ 步 kill -9 整個流程 → 開機單元自動退回、舊版起來、筆數一致;systemctl stop docker 後 sudo guidantai recover 收尾
5 P3 P3-2d 跨版兩段:更新站回路徑(必經版本)、下載多包、更新單帶序列、CLI 依序跑每段各自複本與健康檢查、第二段失敗停在中間版(雛形 ⑤) BE extend:check 回應的 path[] 由更新站算;BE 下載迴圈;lib/upgrade_order.sh 讀序列逐段呼叫 runner app/product_update/service/product_update_app_service.py、app/product_update/service/package_downloader.py、scripts/installer/lib/upgrade_order.sh P3-2c、P2-1 DEV 8003 對本機 wrangler dev:更新站標一版必經 → check 回 path[] 兩段、BE 兩包都下、更新單帶序列;upgrade_order.sh --dry-run 逐段呼叫 runner(看 log 兩次);單段情境行為不變(既有 test 綠) 190:1.25.1 裝機、1.26.0 標必經 → 兩包都下、兩段跑完;第二段 kill → 停在 1.26.0 可用
5 P4 P4-5 Agent 升級指令比照主系統:每步時限、進度檔、失敗自動退回(兩份 install.sh 只加一行呼叫,邏輯進 install-lib/) evidence-agent extend:既有 _upgrade_revert、upgrade_guard.sh 收進 install-lib/upgrade_runner.sh;deploy/install.sh 的 cmd_upgrade 委派新包安裝程式照舊(D30 同形) scripts/install/install.sh 1387–1545(一行)、deploy/install.sh 1153(一行) agent/install-lib/upgrade_runner.sh、agent/install-lib/progress.sh — 本機 docker ubuntu 容器:對假包跑 install-lib/upgrade_runner.sh → 每步時限、進度檔與主系統同欄位、失敗步驟自動退回;兩份 install.sh 行數不增;shellcheck 綠 lab 機(決策者指定一台):升級中 kill → 重跑或開機自動退回
6 P1 P1-9b CI 跨版排練:e2e 目標先裝舊版+灌真實舊庫,再用新包 guidantai upgrade;失敗擋 package BE extend:deploy_e2e.sh 既有 e2e 路徑加「先裝指定舊版」參數;舊庫用 smoke_tempdb 既有的 dump 來源 .gitlab-ci.yml、scripts/build/deploy_e2e.sh P1-7 glab ci lint 綠;deploy_e2e.sh --base-version 1.26.0 --dry-run 印出會做的步驟(先裝舊版、灌 dump、再 upgrade)且參數解析有 test;job 的 needs/rules 讓它紅就擋 package(讀 yaml 驗)。不跑 pipeline、不碰 190 pipeline 多一個 rehearse-upgrade job:190 裝 1.26.0 → 新包升級成功、筆數一致;放壞 migration 時 job 紅、package 不跑
6 P4 P4-2 Agent 跟進(主系統側):agent_update_rollouts 表、開放後自動建第一批 1 台、心跳回 target_version+包資訊(core/plugins/_agent_update.py)、Agent 下載端點、收升級結果推進或暫停、更新中心 Agent 區端點(暫停/繼續/推選定) BE extend:照 _agent_vulndb.py 形狀新檔;core/plugins/remote_agent.py 接 update_extra 一行;端點進 P3-2a 模組 core/plugins/remote_agent.py(一行)、app/product_update/service/product_update_app_service.py、api/product_update/routes/product_update_route.py scripts/sql/2026-xx-xx-fr167-agent-update-rollouts.sql、core/plugins/_agent_update.py、app/product_update/service/agent_rollout_service.py、api/remote_agent/routes/agent_update_bundle_route.py P1-3a、P3-2c、P1-9a DEV 8003+pytest:升級完成事件 → rollouts 一筆 batch 1 一台;該台回成功 → 其餘進 batch 2;回失敗 → paused;心跳回 update 只給目標批(用 test client 模擬三台心跳);下載端點走資料面 JWT、回包+sha;無能力點 403;守衛測試綠 三台 Agent:主系統升完先一台成功再其餘;第一台失敗其餘 paused;推選定只對落後的台
7 P4 P4-3 Agent 跟進(Agent 側):心跳 observer 看 target_version、從主系統下載包驗雜湊、寫 agent-upgrade-order.json、主機 systemd path 觸發 install.sh upgrade、結果隨心跳回報 evidence-agent extend:observer 照 vulndb_sync.py 形狀新檔(下載、續傳、驗 sha 都 reuse 它的做法);enroll.py 掛一行;systemd 單元由 P4-5 的 install-lib 裝 core/enroll.py 364(一行)、agent/install-lib/upgrade_runner.sh(裝 path 單元) core/update_sync.py、agent/install-lib/systemd/guidant-agent-upgrade-order.{path,service} P4-2、P4-5 Agent pytest:假心跳帶 update → 下載到暫存、sha 對、寫 agent-upgrade-order.json;sha 不對 → 刪檔回報;主系統 paused(無 update)時不動;observer 掛點一行;systemd 單元檔 systemd-analyze verify 綠(docker ubuntu) lab 機:心跳帶 update → 下載、驗 sha、寫單、path 單元觸發 install.sh upgrade,成功後心跳 agent_version 變新;失敗自動退回回報 failed
7 P4 P4-4 更新中心 Agent 跟進區:批次表、暫停/繼續、推選定、只升 Agent 說明(雛形 ④) FE reuse:表格照主站 DataTable 慣例;接 P4-2 端點 src/views/product-update/UpdateCenter.vue src/views/product-update/AgentRolloutPanel.vue P4-2、P3-3 雛形 ④;第一台失敗後其餘顯示已暫停;推選定只能勾落後的 —
8 P5 P5-1 手冊與安裝文件:升級一章改寫(更新中心、離線上傳、排程、失敗怎麼看、recover、diag)、防火牆放行清單、資安例外登記(對外更新站)、回報出貨基線待重產 BE extend:手冊既有章節;例外登記進 docs/security-report/requirements/ docs/user-manual/、docs/security-report/requirements/、scripts/installer/README.md P4-3、P4-4 章節齊;文件衛生 grep 0;例外登記可查;回寫明講出貨基線待重產 —
8 P5 P5-2 SPEC:更新中心頁新頁(writing-feature-specs) BE new(spec 頁)照既有頁形狀 docs/spec-site/current/system-admin/product-update.md P4-4 頁面齊、變更紀錄一行、mkdocs build 綠 —
8 P5 P5-3 e2e:更新中心頁(有新版→下載→就緒→排程→取消;離線上傳驗不過;無權限只看)對 stub 更新站 test reuse:site-regression 既有 page object;更新站用 wrangler dev stub site-regression features/steps(product-update) P3-3、P3-4 headless 綠(BE 8003+FE dev+wrangler dev stub 由小弟自己起);不實際執行升級;收工前把三個背景服務停掉 —
8 P5 P5-5 結案卡:jedi-remote-agent 發正式版(決策者確認套件清單後)、主 repo 釘正式版、PROD 印樣進 scripts/installer/release-keys/ 並補「PROD 鑰缺就紅」守門、poetry update 指定套件 BE reuse:照 jedi-package-dev skill 發版流程;守門照 build_bundle 既有 --skip-prod-key-check 精神 pyproject.toml、poetry.lock、scripts/build/build_bundle.sh(PROD 印樣缺就紅) scripts/installer/release-keys/<prod-kid>.pub(決策者 init-keys 後交付) P4-3 pyproject.toml 釘正式版且 pip show Location 在 .venv;git status 乾淨;沒 PROD 印樣 build_bundle 紅、有就綠;發版紀錄寫 commit —

7. 端到端驗收

全部在 190 e2e 目標或 DEV 做,不碰 STG/POC。這一節是測試期:開發期卡片全部驗收通過、決策者 push 並出一包之後,由首腦在 190 逐條操作、決策者看畫面;每條對應的卡在 §6 最後一欄。190 會被重灌與重開機多次,測試期間不拿它做別的。

# 情境 怎麼做 判定
1 一道指令直升 190 現跑 1.26.0、灌 POC 資料庫備份(唯讀 pg_dump),sudo guidantai upgrade guidant-ai-<新>.tar.gz 成功;guidantai --version 是新版(D30);資料筆數等於升級前;pgdata-prev-1.26.0 存在且 mtime 不變
2 migration 卡住 新包塞一支 SELECT pg_sleep(700) 10 分判卡、自動退回、舊版可登入、筆數一致
3 升到一半斷電 步 4 進行中 kill -9 全部進程、重開機 開機單元自動退回;進度檔 rolled_back_at 有值
4 docker 當掉 步 4 進行中 systemctl stop docker、再起 sudo guidantai recover 收尾、舊版起來
5 維護頁 停機期間打開原網址 503+步驟清單每 3 秒更新;無按鈕
6 發版 跑 tag pipeline → 後台有草稿 → 指定 190 指紋先裝 → 190 check 看得到 → guidant-release publish 已發布;DEV check 回應簽章驗過
7 更新中心整條 DEV 指向本機 wrangler dev:通知 → 下載 → 就緒 → 立即 更新中心與維護頁都看到進度到完成;history 一列含下載人與按更新的人
8 排程與取消 選排程 → 到點前取消 → 再選排程 → 到點 取消後什麼都沒動;到點 path 單元觸發
9 離線上傳 拔網路、上傳同一包;再上傳改一位元組的包 前者就緒;後者驗不過即刪並顯示原因
10 權限 無 update 能力點帳號 看得到版本與紀錄、看不到任何按鈕;直接打 POST …/apply 403
11 授權過期 DEV 換過期授權檔 仍收到新版、可升;升後唯讀、功能鎖
12 主版號 更新站放一筆 major=2 的已發布 check 回 none
13 跨版兩段 190 重灌 1.25.1、更新站標 1.26.0 必經 兩包都下、兩段跑完;第二段 kill → 停在 1.26.0 可用
14 Agent 跟進 三台 Agent 先一台成功再其餘;故意讓第一台失敗其餘 paused
15 清單過期 更新站放 issued_at 100 天前的清單 客戶端拒收並顯示「請聯絡原廠重新發布」
16 守衛 grep 進度檔與診斷包無密碼;repo 無 token;border-left 0;兩支超標 shell 行數不增

8. 風險與陷阱

風險 怎麼擋
第一期出去那版的 guidantai 若「換自己」做錯,之後每版都卡 P1-1 的 self_replace 要有「換失敗不動任何東西」的測試;1.27.0 出包前在 190 從 1.26.0 升一次(測試期 §7-1)
複製資料庫的時限估錯(客戶庫 50GB) 預檢用實測速率算、×3;時限寫進進度檔讓人看得到;超時放棄=什麼都沒動
fe 容器重建時維護頁消失 步 6 最後才重建 fe;fe 重建前進度檔已 succeeded,使用者只看到幾秒斷線
更新站 Worker 的 WebCrypto 驗章與授權中心的 cryptography 實作漂移 P2-1 守衛測試用授權中心真簽的檔,驗不過擋部署
90 天沒發新版清單過期,客戶升不上 更新站後台列「最近一次簽發」;決策者 publish 同版重簽即可(D31)
主系統容器對 upgrade/orders/ 有寫權,被打穿的話能寫單觸發升級 單上的版本與包必須已驗過簽章且在本機;最壞是「被觸發一次合法升級」,仍走預檢與退回;不接受單上帶任意路徑
兩份 Agent install.sh 各自演化 P4-5 邏輯全進 install-lib/,兩份只剩一行
CRA 顧問說要分安全版 §15 排除方案已寫反悔條件:更新站多一個種類欄,流程不變

9. 這份文件的定位

你想知道 看哪份
為什麼這樣決定、排除了什麼 討論稿 §4、§15
做出來長什麼樣 雛形 10 頁
每張卡改哪些檔、驗收什麼 本頁 §6 與 handoff/cards.py
現在做到哪 README 總進度表、Notion 母卡
上線後的畫面與 API SPEC 頁(P5-2 產)與 FINAL-SPEC(收尾產)