FR-167 · 技術規格 · 2026-11-01
檢查到就緒、按更新到回報、Agent 跟進、發版:誰打哪支端點、跑哪支腳本、要多久、失敗怎樣
四張圖把線上更新從頭走到尾:① 主系統定時問更新站、通知管理員、下載、驗章、就緒;② 管理員按更新、主系統寫更新單、主機 systemd 收單、八步接力、換接、前門恢復、回報更新站;③ 主系統升完推 Agent、Agent 下載、Agent 主機跑升級、結果隨心跳回來;④ CI 出包登記草稿、傳 R2、決策者在自己電腦蓋章發布、客戶端看到。每張圖後面一張逐步表,時間與端點都對到程式碼。
| 代號 | 是什麼 | 在哪 |
|---|---|---|
| 管理員 | 有「系統更新」權限的人,在更新中心頁操作 | 瀏覽器 |
| FE | 更新中心頁(compliance-manager-fe src/views/product-update/) |
瀏覽器 |
| BE | 主系統後端 api(/api/1.0/system/product-update/*) |
客戶主機 guidant-api 容器 |
| 排程 | 主系統背景排程(core/scheduler_update.py) |
同上,gunicorn master 只跑一份 |
| Worker | 主系統背景工作(RUN_MODE=worker),跑下載 |
guidant-worker 容器 |
| 更新站 | Cloudflare Worker+D1+R2(update-center repo) | 原廠 |
| R2 | 放出貨包的物件儲存,下載用 15 分鐘預簽網址 | 原廠 |
| 主機 | 客戶主機上的 systemd 與 guidantai |
客戶主機 root |
| 前門 | guidant-fe 容器(nginx),後端停時送維護頁 |
客戶主機 |
| Agent | 證據蒐集 Agent(evidence-agent) | Agent 主機容器 |
| Agent 主機 | Agent 主機上的 systemd 與 Agent 安裝程式 | Agent 主機 root |
| CI | GitLab CI package 階段(188 build 機) |
原廠 |
| CLI | guidant-release(update-center cli/),握私鑰 |
決策者電腦 |
主系統與主機的交接只有檔案:主系統寫 upgrade/orders/、upgrade/incoming/;主機寫 upgrade/upgrade-progress.json、upgrade/draining、upgrade/last-switch.json。主機不打主系統。
%%{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
autonumber
participant SCH as 排程
participant BE as BE
participant US as 更新站
participant ADM as 管理員
participant FE as FE
participant WK as Worker
participant R2 as R2
participant H as 主機
SCH->>BE: 每 6 小時(與啟動時一次)run_scheduled_check
BE->>US: POST /v1/check(授權檔、指紋、現行版)
US-->>BE: none,或 available+path+每段 signed_manifest
BE->>BE: 每段用本機印樣驗章、版號遞增、主版號、min_base_version
BE->>BE: 寫 last_check(系統設定)
BE-)ADM: 新的一版才寄信(同一版不重寄)
ADM->>FE: 開更新中心
FE->>BE: GET /system/product-update/status
ADM->>FE: 按「下載」或「更新到 X」
FE->>BE: POST /download 或 POST /apply
BE->>BE: 建紀錄 downloading,入列背景工作
WK->>BE: 撿單 product_update_download
loop 路徑每一段
WK->>US: POST /v1/download-url(version)
US-->>WK: 15 分鐘預簽網址+sha256+size
WK->>R2: GET(Range 續傳)寫 incoming/*.tar.gz.part
WK->>WK: 大小與 sha256=清單才改名
WK->>US: POST /v1/download-url(artifact=ops),拿得到才下
end
WK->>BE: 紀錄改 ready、記下載速率
alt 按的是「更新到 X」
WK->>H: 直接寫更新單(接圖 2)
else 只按了下載
WK->>H: 寫 preflight 單到 orders/
H->>H: 只跑第 1 步,寫進度檔 status=preflight
end
FE->>BE: 每 3 秒 GET /status,看到 ready 與預檢結果
| 步 | 誰 | 打哪支端點/跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1 | 排程 | core/scheduler_update.py:13-36,CHECK_INTERVAL_HOURS=6;啟動時另跑一次;關了自動檢查或沒設更新站網址就不問 |
— | 例外只記 log |
| 2 | BE | update_site_client.check → POST /v1/check |
連線 10 秒、讀 30 秒(update_site_client.py:20-21) |
不拋:last_check 記 ok=false、error_code(站沒設定/連不到),畫面顯示「最近一次檢查失敗」 |
| 3 | 更新站 | src/routes/client.ts:41-45:驗授權章、指紋綁定、拒絕名單、主版號,回比現行新的版本;草稿只回給內部先裝名單 |
秒級 | 401 invalid_license、403 license_denied |
| 4 | BE | product_update_flow._parse_available(:217-231)→ package_verifier.verify_manifest(:206-218) |
毫秒 | 任一段驗不過:整段不認,error_code=PRODUCT_UPDATE_BAD_SIGNATURE |
| 5 | BE | _write_last_check(ROOT 租戶 system_configs,group PRODUCT_UPDATE、key last_check) |
— | 寫不進只記 log |
| 6 | BE | _notify_admins(:344-366),收件人同授權到期通知;notified_version 去重 |
每封秒級 | 一封寄不出不影響其他人;一封都沒寄出就不記去重,下次再寄 |
| 7~8 | FE | GET /system/product-update/status(product_update_app_service.py:130-150) |
— | — |
| 9~10 | FE→BE | POST /system/product-update/download;或 POST /system/product-update/apply(包還沒下時先建下載單並記下誰按、排程時間、Agent 選擇) |
— | 已有 downloading/ready:409 PRODUCT_UPDATE_BUSY;排程時間早於預估下載完成:擋下(upgrade_order.py:320-330) |
| 11 | BE | start_download(product_update_flow.py:406-419):紀錄 downloading,入列 product_update_download,max_attempts=1 |
— | — |
| 12 | Worker | run_download(:474-513),以按下載的人的身分跑 |
— | — |
| 13~14 | Worker→更新站 | POST /v1/download-url;R2 預簽 15 分鐘(update-center src/releases.ts:10) |
秒級 | 503 download_unavailable → 紀錄 failed |
| 15 | Worker→R2 | package_downloader.download_path(:242-262):一開始就以整條路徑剩餘大小檢查空間;每收 64MB 看一次是否已取消 |
依頻寬(連線 15 秒、讀 120 秒逾時,package_downloader.py:32-34) |
中斷留 .part 下次續傳;取消時整條路徑的檔一起刪 |
| 16 | Worker | 大小與 sha256 對得上清單才 os.replace 成正式檔名 |
5GB 約 20 秒算雜湊 | 對不上刪檔,紀錄 failed(包已刪、要重新下載) |
| 17 | Worker | 維運指令小檔 guidant-ai-<版>.ops.tar.gz(清單有 ops_sha256 才下) |
秒級 | 拿不到不算失敗;主機第 0 步改從整包抽 |
| 18 | Worker | _finish_job(ready)、_record_download_rate |
— | — |
| 19 | Worker | apply_after_download(upgrade_order.py:342-365):紀錄有 applied_by 才接著寫單 |
跳過重算雜湊 | 下載完已過排定時間:留 ready 記原因;寫單失敗:留 ready 記原因 |
| 20~21 | Worker→主機 | request_preflight(upgrade_order.py:388-399)寫 type=preflight 單;主機 ord_run_preflight(upgrade_order.sh:240-263) |
第 1 步 120 秒上限 | 寫單失敗只記 log;主機正在升級(拿不到鎖)就略過 |
| 22 | FE | 輪詢規則 pollIntervalMs(FE productUpdateState.js:30-35):有還沒結束的單就 3 秒一次 |
— | — |
離線:主機連不到更新站時,原廠提供離線包,管理員在更新中心分塊上傳(POST /uploads → PUT /uploads/<id>/chunks/<n> → POST /uploads/<id>/complete),驗章、比大小與 sha256 後一樣建 ready 紀錄並送 preflight 單(product_update_flow.py:576-640)。驗不過建一列 failed。
%%{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
autonumber
participant ADM as 管理員
participant FE as FE
participant BE as BE
participant SD as 主機 systemd
participant RN as 主機 runner
participant FD as 前門
participant SCH as 排程
participant US as 更新站
ADM->>FE: 按「立即更新」(勾或不勾 Agent 跟進)
FE->>BE: POST /system/product-update/apply
BE->>BE: 每段重驗包、紀錄 scheduled、記 job_id
BE->>SD: 寫 orders/{job_id}.json(暫存再 rename)
SD->>SD: path 單元觸發 upgrade --from-order
SD->>SD: 讀單、每段重驗包
SD->>RN: 交給 upgrade_runner.sh incoming 的包
RN->>RN: 第 0 步 驗包、換成新版維運指令、exec 新版
RN->>RN: 第 1 步 預檢
RN->>RN: 第 2 步 寫 draining,等工作做完
RN->>RN: 第 3 步 停服務、複製資料庫到 pgdata-next
Note over FD: 後端停了,前門送維護頁、讀進度檔
RN->>RN: 第 4 步 載映像、以複本起 db、migrate
RN->>RN: 第 5 步 以複本起受檢 api、實打
RN->>RN: 第 6 步 換接、起新版、寫 succeeded、寫 last-switch.json
RN->>FD: 最後重建前門
FD-->>ADM: 維護頁看到 succeeded、api 通了,帶 ?upgraded=1 回去
RN->>RN: 第 7 步 清理
SCH->>BE: 每分鐘讀進度檔
BE->>BE: 紀錄 succeeded、清舊 last_check、重查新版
BE->>US: POST /v1/report(result=succeeded)
BE->>BE: 每步補寫 system_logs
| 步 | 誰 | 打哪支端點/跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1~2 | FE→BE | POST /system/product-update/apply,body mode(now/scheduled)、at、with_agents、agent_uids |
— | 沒權限 403;包不在 412 PRODUCT_UPDATE_PACKAGE_MISSING;排程時間不在 1 分鐘後到 60 天內:400(upgrade_order.py:134-143) |
| 3 | BE | _enqueue(upgrade_order.py:255-276):verify_ready_path 每段重驗清單與雜湊;_schedule_job 紀錄改 scheduled、job_id、not_before |
5GB 約 20 秒(剛下載完的路徑跳過) | 驗不過 400;寫單失敗紀錄改回原狀態 |
| 4 | BE | write_order(:114-117):orders/<job_id>.json,寫暫存再 rename |
毫秒 | — |
| 5 | 主機 | guidant-upgrade-order.path(PathChanged)→ guidant-upgrade-order.service → guidantai upgrade --from-order |
秒級 | path 單元沒裝或沒啟用:單一直留著,畫面停在「正在準備更新」 |
| 6 | 主機 | upgrade_order.sh ord_load、ord_verify_package(:75-131):格式、包在 incoming/、本機印樣驗章、版號比前一段新 |
每段一次雜湊 | 驗不過:進度檔 skipped/unchanged、刪單 → 紀錄 skipped |
| 6' | 主機 | 排程單:systemd-run 一次性 timer,到點以 --run 回到第 6 步再驗一次(:133-148) |
到 not_before |
到點時單已刪=已取消,什麼都不做 |
| 7 | 主機 | ord_run_segment(:185-224),跨版每段一次;旁邊每秒看 .cancel 旗標 |
— | 一段失敗就停,停在中間版 |
| 8~15 | runner | 第 0~7 步,見 upgrade-runner-spec.md §3~§5 | 第 3~6 步合計上限 3600 秒 | 第 0~2 步失敗:skipped;第 3~6 步:自動退回 → rolled_back 或 rollback_failed |
| 11' | 前門 | nginx error_page 502 503 504 =503 /maintenance.html;維護頁每 3 秒讀 /upgrade-progress.json |
— | — |
| 14 | runner | upgrade_switch.sh:145-146:succeeded 寫在重建前門之前 |
— | 寫完之後逾時不退回 |
| 15~16 | 前門 | maintenance.js:331-358:進度檔不是 running、/api/1.0/version 通了才 reload |
前門重建約數秒;等 fe healthy 上限 60 秒 | 退回也失敗時不放人 |
| 18 | 排程 | product_update_order_sync,每分鐘(core/scheduler_update.py:39-67) |
— | 各段各自 try,回報出錯不擋 Agent 跟進 |
| 19 | BE | sync_from_progress(upgrade_order.py:451-473);after_switch 讀 last-switch.json,清掉換版前的 last_check 並以新版重查(:605-618) |
— | — |
| 20 | BE | report_final_once(:486-513):紀錄是終態且還沒報過才 POST /v1/report(target=host) |
讀 30 秒 | 送不到每分鐘重試,結束超過 1 天放棄;只是統計 |
| 21 | BE | log_steps_once(:562-585):每步一筆、整次結果一筆,事件碼 6239~6248 |
— | 每張單只寫一次 |
畫面輪詢也會同步:GET /status 會呼叫 after_switch(recheck=False),換接剛完成就同步紀錄、不等排程,但不打更新站(product_update_app_service.py:130-134)。
取消:管理員按「取消」→ DELETE /system/product-update/apply。單還在(主機還沒撿)=刪單;主機已開跑且最後一步 <3=寫 orders/<job_id>.cancel,主機收掉 runner、進度檔寫 skipped;第 3 步起 409 PRODUCT_UPDATE_TOO_LATE_TO_CANCEL(upgrade_order.py:374-385)。紀錄回 ready,包還在、可以重排。
重試:rolled_back 或 skipped 時按「重試」→ POST /system/product-update/retry,用 incoming/ 同一個包從預檢重跑(upgrade_order.py:368-371)。
陷阱:主機不打主系統,結果最慢晚一分鐘
主機只寫進度檔,主系統每分鐘讀一次(core/scheduler_update.py:39-43)。換接完成那一刻畫面靠 last-switch.json+status 請求先同步;回報更新站與補寫系統紀錄只走排程。
%%{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
autonumber
participant SCH as 排程
participant BE as BE
participant AG as Agent
participant AH as Agent 主機
participant US as 更新站
SCH->>BE: 每分鐘 tick(進度檔 succeeded、to=現行版)
BE->>BE: 建跟進列:第 1 批 1 台 dispatched、其餘第 2 批 pending
BE-)AG: POST /update/notify(叫它立刻心跳)
AG->>BE: POST /agents/heartbeat
BE-->>AG: data.update=target_version、bundle_sha256、size
AG->>BE: GET /agents/update/bundle(續傳)
AG->>AG: 驗大小與 sha,寫 agent-upgrade-order.json
AH->>AH: path 單元觸發 upgrade-host order
AH->>AH: 包搬進 staging、驗 sha、安全解包、載映像、舊版驗新版章
AH->>AH: install.sh upgrade(1 預檢、2 停接、3 換設定、4 重建容器、5 健康、6 清理)
AH->>AH: 寫 Agent 進度檔(同 job_id)
AG->>AG: 下次心跳前讀進度檔
AG->>BE: 心跳帶 upgrade_result(succeeded 或 failed)
BE->>BE: 跟進列改 succeeded 或 failed
SCH->>BE: 下一分鐘 _advance
BE->>BE: 前一批全成功放下一批;有失敗其餘 paused
SCH->>US: POST /v1/report(target=agent),送達才記 reported_at
| 步 | 誰 | 打哪支端點/跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1 | 排程 | _agent_rollout_tick → AgentRolloutService.tick(core/scheduler_update.py:86-101;agent_rollout_service.py:447-459) |
每分鐘 | 例外只記 log,不擋紀錄同步 |
| 2 | BE | _start_following(:461-481):更新單 with_agents 為 false 就不建;只取落後、active、未停用、勾選的;包要在 upgrade/bundles/guidant-ai-<版>/agent/ |
— | 包內沒有 Agent 包:記 warning、不跟進 |
| 3 | BE | notify_in_background → Agent 資料面 POST /update/notify(mTLS+綁「方法 路徑」的短效 JWT) |
5 秒逾時(agent_update_notify_client.py:23) |
連不到只記 warning,等下一次心跳(預設 300 秒) |
| 4~5 | Agent→BE | POST /api/1.0/agents/heartbeat;回應 data.update 由 update_for_agent(agent_rollout_service.py:369-386)決定:列是 dispatched、仍落後、host_version 不比現行新、包版號=target_version |
— | 任一條件不過就不帶 update,Agent 什麼都不動 |
| 6 | Agent→BE | GET /api/1.0/agents/update/bundle(同一套判定,續傳) |
依頻寬 | 404=已不在下發批,等下一次心跳;下載中斷留 .part 下次續;同一包失敗 1800 秒內不重下 |
| 7 | Agent | update_sync.sync_once(evidence-agent guidant_agent/sync/app/update_sync.py:403-428):大小與 sha 對才寫單;單的 job_id 形狀 agent-YYYYmmdd-HHMMSS-<6 hex> |
— | 大小或 sha 不符:刪檔、回報 failed |
| 8 | Agent 主機 | guidant-agent-upgrade-order.path(PathChanged=<升級目錄>/agent-order)→ upgrade-host order(upgrade_order.sh:158-194) |
秒級 | 單元沒裝:Agent 等 7200 秒判 failed「主機的升級單元可能沒裝或沒啟用」 |
| 9 | Agent 主機 | _uo_precheck、_uo_stage_bundle、_uo_load_and_verify(:217-319):版號比現行新;包 mv 進 root 700 的 agent-staging/;列清單只收一般檔與目錄、解開不超過 3 倍+1GB;只收 guidant-ai-agent:<版> 一個 tag;用現行版執行檔驗新版程式樹簽章 |
1.7GB 包數分鐘 | 寫 agent-upgrade-host.json(failed 或 skipped),進度檔 unchanged |
| 10 | Agent 主機 | install.sh upgrade → upr_upgrade(upgrade_runner.sh:398-445) |
預檢 120、套用 900、切換 1800、健康 600、清理 600;第 4~5 步合計 3600 秒 | 第 3 步起失敗自動退回(還原 .env、compose 檔,up -d) |
| 11 | Agent 主機 | 進度檔與主系統同契約;job_id=升級單 job_id |
— | — |
| 12~13 | Agent→BE | _order_outcome(update_sync.py:109-136)判結果;心跳帶 upgrade_result={version,status,at,error},同一筆一直重送到有新結果 |
下一次心跳 | 進度檔 skipped:不回報,下次心跳重來 |
| 14 | BE | 心跳 observer record_upgrade_result → record_result(agent_rollout_service.py:394-412):版號要等於 target_version |
— | 重送的舊結果不重寫 |
| 15~16 | 排程 | _advance(:496-518) |
每分鐘 | 前一批有失敗:其餘 paused,管理員要按「繼續」 |
| 17 | 排程 | report_agent_results(core/plugins/_agent_update.py:58-77)→ POST /v1/report(target=agent、agent_fingerprint),身分用主機授權 |
讀 30 秒 | 送不到每分鐘重試,結束超過 1 天放棄 |
不跟進時:確認視窗不勾「主系統更新完成後,接著更新 Agent」,主系統升完 Agent 不動;之後在 Agent 區勾選推送(POST /system/product-update/agents/push,可帶 10 分鐘後到 60 天內的排程時間),或「稍後提醒」7 天(POST …/agents/remind-later)。
限制:Agent 跟進只支援 docker compose 形態
原生形態收到 update 會回報 failed 說明原因,不默默不理,否則那一批永遠停在下發中(update_sync.py:13-14)。從沒有自動跟進能力的舊版要先手動升一次;以 install.sh --upgrade 指向 IT 自己解開的目錄升主系統時,bundles/ 下沒有 Agent 包,Agent 不會跟進(handoff/fr167-STATE.md §3)。
%%{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
autonumber
participant CI as CI
participant US as 更新站
participant R2 as R2
participant DEC as 決策者
participant CLI as CLI
participant IN as 內部先裝主機
participant BE as 客戶主系統
CI->>CI: rehearse-upgrade(同一支 build_bundle.sh 打包、排練升級)
CI->>CI: package:SBOM 門檻、build_bundle.sh、推 Harbor
CI->>US: POST /admin/releases/register-draft(CI token)
US-->>CI: 201 draft+R2 key(bundle、sha256、manifest)
CI->>R2: 上傳包、.sha256、release.json(未簽)
DEC->>US: 後台 PUT internal-targets(內部先裝指紋)
IN->>US: POST /v1/check(草稿只回給名單內的機器)
IN->>IN: 照圖 1、圖 2 升級
IN->>US: POST /v1/report(succeeded)
DEC->>CLI: guidant-release publish {版}
CLI->>US: GET /admin/releases/{版}(取草稿與內部先裝結果)
CLI->>CLI: 檢查全部成功、確認、私鑰蓋章、用印樣驗自己的章
CLI->>US: POST /admin/releases/{版}/publish(signed_manifest、kid)
US-->>CLI: published
BE->>US: POST /v1/check
US-->>BE: available+signed_manifest
BE->>BE: 用本機印樣驗章(接圖 1)
| 步 | 誰 | 打哪支端點/跑哪支程式 | 要多久 | 失敗怎樣 |
|---|---|---|---|---|
| 1 | CI | .gitlab-ci.yml rehearse-upgrade(:199-213):build_bundle.sh 後以 deploy_e2e.sh 從指定舊版升上來 |
上限 2 小時 | 排練不過不進 package |
| 2 | CI | package(:215-225):sbom.sh(白名單外 Critical 就停)→ build_bundle.sh(公鑰表沒有 PROD 鑰就紅)→ push_harbor.sh |
Nuitka 編譯約 15~20 分 | 任一步紅就不出包 |
| 3~4 | CI→更新站 | scripts/build/publish_draft.sh → POST /admin/releases/register-draft,body=release.json(version、min_base_version、sha256、size、has_security_fix、min_agent_version);token 走 header 檔不上命令列 |
秒級 | 非 201 就紅;已發布或撤回的版號 409 not_draft |
| 5 | CI→R2 | aws s3 cp:先大包、再 .sha256、最後 release.json;key 一律用更新站回的 upload.*(v<主版號>/<版號>/<檔名>),腳本不自己拼 |
依包大小 | 中途失敗時沒有 release.json 的草稿不會被當成完整 |
| 6 | 決策者 | 後台 PUT /admin/releases/<版>/internal-targets,最多 20 個指紋,只有草稿能改 |
— | 換包(sha256 不同)時舊的先裝結果不算數 |
| 7~9 | 內部先裝主機 | /v1/check:草稿只回給 internal_fingerprints 列到的機器(update-center src/releases.ts:65-70);升完 /v1/report |
一次完整升級 | 先裝失敗:決策者不能發布 |
| 10~12 | CLI | guidant-release publish <版>(update-center cli/commands.ts:164-195):取草稿 → 檢查內部先裝全部成功 → 輸入版號確認 → 解鎖私鑰 → buildPayload 蓋章(主力章,有下一把就連署並夾帶 endorsed_keys)→ 用印樣驗自己剛蓋的章 |
互動 | 先裝未全成功:不蓋章;私鑰與 .pub 對不上:在這裡擋下 |
| 13~14 | CLI→更新站 | POST /admin/releases/<版>/publish,body {signed_manifest, kid};需 Cloudflare Access 身分 |
秒級 | 草稿先裝未完成 409 internal_not_installed;撤回的 409;kid 已作廢 409 kid_revoked |
| 15~17 | 客戶主系統 | 圖 1:/v1/check 拿到 signed_manifest,用 /etc/guidant/release-keys/<kid>.pub 自己驗 |
— | 更新站回什麼都不信,驗不過整段不認 |
蓋章內容(update-center cli/envelope.ts:51-74):payload_type、version、major、min_base_version、sha256、size、has_security_fix、issued_at、kid,選填 min_agent_version、ops_sha256/ops_size、endorsed_keys。CLI 提示清單 90 天後到期、到期前要重新蓋章(MANIFEST_VALID_DAYS=90,cli/envelope.ts:21);已發布的版本可同版重簽。
換鑰順序:客戶主機驗章只用自己已裝的印樣。新印樣隨包安裝(第 0 步 release_keys_install),所以要先用舊章發一版、這一版把新印樣裝上去,下一版才能用新章(scripts/installer/lib/release_keys.sh:5-7)。
現況缺口:CI 正式發布路徑不帶維運指令小檔
register-draft 的輸入只收 version、min_base_version、sha256、size、has_security_fix、min_agent_version(update-center src/releases.ts:196-213),publish_draft.sh 也只傳三個檔、不傳 .ops.tar.gz。所以走正式 CLI 發布的版本,清單裡沒有 ops_sha256,客戶主機第 0 步會從整包抽維運指令(5GB 約兩分多,時限依包大小每 GB 60 秒)。更新站下載端點已認得 artifact: "ops"、CLI 的 buildPayload 也會帶 ops 兩欄,缺的是草稿登記與上傳這一段。
現況缺口:主系統驗清單沒有判 90 天效期
package_verifier.verify_manifest 只驗 kid、簽章、版號遞增、主版號(:206-218);issued_at 只解析、不比對。主機 shell 端也不判(upgrade_order.sh:96「清單不設有效期」)。90 天目前只是 CLI 對決策者的提醒。
| 文件 | 內容 |
|---|---|
| design.md | 設計定案 D1~D31、端到端驗收 |
| upgrade-runner-spec.md | 主機升級程式每一步、進度檔、更新單、systemd、目錄佈局 |
| state-machines-spec.md | 更新紀錄、Agent 跟進列、進度檔的狀態與畫面對照 |
handoff/fr167-STATE.md §3 |
已落地的契約(與 design 不同處) |
update-center repo README.md |
更新站端點與後台完整說明 |