FR-167 · 設計文件 v1 · 2026-10-29(D1~D31 定案;35 張卡、8 批)
升級指令絕不出事:在資料庫複本上升、每步有時限、失敗自動退回;更新站在 Cloudflare、發版在決策者電腦蓋章;更新中心一頁通知、下載、按更新;Agent 先一台再其餘
這份把討論稿 31 項定案展開到「照做就能交」。兩層:第一層改現有的 guidantai,升級在資料庫複本上做、每步有時限、失敗或斷電自動退回,現在那份資料庫從頭到尾沒動過;第二層加一個更新站(Cloudflare Worker+R2)、一個發布 CLI(決策者電腦,一正一備印章)、主系統一頁更新中心(通知、下載、驗簽名、立即或排程、更新紀錄)、主機兩個 systemd 單元看更新單呼叫升級指令。版本只有一種、不自動裝、授權過期仍可升同主版號最新版。35 張卡 8 批,依賴與同批撞檔由 handoff/cards.py 程式驗過。
狀態: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(已建、branchmain,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 |
現在每出一版,客戶要自己把 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→回報結果
做完之後長這樣(對照雛形 10 頁):儀表板冒出「有新版」橫幅(⓪);更新中心看版本說明、預檢、按下載(①);下載中服務照常(①b);就緒後選立即或排程(①c);停機期間打開原網址是維護頁,每 3 秒更新步驟與時限(②);失敗自動退回並給診斷包(③);成功後 Agent 先一台再其餘(④,僅在確認視窗勾了「接著更新 Agent」時自動跟進);跨版兩段(⑤);例外狀態各一張卡(⑥);更新站後台只給原廠看(⑦)。
| 期 | 做什麼 | 產出 | 完成怎麼判定(決策者檢查法) |
|---|---|---|---|
| 一 升級變安全(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 兩題先不管,不列功課。
定案一字內容以討論稿 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 驗 |
| 零件 | 現況(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 |
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 當掉自動退回沒跑的情況。
pgdata-next、以 GUIDANT_PGDATA_DIR=pgdata 起舊映像、健康檢查。沒有還原動作。pgdata-prev-<舊版> 是升級前快照,只給災難救援、不是回滾(D14);下次升級成功時清掉。cp -a,退回時一起換回。core/enroll.py 每次心跳 outbox.drain()),本案不另做。旗標檔 <資料目錄>/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 件做完」。不新開端點、不改任務狀態機。
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。
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。
release.json 多一個 has_security_fix 讓更新說明多「影響範圍」「建議動作」兩段,其餘行為一樣。product_major(P2-5,缺欄視為 1)。更新站 check 只比主版號、不看 expires_at。產品內授權過期的行為(唯讀、模組鎖)不變,本案不碰 common/authz/license.py。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 主線版號 ≤ 主系統現行版,否則不派。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 不連外網、不連更新站、不多常駐程式。manual rollout;Agent 單獨修正版(更新站 release.json 標 agent_only)→ 主系統下載存著不升自己,建 agent-only rollout。第一版先做前者,後者欄位先留。| 規則 | 怎麼守 |
|---|---|
| 對外服務無私鑰 | 更新站只有公鑰表與 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) |
批(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 |
— |
全部在 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 行數不增 |
| 風險 | 怎麼擋 |
|---|---|
第一期出去那版的 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 排除方案已寫反悔條件:更新站多一個種類欄,流程不變 |