---
title: "主機升級程式規格 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "主機升級程式"
subtitle: "guidantai upgrade／recover／更新單：第 0～8 步、時限、資料庫複本、退回、進度檔、systemd、目錄佈局"
lede: "主系統主機上真正換版的是 `guidantai upgrade`。它先換成新版的自己，再跑八步：預檢、停接新工作、複製資料庫、在複本上升級、健康檢查、換接、清理，失敗時跑第 8 步退回。**現在那份資料庫從頭到尾不被寫入**：升級做在複本上，換接前任何一步失敗就丟掉複本、起回舊版。本頁每個數字都對到程式碼行號，路徑以 `scripts/installer/` 為根。"
chips: [
  {text: "真相來源：scripts/installer/lib/*.sh", kind: accent},
  {text: "第 3～6 步合計上限 3600 秒", kind: warn},
  {text: "換接寫完 succeeded 後不退回", kind: crit}
]
footer: "FR-167 · 主機升級程式規格 · 2026-11-01 · 設計脈絡見 [design.md](design.md) · 沿革見 LOG 與 git log"
---

## 範圍 {#scope nav="範圍"}

這份寫的是**主系統主機側**的升級程式：`guidantai`、`install.sh --upgrade`、`lib/` 底下的步驟檔、systemd 單元、進度檔。主系統後端怎麼寫更新單、怎麼讀進度檔，寫在 [state-machines-spec.md](state-machines-spec.md) 與 [sequence-diagrams-spec.md](sequence-diagrams-spec.md)。Agent 主機的升級程式形狀相同，差異在 §11。

| 讀者要找的 | 看哪一節 |
|---|---|
| 這台機器上有哪些指令、退出碼是什麼 | §2 入口與子命令 |
| 某一步卡住、逾時是怎麼判的 | §4 八步總表、§5 各步細節 |
| 資料庫放在哪、換接後舊的在哪 | §6 資料庫複本與換接 |
| 升級中斷（斷電、kill）之後會怎樣 | §7 退回路徑 |
| 前端、維護頁讀到的 JSON 長怎樣 | §8 進度檔 |
| 主系統寫的更新單長怎樣、主機怎麼驗 | §9 更新單 |
| 主機上檔案放在哪 | §10 systemd 單元、§12 目錄佈局 |

## 入口與子命令 {#entry nav="入口"}

### 子命令

| 指令 | 誰呼叫 | 做什麼 | 程式位置 |
|---|---|---|---|
| `sudo guidantai upgrade <guidant-ai-<版>.tar.gz> [--deadline <時間>] [--force]` | IT 手動 | 驗包、換自己、跑八步。包旁要有 `.sha256` 與 `.release.json` | `guidantai:1487-1489` → `lib/upgrade_runner.sh` |
| `sudo guidantai recover` | IT 手動 | 上次升級停在第 3 步之後沒收尾、或自動退回失敗時，手動退回 | `guidantai:1490` → `upgrade_rollback.sh --recover` |
| `guidantai upgrade --from-order` | `guidant-upgrade-order.service` | 讀 `upgrade/orders/` 的更新單 | `upgrade_runner.sh:241` → `upgrade_order.sh` |
| `guidantai upgrade --boot-check` | `guidant-upgrade-boot-check.service` | 開機時看上次升級有沒有跑完 | `upgrade_runner.sh:242` → `upgrade_boot_check.sh` |
| `sudo guidantai diag [--since][--request-id][--out][--no-limit]` | IT 手動或 diag 更新單 | 產診斷包，含升級進度檔與更新單 | `guidantai:656`、`lib/diag_upgrade_stage.sh` |
| `guidantai --version` | 任何人 | 印 `/usr/local/lib/guidant/VERSION`（維運指令本身的版本） | `guidantai:1485-1486` |
| `sudo ./install.sh --upgrade [--force]` | IT 在解開的包目錄 | 以 `--bundle-dir` 交給同一支 runner，第 0 步只裝不驗章 | `install.sh:2606-2621` |
| `install.sh --upgrade-phase <config\|post-migrate\|post-switch\|ops-cli>` | 步驟檔內部 | 設定補齊的四段，每段冪等 | `install.sh:2563-2604` |

`upgrade-host` 不是主系統的子命令，是 Agent 安裝程式的子命令（`upgrade-host boot-check|order`），見 §11。

### runner 的參數

| 參數 | 意思 | 位置 |
|---|---|---|
| `<tar.gz>` | 出貨包。第 0 步只抽維運指令，整包由第 4 步解到 `upgrade/bundles/` | `upgrade_runner.sh:193-194` |
| `--bundle-dir <目錄>` | 已解開的包（`install.sh --upgrade` 用），不驗包簽章 | `:187` |
| `--deadline <時間>` | 停接新工作的等待截止（`date -d` 看得懂的格式） | `:188`、`:214-216` |
| `--force` | 新舊版號相同也照跑（同版號重編映像） | `:190` |
| `--root <r>` | 只給測試：維運指令裝到 `<r>/usr/local` | `:189` |

出貨包與 `--bundle-dir` 只能擇一（`:198`）。

### 退出碼

以 runner 檔頭為準（`upgrade_runner.sh:13-14`）：

| 碼 | 意思 | 系統狀態 |
|---|---|---|
| 0 | 升級成功（含「換接後才逾時」，見 §5.6） | 新版服務中 |
| 1 | 失敗，什麼都沒動（第 0～2 步失敗、已有升級在跑） | 舊版照常 |
| 2 | 參數錯誤、資料目錄不合理 | 沒開始 |
| 3 | 失敗，已自動退回 | 舊版服務中 |
| 4 | 退回也失敗，要人處理 `sudo guidantai recover` | 不確定，看進度檔 |
| 10 | 這次不更新（停接新工作在時段內等不到，或更新單被取消） | 舊版照常 |

::: {.callout .warn}
**陷阱：`guidantai --help` 的退出碼表與 runner 不同**

`guidantai:185-194` 的說明寫「1＝參數或用法錯誤」，那是維運指令其他子命令的意思；`upgrade` 子命令是 `exec` runner，回的是上表。寫自動化判退出碼時照上表。
:::

### 步驟檔的執行契約

每步以子行程 `bash <步驟檔>` 執行（`upgrade_runner.sh:16-18`），環境變數帶：

| 變數 | 內容 |
|---|---|
| `UPGRADE_DIR` | `<資料目錄>/upgrade` |
| `GUIDANT_DATA_DIR` | 資料目錄（`/etc/guidant-ai/install.conf` 定位） |
| `UPG_JOB_ID` | 這次升級的編號，形狀 `YYYYmmdd-HHMM-<hex>`（`:253`） |
| `UPG_FROM`／`UPG_TO` | 現行版（讀 `.env` 的 `GUIDANT_VERSION`）／目標版（讀清單 `version` 或包內 `VERSION`） |
| `UPG_TARBALL`／`UPG_BUNDLE_DIR` | 出貨包路徑／解開後的目錄（給 tar.gz 時是 `upgrade/bundles/guidant-ai-<版>`） |
| `UPG_STEP_N` | 第幾步 |
| `UPG_DEADLINE` | 截止時間 epoch 秒（空＝沒有） |
| `UPG_FORCE` | `--force` 時為 1 |

步驟回 0＝過、回 10（`UPG_RC_SKIP`，`progress.sh:31`）＝這次不更新、其他＝失敗；逾時由 runner 判成 124（`timeout -k` 收掉的 137 也算 124，`upgrade_runner.sh:80-82`）。

## 第 0 步：先換成新版的自己 {#self-replace nav="第 0 步"}

舊版主機上的 `guidantai` 只負責第 0 步：驗包、把包內新版維運指令裝上去，然後 `exec` 新版跑第 1 步之後。所以**第 1 步以後跑的永遠是新版的步驟檔**，修 runner 的錯不必等客戶先升一次維運指令。

| 項目 | 內容 | 位置 |
|---|---|---|
| 何時跳過 | 給的是 tar.gz、`/usr/local/lib/guidant/VERSION` 已等於目標版、沒設 `UPG_FORCE_SELF_REPLACE=1`：記 `skipped`「維運指令已是 X，不重裝」 | `upgrade_runner.sh:261-265` |
| 來源優先序 | ① 包旁小檔 `guidant-ai-<版>.ops.tar.gz`：驗過章的清單有 `ops_sha256` 且雜湊對得上才用 ② 否則從整包抽（要讀完整包） | `self_replace.sh:70-87`、`:186-193` |
| 時限 | 小檔可用＝60 秒；從整包抽＝每 GB 60 秒、下限 300；`--bundle-dir`＝60 秒 | `upgrade_runner.sh:56-68` |
| 驗包順序（整包） | 清單簽章（用本機已裝的印樣）→ 整包 sha256＝`.sha256` 檔＝清單 `sha256` → 版號格式 | `self_replace.sh:44-66` |
| 抽哪些檔 | `VERSION`、`guidantai`、`guidantai_bugsink.sh`、`lib/`、`release-keys/`、`maintenance/`，只跑一次 tar；包內 `VERSION` 要等於清單版號 | `self_replace.sh:93-105` |
| 安裝 | 先複製到同檔案系統暫存目錄，舊 `lib/` 改名讓位、新的 `mv` 進去；失敗換回舊版。換檔一律 rename，不原地覆寫 | `self_replace.sh:140-183` |
| 順便裝 | 發布印樣（`release_keys_install`）、三個 systemd 單元（`systemd_units_install`）、維護頁三支到 `upgrade/maintenance/` | `self_replace.sh:179-182` |
| 交棒 | `exec env GUIDANT_UPGRADE_REEXEC=1 /usr/local/bin/guidantai upgrade <原參數>`；新版看到旗標就不重鎖、不重跑第 0 步，並核對進度檔的 `job_id` 與 `UPG_JOB_ID` 相同 | `upgrade_runner.sh:247-251`、`:272` |
| 失敗 | 頂層記 `skipped`、`result=unchanged`，退出碼 1 | `upgrade_runner.sh:112-117` |

::: {.callout .crit}
**陷阱：換檔不可原地覆寫**

舊版 `guidantai` 與 runner 此刻正被 bash 逐行讀取。覆寫同一個 inode，舊行程會讀到新檔的後半段；rename 後舊 inode 留到行程結束才釋放（`self_replace.sh:21-22`）。

[**連帶**：改步驟順序或時限時，要想到舊版 runner 只會跑到第 0 步為止（`upgrade_runner.sh:25-26`）。]{.rec}
:::

::: {.callout .warn}
**陷阱：開機檢查單元要在第 3 步之前就裝好**

第 0 步就裝 systemd 單元，是因為第 3 步停服務之後斷電，要有人在開機時負責退回（`systemd_units.sh:32-33`）。
:::

## 八步總表 {#steps nav="八步"}

```{.mermaid cap="圖 1 — runner 的步驟鏈：第 0～2 步失敗什麼都沒動，第 3～6 步失敗走第 8 步退回"}
%%{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'}}}%%
flowchart LR
  S0["0 self_replace<br/>換成新版維運指令"] --> S1["1 preflight<br/>預檢"]
  S1 --> S2["2 drain<br/>停接新工作"]
  S2 --> S3["3 db_copy<br/>停服務、複製資料庫"]
  S3 --> S4["4 apply_on_copy<br/>在複本上升級"]
  S4 --> S5["5 health<br/>健康檢查"]
  S5 --> S6["6 switch<br/>換接"]
  S6 --> S7["7 cleanup<br/>清理"]
  S0 -. 失敗 .-> U["skipped／unchanged<br/>退出碼 1"]
  S1 -. 失敗 .-> U
  S2 -. 失敗 .-> U
  S2 -. 時段到 .-> K["skipped／unchanged<br/>退出碼 10"]
  S3 -. 失敗或逾時 .-> R["8 rollback<br/>退回"]
  S4 -. 失敗或逾時 .-> R
  S5 -. 失敗或逾時 .-> R
  S6 -. 寫 succeeded 前失敗 .-> R
  R --> RB["rolled_back 退出碼 3<br/>或 rollback_failed 退出碼 4"]
  S7 -. 失敗 .-> W["只記 warning<br/>結果仍是 upgraded"]
```

時限常數全在 `upgrade_runner.sh:36-47`，環境變數覆寫只給測試縮時用。

| 步 | name | 步驟檔 | 做什麼（一句） | 時限 | 失敗後 |
|---|---|---|---|---|---|
| 0 | `self_replace` | `self_replace.sh` | 驗包、換成新版維運指令、`exec` 新版 | 60 秒，或每 GB 60 秒下限 300 | `skipped`／`unchanged`，退出 1 |
| 1 | `preflight` | `upgrade_preflight.sh` | 只讀檢查，不過的項目一次列完 | 120 秒（`UPG_LIMIT_PREFLIGHT`） | `skipped`／`unchanged`，退出 1 |
| 2 | `drain` | `upgrade_drain.sh` | 寫 `draining` 旗標，等進行中的背景工作與 Agent 任務做完 | 沒有自己的時限；有 `--deadline` 就到截止為止 | 時段到：`skipped`，退出 10；其他失敗：`skipped`，退出 1 |
| 3 | `db_copy` | `upgrade_db_copy.sh` | 停服務、留設定快照、`cp -a` 資料庫到 `pgdata-next` | 有預估＝`estimate_copy_s`×3＋300；沒預估＝1800；再受總上限 | 退回 |
| 4 | `apply_on_copy` | `upgrade_apply_on_copy.sh` | 解包、載映像驗雜湊、設定補齊、以複本起 db、migrate | 只受總上限；載映像 300 秒、單支 migration 600 秒無進度由步驟自己判 | 退回 |
| 5 | `health` | `upgrade_health.sh` | 以複本起受檢 api，等健康、實打版本／登入／三支 GET | 300 秒（`UPG_LIMIT_HEALTH`），再受總上限 | 退回 |
| 6 | `switch` | `upgrade_switch.sh` | 複本轉正、寫版號、起新版、寫 `succeeded`、最後重建前門 | 300 秒（`UPG_LIMIT_SWITCH`），再受總上限 | 寫 `succeeded` 前退回；之後不退回 |
| 7 | `cleanup` | `upgrade_cleanup.sh` | 只留本版與上一版的映像、上一份快照、本次的包 | 600 秒（`UPG_LIMIT_CLEANUP`） | 只記 `warning` |
| 8 | `rollback` | `upgrade_rollback.sh` | 停新版、放回原份、換回設定、起舊映像、健康檢查 | 900 秒（`UPG_LIMIT_ROLLBACK`） | `rollback_failed`，退出 4 |

**總上限**：第 3～6 步合計 3600 秒（`UPG_TOTAL_LIMIT`），從第 3 步開始計時（`upgrade_runner.sh:150`）。每步實際時限＝「該步自己的時限」與「總上限剩餘」取小者，該步沒有自己的時限就用剩餘（`:99-105`）。

**逾時怎麼收**：`timeout -k 30 <秒>`，先送 TERM、再等 30 秒（`UPG_KILL_GRACE`）送 KILL（`:47`、`:80`）。步驟檔都有 TERM trap，收到就清自己的半成品。

**失敗原因怎麼留**：步驟自己寫的 `detail` 會保留，runner 只在後面附「（退出碼 N）」（`:91-92`）。維護頁與更新中心顯示的就是這段。

## 各步細節 {#step-detail nav="各步細節"}

### 5.1 第 1 步 預檢 {#s1 nav="-"}

`upgrade_preflight.sh:285-306`。**這一步什麼都不能動**：不刪映像、不清快取、不停服務（`:7`）。每一項都跑完才下結論，不過的項目一次列出（`:8`）。

| 檢查 | 不過時的字樣（節錄） | 位置 |
|---|---|---|
| 既有安裝：`.env`、`guidant.env` 在、讀得出現行版號 | 「找不到 …/.env，這台主機看起來還沒安裝過」 | `:42-46` |
| 出貨包：主機有 python3（或 perl 的 JSON::PP 等三模組）、驗章、雜湊；`--bundle-dir` 時看 `VERSION` | 「出貨包驗證失敗：…」 | `:110-133` |
| 版本：目標比現行新（`--force` 可同版）、不降版、現行 ≥ 清單 `min_base_version` | 「X 只能從 Y 以上直升，現行是 Z。必經路徑：…」 | `:70-107` |
| docker 可用、資料庫連得上 | 「資料庫連不上：guidant-db 未在執行或帳號密碼不符」 | `:150-162` |
| 核心：`/var/run/reboot-required` 存在、或執行中核心比已裝最新的舊；實際以 `--security-opt no-new-privileges` 起一次現行 be 映像（60 秒） | 「系統已安裝新核心但尚未重開機，請先 sudo reboot 再升級」 | `upgrade_preflight_host.sh:41-58` |
| 空間：同一檔案系統的需求加總＋2GB 保留，各自比可用 | 「<掛載點> 空間不足：可用 A，需要 B（…保留 2.0GB），還差 C」 | `:194-244` |

**空間怎麼算**（`:194-215`）：

| 項目 | 大小 | 放在哪 |
|---|---|---|
| 包解開 | 出貨包大小 | `upgrade/` |
| 新映像 | 出貨包大小（映像層本來就壓過） | docker 根目錄 |
| 資料庫複本 | `pgdata` 大小 × 120%（`PF_DB_COPY_FACTOR_PCT`） | 資料目錄下（`pgdata-next` 的上層） |
| 保留 | 每個檔案系統 2GB（`PF_RESERVE_KB`） | — |

**停機預估**（`:259-272`）：在複本落點寫 100MB（`dd … conv=fsync`）量速率，`estimate_copy_s`＝資料庫大小÷速率，`estimate_downtime_s`＝`estimate_copy_s`×3。量不到就不寫，第 3 步用預設 1800 秒。

**寫進度檔**：步驟 `detail`（每項一行）、步驟 `failures`（不過項目的字串陣列）、頂層 `estimate_copy_s`／`estimate_downtime_s`（`:297-305`）。

::: {.callout .warn}
**陷阱：給人看的不過項目讀 `failures`，不要讀 `detail`**

步驟失敗時 runner 會在 `detail` 後面附退出碼（`upgrade_preflight.sh:12`）。畫面要逐項列，用 `failures` 陣列。
:::

### 5.2 第 2 步 停接新工作 {#s2 nav="-"}

`upgrade_drain.sh`。

| 項目 | 值 | 位置 |
|---|---|---|
| 旗標檔 | `upgrade/draining`，內容 `job_id=…` 與 `since=…`，寫暫存再 `mv` | `:60-65` |
| 「進行中」的定義 | `public.background_jobs` 的 `running` ＋ `compliance.agent_tasks` 的 `dispatched`／`running`。`queued`／`pending` 不算：旗標一放主系統就不再撿 | `:12-13`、`:93-104` |
| 查詢帳號 | `.env` 的 `GUIDANT_DB_ADMIN_USER`（預設 `cmmgr`），並 `SET app.is_super_admin='t'` | `:76-88`、`:100` |
| 查詢間隔 | 10 秒（`UPG_DRAIN_INTERVAL`） | `:27` |
| 看到 0 之後 | 隔 3 秒再查一次仍是 0 才算過（`UPG_DRAIN_SETTLE`） | `:31`、`:121-124` |
| 等太久的提醒 | 1800 秒還沒完，寫步驟 `notice` | `:26`、`:131-135` |
| 放棄 | 截止前 5 秒自己回 10（`UPG_DRAIN_DEADLINE_MARGIN`），不讓 runner 的 timeout 收成 124 | `:29`、`:136` |
| 查不到資料庫 | 連續 30 次就判失敗（`UPG_DRAIN_MAX_DB_FAIL`），不把「查不到」當 0 | `:33`、`:127` |
| 寫進度檔 | 步驟 `running_jobs`、`running_agent_tasks`（數字）、`progress` 白話 | `:106-110` |

::: {.callout .crit}
**陷阱：旗標只在回 0 時留著**

放棄、失敗、被 TERM 都要刪旗標，否則主系統一直不接單、畫面看不出原因（`upgrade_drain.sh:15-16`）。旗標由第 6 步在新版 api 健康後、或第 8 步在舊版起來後才刪。
:::

::: {.callout .crit}
**陷阱：不可用 `cm_app` 查**

`cm_app` 受 RLS 擋，沒帶租戶脈絡時 count 永遠是 0，會被當成「做完了」直接往下停服務（`upgrade_drain.sh:17-18`）。
:::

### 5.3 第 3 步 停服務、複製資料庫 {#s3 nav="-"}

`upgrade_db_copy.sh:214-226`。

| 順序 | 動作 | 位置 |
|---|---|---|
| ① | 確認來源：`upg_pgdata_dir`（`.env` 有 `GUIDANT_PGDATA_DIR` 就用它，沒有＝named volume `guidant_guidant-pgdata` 的 mountpoint），要有 `PG_VERSION`；來源若就是複本位置＝上次沒收尾，要先 `recover` | `:148-157` |
| ② | 停 `guidant-api`、`guidant-worker`、`guidant-socketio`，再以 `stop -t 120` 停 `guidant-db`；任一還在跑就不複製。前門 `guidant-fe` 不停 | `:126-137` |
| ③ | 設定與憑證快照到 `<資料目錄>/.upgrade-config-prev/`（700），項目見下表；`MANIFEST` 記每一項存在或不存在，`JOB_ID` 記這次編號 | `:56-73` |
| ④ | `cp -a pgdata pgdata-next.partial`（背景跑、每 10 秒寫百分比），完成後比檔案數、對得上才 `mv` 成 `pgdata-next`；進度檔 `db` 改成 `{"current":"pgdata","copy":"pgdata-next"}` | `:164-190` |

**設定快照項目**（`DBC_CONFIG_ITEMS`，`:31`）：`.env`、`guidant.env`、`docker-compose.yml`、`certs`、`pki`、`seaweedfs/s3.json`、`ai-gateway-rules`。

**失敗或被 TERM**（`:194-212`）：先起回舊版服務（成功就刪 `draining`），再刪複本與這次的設定快照，`db` 改回 `copy:null`。別次升級留下的快照不動。

**證據檔不複製**：物件儲存（SeaweedFS）不在複本範圍（`:7-8`）。

::: {.callout .crit}
**陷阱：「`pgdata-next` 存在」等於「複本完整」**

複製一律先寫 `.partial`，完整且核對過才改名（`upgrade_db_copy.sh:12`）。後面每一步都靠這個約定判斷，不要直接寫 `pgdata-next`。
:::

### 5.4 第 4 步 在複本上升級 {#s4 nav="-"}

`upgrade_apply_on_copy.sh:186-201`。

| 順序 | 動作 | 時限或判定 | 位置 |
|---|---|---|---|
| ① | 給 tar.gz 時解整包到 `upgrade/bundles/.extract-<名>`，核對 `VERSION`＝目標版才改名成 `bundles/guidant-ai-<版>` | 只受總上限 | `:46-62` |
| ② | 載映像：從新包 `install.sh` 取出 `load_images`，逐檔比對 `bundle-manifest.sha256`，不符就拒 | 300 秒（`UPG_LOAD_LIMIT`） | `:67-93` |
| ③ | `install.sh --upgrade-phase config`：分類器變數、Redis 帳號、物件儲存、Agent PKI、AI 閘道規則、Redis 密鑰、AI 金鑰加密鑰、儲存設定 | — | `install.sh:2577-2586` |
| ④ | 以 `GUIDANT_PGDATA_DIR=pgdata-next` 起 `guidant-db`，等 healthy，再用 `docker inspect` 確認掛的真的是複本 | 180 秒（`UPG_DB_READY_LIMIT`） | `:100-117` |
| ⑤ | `GUIDANT_INIT_MODE=migrate MIGRATE_APPLY=1` 跑 init 映像（容器名 `guidant-upgrade-migrate`），輸出寫 `<資料目錄>/log/upgrade-migrate-<job_id>.log`，每 2 秒讀新行 | 單支 600 秒沒有新的 `PROGRESS` 行就判卡住（`UPG_STALL_TIMEOUT`） | `:144-173` |
| ⑥ | `install.sh --upgrade-phase post-migrate`：錯誤追蹤站、AI 金鑰、儲存快照補建、內建儲存 TLS、弱點資料 | — | `install.sh:2587-2594` |

**進度怎麼來**：init 映像的 `migrate.sh` 每套一支印一行 `PROGRESS<TAB>n/總數<TAB>檔名`，這裡轉寫成步驟 `progress`「migration n/總數 檔名」（`:126-141`）。

::: {.callout .crit}
**陷阱：migrate 前一定先確認 db 掛的是複本**

掛錯就是直接改客戶現在那份資料，退回救不回來（`upgrade_apply_on_copy.sh:13`）。第 5 步會再確認一次（`upgrade_health.sh:41-49`）。

[**連帶**：移掉 `PROGRESS` 那行輸出，「單支 10 分無進度」就判不到，卡住要等總上限 60 分（`:11-12`）。]{.rec}
:::

::: {.callout .warn}
**陷阱：migration log 不放 `upgrade/`**

前門維護頁唯讀掛整個 `upgrade/`，migration 輸出不該對未登入的人可見（`upgrade_apply_on_copy.sh:145`）。
:::

### 5.5 第 5 步 健康檢查 {#s5 nav="-"}

`upgrade_health.sh:129-149`。

| 順序 | 動作 | 判定 | 位置 |
|---|---|---|---|
| ① | 再確認 `guidant-db` 掛的是複本 | 不是就失敗 | `:41-49` |
| ② | `up -d guidant-db guidant-redis guidant-seaweedfs`，再 `compose run -d --no-deps --name guidant-upgrade-api guidant-api` | 容器不在就失敗 | `:51-59` |
| ③ | 等受檢容器 healthy，每 30 秒寫一次進度 | 180 秒（`UPG_HEALTH_READY_LIMIT`）；容器結束立刻失敗 | `:62-76` |
| ④ | 在容器裡打自己 `GET /api/1.0/version` | 200 且 `version`＝目標版 | `:93-103` |
| ⑤ | `POST /api/1.0/login`，帳號是隨機的不存在帳號 | 401 且回應含 `LOGIN_401001` | `:105-113` |
| ⑥ | 不帶 token 打 `/api/1.0/users/menu`、`/api/1.0/module-frames/menu`、`/api/1.0/system/product-update/status` | 一律 401；404＝那組功能沒進新版 | `:33`、`:115-124` |

每次 curl 15 秒逾時（`UPG_HEALTH_CURL_TIMEOUT`）。受檢容器用完一律刪（`:127`）。**worker 與 socketio 不在這一步起**：這時候起 worker 會在還沒驗過的複本上撿單（`:29-31`）。

::: {.callout .crit}
**陷阱：受檢 api 不可叫 `guidant-api`**

前門靠 docker DNS 名稱 `guidant-api` 找後端。受檢容器若叫這個名字，前門會立刻把使用者導進還沒驗過的版本、維護頁提早消失；之後檢查不過要退回時，已經有人在新版寫過資料（`upgrade_health.sh:7-10`）。
:::

### 5.6 第 6 步 換接 {#s6 nav="-"}

`upgrade_switch.sh:120-154`。

| 順序 | 動作 | 判定 | 位置 |
|---|---|---|---|
| ① | 停 `guidant-api`、`guidant-worker`、`guidant-socketio`、`guidant-db`（`stop -t 120`） | 停不掉就不換接 | `:53-57` |
| ② | 換資料夾（§6） | 任一 `mv` 失敗就放回並還原 `.env` | `:60-89` |
| ③ | `.env` 寫 `GUIDANT_VERSION=<新版>` | — | `:126` |
| ④ | 起 `guidant-db`、`guidant-redis`、`guidant-seaweedfs`，等 db healthy | 180 秒（`UPG_SWITCH_READY_LIMIT`） | `:129-132` |
| ⑤ | `--upgrade-phase post-switch`（補灌系統檔）、`--upgrade-phase ops-cli`（維運指令與部署定義檔換成本版、寫安裝紀錄） | 失敗就換接失敗 | `:133-136` |
| ⑥ | 起 `guidant-api`、`guidant-sandbox`、`guidant-socketio`、`guidant-worker`（有 `guidant-bugsink` 容器才帶），等 api healthy | 180 秒 | `:137-140` |
| ⑦ | 刪 `draining` 旗標 | — | `:142` |
| ⑧ | **進度檔寫 `succeeded`／`upgraded`**，寫 `upgrade/last-switch.json`（`{job_id,to,at}`） | — | `:145-146`、`:111-118` |
| ⑨ | 重建 `guidant-fe`，等 healthy | 60 秒（`UPG_SWITCH_FE_LIMIT`）；沒就緒只記 detail，不退回 | `:147-153` |

**換接後才逾時**：第 6 步若在寫完 `succeeded` 之後才逾時（前門重建慢），runner 看到 `result=upgraded` 就不退回、也不改寫結果，退出 0（`upgrade_runner.sh:119-123`）。

::: {.callout .crit}
**陷阱：前門一定最後才重建**

它重建那幾秒是整個升級唯一看不到維護頁的空檔；在它之前進度檔要已經是 `succeeded`，維護頁才會顯示「已完成」並自己重新整理（`upgrade_switch.sh:15-16`）。
:::

::: {.callout .crit}
**陷阱：系統檔與維運指令要在 api 起來之前做完**

舊前門一看到新 api 就會把人導過去；這兩段若排在後面，舊版畫面會對著新版 api 跑上一兩分鐘（`upgrade_switch.sh:127-128`）。
:::

### 5.7 第 7 步 清理 {#s7 nav="-"}

`upgrade_cleanup.sh:114-126`。任何一項刪不掉只記 warning，結果仍是 `upgraded`（`upgrade_runner.sh:173`）。

| 項目 | 留什麼 | 刪什麼 | 位置 |
|---|---|---|---|
| 映像 | `guidant-ai-be`、`guidant-ai-fe`、`guidant-ai-init` 各留 `UPG_TO` 與 `UPG_FROM` 兩個標籤 | 其他標籤（`latest`、`<none>` 不碰） | `:37-48` |
| 資料庫快照 | 進度檔 `db.prev` 指的那一份 | 其他 `pgdata-prev-*`；`prev_kind=dir` 時第一次升級留下的 named volume 也刪（是現行那份就不刪） | `:65-84` |
| 更新包 | `incoming/` 本版的 `.tar.gz`、`.ops.tar.gz`、`.release.json`（含 `.sha256`）；`bundles/guidant-ai-<本版>/agent/` | 更早版本的包；本版解開目錄裡 `agent/` 以外的大檔 | `:89-112` |
| 更新單 | — | `orders/<job_id>.json`、`.cancel` | `:108-111` |

`bundles/<本版>/agent/` 要留著：主系統升完後 Agent 跟進、推送、下載都從這裡找 Agent 包（`:86-88`）。

## 資料庫複本與換接 {#db nav="複本換接"}

### 6.1 三個路徑函式

都在 `lib/upgrade_common.sh`，所有步驟只走這三支，不自己拼路徑。

| 函式 | 回什麼 | 位置 |
|---|---|---|
| `upg_pgdata_dir` | 現在那份：`.env` 有 `GUIDANT_PGDATA_DIR` 就用它（相對路徑以資料目錄為準），沒有＝named volume `guidant_guidant-pgdata` 的 mountpoint | `:31-39` |
| `upg_pgdata_copy_dir` | 複本：`<資料目錄>/pgdata-next` | `:43` |
| `upg_pgdata_main_dir` | 換接後的固定位置：`<資料目錄>/pgdata` | `:46` |

compose 檔的 db 掛載是 `${GUIDANT_PGDATA_DIR:-guidant-pgdata}:/var/lib/postgresql/data`（`docker/production/docker-compose.yml:302`）。

### 6.2 兩種情況（prev_kind）

| | 第一次升級（`prev_kind=volume`） | 之後的升級（`prev_kind=dir`） |
|---|---|---|
| 換接前現在那份 | docker named volume `guidant_guidant-pgdata`（`.env` 沒有 `GUIDANT_PGDATA_DIR`） | `<資料目錄>/pgdata` |
| 換接時對它做什麼 | 原封不動（named volume 改不了名）；若 `<資料目錄>/pgdata` 已存在就拒絕換接 | `mv pgdata pgdata-prev-<舊版>`；同名已在（`--force` 重跑）加 `-<YYYYmmddHHMMSS>` |
| 複本變成 | `mv pgdata-next pgdata` | `mv pgdata-next pgdata` |
| `.env` | 寫 `GUIDANT_PGDATA_DIR=<資料目錄>/pgdata` | 同左（值不變） |
| 進度檔 `db` | `{"current":"pgdata","copy":null,"prev":"guidant_guidant-pgdata","prev_kind":"volume"}` | `{"current":"pgdata","copy":null,"prev":"pgdata-prev-<舊版>","prev_kind":"dir"}` |
| 清理時 | 下一次升級成功（`prev_kind=dir`）才刪這個 volume | 只留 `db.prev` 那一份 |

位置：`upgrade_switch.sh:7-11`、`:38-49`、`:60-89`；清理 `upgrade_cleanup.sh:65-84`。

::: {.callout .crit}
**陷阱：`.env` 一定先寫、再搬資料夾**

順序反過來時，搬完還沒寫就斷電，下次開機 compose 依舊值接回 named volume，複本裡套好的 migration 等於不存在（`upgrade_switch.sh:13-14`）。寫 `.env` 前先留 `.env.upgswitch`，任何 `mv` 失敗連 `.env` 一起放回（`:75-85`）。
:::

::: {.callout .warn}
**陷阱：快照只給災難救援，不是回滾**

換接後 `pgdata-prev-<舊版>` 是升級前那一刻的資料。新版開放使用後的寫入不在裡面，拿它回去等於丟資料。`recover` 遇到 `result=upgraded` 一律不退回（`upgrade_rollback.sh:233-235`）。
:::

### 6.3 設定檔怎麼改

`upg_set_kv`（`upgrade_common.sh:71-79`）改一個鍵：整份寫暫存再 rename，值經環境變數交給 awk、不上命令列。`.env` 寫一半會讓 compose 整份解析失敗、服務全起不來。

步驟起新版服務一律用 `upg_new_dc_argv`（`:52-55`）：`env GUIDANT_VERSION=<新版> GUIDANT_PGDATA_DIR=<複本或 pgdata> docker compose --env-file <.env> -f <新包的 compose 檔>`。shell 環境優先於 `--env-file`，所以換接前 `.env` 還是舊版號也起得出新版。

## 退回路徑 {#rollback nav="退回"}

### 7.1 三個入口，同一支退回

`upgrade_rollback.sh:3-7`、`:329-340`。

| 入口 | 何時 | 鎖 | 收尾 |
|---|---|---|---|
| `--from-runner` | runner 第 3～6 步失敗（第 8 步） | runner 持有 | runner 寫 `rolled_back` 或 `rollback_failed` |
| `--boot` | `guidant-upgrade-boot-check.service` 開機時 | 自己取，拿不到就不動 | 自己寫；**開機時一律回 0** |
| `--recover` | IT 執行 `sudo guidantai recover` | 自己取，拿不到就不動 | 自己寫；另外先 `docker compose -p guidant down --remove-orphans` 清 docker 當掉留下的殘骸（不帶 `-v`） |

開機與 recover 先判斷該不該退（`rb_decide`，`:267-280`）：

| 進度檔 `result` | 最後一步 | 開機 | recover |
|---|---|---|---|
| 沒有進度檔 | — | 不動 | 不動 |
| `upgraded` | — | 不動 | 不退回，回 1，提示 `guidantai start` |
| `unchanged`／`rolled_back` | — | 不動 | 不動 |
| `rollback_failed` | — | 不動（要人看過再 recover） | 退回 |
| `null`（沒跑完） | ≥3 | 退回 | 退回 |
| `null`（沒跑完） | <3 | 只清旗標，寫 `failed`／`unchanged` | 同左 |

「沒跑完」看 `result` 不看 `status`：runner 失敗時先把 `status` 改成 `failed` 才開始退回，退回中被打斷就是 `status=failed`、`result=null`（`:264-266`）。

### 7.2 退回做什麼

`rb_rollback`（`:232-250`），每段都先看「現在是什麼樣子」再動，可以重跑。

| 順序 | 動作 | 位置 |
|---|---|---|
| ① | `result=upgraded` 就拒絕（換接已完成、新版已開放使用） | `:233-235` |
| ② | 刪受檢容器 `guidant-upgrade-api`、`guidant-upgrade-migrate`；依序停 `guidant-api`、`guidant-worker`、`guidant-socketio`、`guidant-sandbox`、`guidant-bugsink`、`guidant-db`，前門不停 | `:37-40`、`:65-85` |
| ③ | 換接開始過（第 6 步有紀錄，且 `db.copy=pgdata-next` 或有 `prev_kind`）：把 `pgdata` 搬回 `pgdata-next`、快照改回 `pgdata`；之後一律刪 `pgdata-next`（`.partial` 也刪）；`db` 立刻改回 `copy:null` | `:118-148` |
| ④ | 確認升級前那份在：目錄要有 `PG_VERSION`，或 volume 存在 | `:150-160` |
| ⑤ | 換回設定：這次的設定快照（`JOB_ID` 相同）全部換回；否則有 `.env.upgswitch` 就換回 `.env` | `:163-171` |
| ⑥ | 確認舊映像在：進度檔 `prev_images` 有列就照列的，沒列就用 `.env` 版號推 `guidant-ai-be:<版>`、`guidant-ai-fe:<版>` | `:176-191` |
| ⑦ | 以舊版 compose 起 `guidant-db`…`guidant-fe` 全部（`env -u GUIDANT_PGDATA_DIR -u GUIDANT_VERSION`，只看 `.env`） | `:53-56`、`:193-203` |
| ⑧ | db healthy 且掛的是升級前那份、api healthy 且映像是舊版、fe 60 秒內 healthy（沒就緒只提示） | `:217-227` |
| ⑨ | 刪 `draining`、寫 `rolled_back_at`、刪這次的設定快照 | `:243-248` |

等待上限：db 與 api 各 180 秒（`UPG_ROLLBACK_READY_LIMIT`），fe 60 秒（`UPG_ROLLBACK_FE_LIMIT`）。整個第 8 步 900 秒。

::: {.callout .crit}
**陷阱：判斷換接做到哪只認換接自己留的記號**

看資料夾在不在猜不得：同版號重跑時舊快照也叫 `pgdata-prev-<版>`。判斷依據是 `.env.upgswitch` 與進度檔 `db.prev_kind`（`upgrade_rollback.sh:12-13`）。放回資料夾之後要立刻把 `db` 改成 `copy:null`，否則重跑會把已放回的原份當成升級後那份搬走（`:145-146`）。
:::

::: {.callout .crit}
**陷阱：設定目錄要在原目錄裡面覆寫**

`certs/` 與 `pki/` 被 redis、seaweedfs、fe 以 bind mount 掛著且升級全程沒停。換掉目錄 inode，容器內就變空目錄，healthcheck 讀不到 CA 轉 unhealthy，之後任何 compose up 都因依賴不健康起不來，退回也起不來（`upgrade_db_copy.sh:75-77`）。有 rsync 用 `rsync -a --delete`，沒有就清內容留目錄再複製。
:::

### 7.3 開機檢查

`upgrade_boot_check.sh`：`rb_standalone boot`，任何結果都 `exit 0`。oneshot 回非零只會讓 systemd 把單元標 failed，docker 的 restart 原則照樣把容器起來；退回失敗的事實記在進度檔，維護頁與 `status` 看得到（`:7-8`）。

## 進度檔 {#progress nav="進度檔"}

### 8.1 位置、寫法、讀者

| 項目 | 內容 | 位置 |
|---|---|---|
| 檔案 | `<資料目錄>/upgrade/upgrade-progress.json`，權限 644 | `progress.sh:51`、`:126` |
| 唯一寫入者 | `lib/progress.sh` 的函式（runner、步驟、更新單處理、退回都 source 它） | `:3` |
| 寫法 | 每個欄位一個小檔放 `upgrade/progress.d/`（700），存已編碼的 JSON 值；任何一欄改了就整份重組、寫 `.tmp.<pid>` 再 `mv`。讀者永遠讀到完整 JSON | `:15-16`、`:111-128` |
| 不用 jq | 客戶主機不保證有 | `:17` |
| 防撞 | 有 `flock` 就鎖 `progress.d/.lock`（最多等 10 秒），沒有照寫 | `:82-89` |
| 遮罩 | 字串寫入前遮掉 `PASS`／`PASSWORD`／`SECRET`／`TOKEN`／`PRIVATE_KEY` 開頭鍵的值；控制字元去掉 | `:60-71` |
| 讀者 | 前門維護頁（未登入可讀）、更新中心（`GET /system/product-update/status`）、開機檢查、診斷包；主系統讀上限 256KB | `progress.sh:5`、`product_update_app_service.py:34-64` |
| 欄位規則 | 只增不改名；讀者不認得的欄位要忽略 | `progress.sh:5` |
| 開新一次 | `progress_init` 會刪掉整個 `progress.d/` 與 `last-switch.json` | `:147-167` |

::: {.callout .crit}
**陷阱：前門維護頁不用登入就讀得到這個檔**

不可寫入密碼、金鑰、連線字串。遮罩只是最後一道，不是可以放心丟進來的理由（`progress.sh:12-13`）。
:::

### 8.2 頂層欄位

輸出順序是下表前段的固定順序，其餘欄位依字母排在後面（`progress.sh:96-117`）。

| 欄位 | 型別 | 誰寫 | 內容 |
|---|---|---|---|
| `job_id` | 字串 | `progress_init` | 這次升級的編號；更新單觸發時＝單的 `job_id` |
| `from`／`to` | 字串 | `progress_init` | 這一段的起訖版號 |
| `path` | 字串陣列 | `progress_init` | 更新單的整條路徑（跨版時兩段以上）；手動升級＝`[to]` |
| `segment` | 物件或 null | `progress_init` | 跨版時 `{"n":第幾段,"of":共幾段}`，否則 `null` |
| `triggered_by` | 物件 | `progress_init` | `{"kind":"cli\|order\|preflight","user":"<login>","mode":"now\|scheduled","at":"<ISO>"}` |
| `started_at` | 字串 | `progress_init` | ISO 時間（主機時區） |
| `status` | 字串 | runner／退回／更新單 | 見 [state-machines-spec.md](state-machines-spec.md) §3 |
| `db` | 物件 | 第 3、6、8 步 | `{"current":"pgdata","copy":null\|"pgdata-next"}`，換接後多 `prev`、`prev_kind` |
| `prev_images` | 字串陣列 | `progress_init` 給 `[]` | 退回要用的舊映像；空陣列時退回用 `.env` 版號推 |
| `result` | 字串或 null | `progress_finish` | 系統此刻的樣子：`null`（還在跑）／`upgraded`／`unchanged`／`rolled_back`／`rollback_failed` |
| `error` | 字串或 null | `progress_finish` | 給人看的失敗原因（白話步驟名，如「第 4 步（在複本上升級）逾時，已退回 1.27.6」） |
| `rolled_back_at` | 字串或 null | runner／退回 | 退回完成時間 |
| `updated_at` | 字串 | 每次重組 | 最後一次寫入時間 |
| `estimate_copy_s` | 數字 | 第 1 步 | 預估複製秒數（量得到才有） |
| `estimate_downtime_s` | 數字 | 第 1 步 | `estimate_copy_s`×3 |
| `steps` | 陣列 | 每步 | 見下表 |

### 8.3 步驟欄位

| 欄位 | 型別 | 內容 |
|---|---|---|
| `n` | 數字 | 0～8 |
| `name` | 字串 | 程式代號：`self_replace`、`preflight`、`drain`、`db_copy`、`apply_on_copy`、`health`、`switch`、`cleanup`、`rollback`。讀者靠它對自己的翻譯 |
| `status` | 字串 | `running`／`ok`／`failed`／`timeout`／`skipped`／`warning` |
| `started`／`ended` | 字串 | ISO 時間 |
| `limit_s` | 數字或 null | 這一步的時限；0 寫成 `null` |
| `detail` | 字串 | 步驟寫的摘要或失敗原因 |
| `progress` | 字串 | 進行中的白話進度（如「複製資料庫 42%（3.1GB／7.4GB）」） |
| `last_progress_at` | 字串 | 寫 `progress` 時順手記 |
| `failures` | 字串陣列 | 只有第 1 步：不過的項目 |
| `notice` | 字串 | 只有第 2 步：等太久的提醒 |
| `running_jobs`／`running_agent_tasks` | 數字 | 只有第 2 步：還在跑的件數 |

步驟白話名（給 `error`、`detail` 用）在 `progress.sh:35-48`；前門維護頁與更新中心各有自己的對照，改一邊要一起改。

### 8.4 只讀預檢的進度檔

包就緒後主系統會寫一張 `type=preflight` 的更新單，主機只跑第 1 步（`upgrade_order.sh:240-263`）。這時進度檔長得不一樣：

| 欄位 | 值 |
|---|---|
| `triggered_by.kind` | `preflight` |
| `status` | `preflight`（不是 `running`：畫面與維護頁把 `running` 當升級中） |
| `result` | `passed` 或 `failed` |
| `steps` | 只有 `n=1` |

拿不到升級鎖（正在升級）就不跑，免得 `progress_init` 清掉正在升級那次的進度檔（`:238-239`、`:244-248`）。

### 8.5 範例（升級中、第 4 步）

```json
{"job_id":"20261101-2200-a1b2","from":"1.27.6","to":"1.27.7","path":["1.27.7"],"segment":null,
 "triggered_by":{"kind":"order","user":"wang.dm","mode":"scheduled","at":"2026-11-01T21:30:00+08:00"},
 "started_at":"2026-11-01T22:00:04+08:00","status":"running",
 "db":{"current":"pgdata","copy":"pgdata-next"},"prev_images":[],
 "result":null,"error":null,"rolled_back_at":null,"updated_at":"2026-11-01T22:03:12+08:00",
 "estimate_copy_s":41,"estimate_downtime_s":123,
 "steps":[
  {"n":0,"name":"self_replace","status":"ok","started":"…","ended":"…","limit_s":60},
  {"n":1,"name":"preflight","status":"ok","started":"…","ended":"…","limit_s":120,"detail":"…"},
  {"n":2,"name":"drain","status":"ok","started":"…","ended":"…","limit_s":null,"detail":"進行中的工作都已做完","running_agent_tasks":0,"running_jobs":0},
  {"n":3,"name":"db_copy","status":"ok","started":"…","ended":"…","limit_s":423,"detail":"資料庫已複製（7.4GB，38 秒）；現在那份不會再被寫入"},
  {"n":4,"name":"apply_on_copy","status":"running","started":"…","limit_s":3138,"progress":"migration 31/47 2026-10-14-fr162-mapping-index.sql","last_progress_at":"…"}]}
```

## 更新單（主機讀的那一側） {#order nav="更新單"}

主系統容器只對 `upgrade/orders/` 有寫權（目錄 730 root:1000，容器能建檔、刪自己知道名字的檔，但列不出目錄），更新單寫完就由主機的 path 單元接手（`systemd_units.sh:20-30`）。

### 9.1 單的形狀

檔名 `upgrade/orders/<job_id>.json`，上限 8192 bytes（`upgrade_order.sh:32`）。主系統寫法見 `app/product_update/service/upgrade_order.py:83-95`。

| 欄位 | 值 | 主機驗什麼 |
|---|---|---|
| `job_id` | `YYYYmmdd-HHMM-<4～8 hex>` | 形狀；且要等於檔名（`:42`、`:81`） |
| `type` | `upgrade`／`diag`／`preflight` | 只收這三個（`:82`） |
| `mode` | `now`／`scheduled` | 只收這兩個；`scheduled` 一定要有 `not_before`（`:83`、`:92`） |
| `version` | 最後一段版號 | 要等於 `path` 最後一段（`:59-60`） |
| `path` | 版號陣列，最多 8 段 | 每段版號形狀 `X.Y.Z(-後綴)`、≤64 字（`:43`、`:56-57`） |
| `not_before` | ISO 時間（要帶時區）或 null | 沒時區就拒（`:61-67`） |
| `requested_by` | login | 不合 `[A-Za-z0-9._@-]{0,64}` 就改成 `unknown`（`:84`） |
| `requested_at` | ISO 時間 | 不用 |

用 `python3 -I` 解（值裡可能有任何東西，不用 grep），符號連結不收（`:47-72`）。

::: {.callout .crit}
**陷阱：單上的東西只信三件事**

單是主系統容器寫的，容器被打穿時單上什麼都可能寫。主機只信：`job_id` 與版號的格式、包在本機 `incoming/` 且用本機印樣驗得過、版號真的比現行新。**單上不接受任何路徑**，包的位置一律由版號在 `incoming/` 下拼出來（`upgrade_order.sh:19-21`）。
:::

### 9.2 主機怎麼處理

`ord_handle`（`upgrade_order.sh:266-284`）：

| type／mode | 動作 | 位置 |
|---|---|---|
| `diag` | `guidantai diag --out upgrade/diag/<job_id>`，產出改 644 讓 api 容器讀，刪單 | `:226-237` |
| `preflight` | 刪單，只跑第 1 步（§8.4） | `:240-263` |
| `upgrade` | 每段重驗包（簽章、雜湊、版號比前一段新）；驗不過寫進度檔 `skipped`／`unchanged`「執行前重新驗證更新包未通過：…」並刪單 | `:99-131` |
| `upgrade` ＋ `scheduled` 且時間未到 | `systemd-run --unit=guidant-upgrade-order-<job_id> --on-calendar=<時間> --timer-property=AccuracySec=1s` 排一次性 timer，到點以 `--run <單>` 回來；單留在 `orders/` | `:133-148` |
| `upgrade` 到點或立即 | 刪單，逐段交給 `upgrade_runner.sh <incoming/guidant-ai-<版>.tar.gz>`，帶 `UPG_JOB_ID`、`UPG_TRIGGER_KIND=order`、`UPG_ORDER_PATH`、`UPG_ORDER_SEGMENT` | `:166-224` |

**跨版**：每段各跑一次 runner（各自複本、各自健康檢查），`job_id` 相同；任一段失敗就停，前面跑完的段留著（停在中間版，那是可以正常使用的版本）（`:16-17`）。每段開跑前都驗，第二段的包壞了第一段就不跑（`:97`）。

**取消**：主系統在主機已開跑後按取消，會寫 `orders/<job_id>.cancel`。主機每秒看一次，**最後一步編號 <3 時**才收掉 runner（`setsid` 的整個行程群組送 TERM），進度檔寫 `skipped`／`unchanged`「已由管理員取消…」，回 10；第 3 步起忽略（`:185-224`）。排程到點時單已被刪＝管理員取消了，什麼都不做（`:270-274`）。

**path 單元只觸發一次**：處理途中新進的單不會再觸發，所以處理完要重掃；排程單會留在 `orders/` 等 timer，記下處理過的檔名才不會一直重掃（`:299-310`）。

## systemd 單元 {#systemd nav="systemd"}

單元檔在包內 `scripts/installer/systemd/`，安裝時把 `@GUIDANT_DATA_DIR@` 換成這台的資料目錄，寫暫存再 `mv`（`systemd_units.sh:48-57`）。全新安裝與每次升級第 0 步都會重裝。三個單元都是本機事件觸發，不開埠、不常駐。

| 單元 | 類型 | 觸發 | 執行 | 關鍵設定 | 啟用方式 |
|---|---|---|---|---|---|
| `guidant-upgrade-order.path` | path | `PathChanged=<資料目錄>/upgrade/orders`，`MakeDirectory=yes`，`DirectoryMode=0730` | 觸發 `guidant-upgrade-order.service` | `WantedBy=paths.target` | `enable --now` |
| `guidant-upgrade-order.service` | oneshot | 由 path 單元 | `/usr/local/bin/guidantai upgrade --from-order` | `After=docker.service`、`Wants=docker.service`、`TimeoutStartSec=infinity` | 不 enable（無 `[Install]`） |
| `guidant-upgrade-boot-check.service` | oneshot | 開機 | `/usr/local/bin/guidantai upgrade --boot-check` | `After=docker.service network-online.target`、`TimeoutStartSec=1500` | 只 `enable`、不 `--now` |
| `guidant-upgrade-order-<job_id>.timer` | 暫時 timer | `systemd-run --on-calendar` | `/bin/bash <lib>/upgrade_order.sh --run <單>` | `AccuracySec=1s`，帶 `GUIDANT_DATA_DIR` | 一次性 |

::: {.callout .warn}
**陷阱：更新單 service 不設時限、用 Wants 不用 Requires**

升級本身由 runner 每步時限與 60 分總上限管，這裡再設一個會在升級跑到一半時把整個行程殺掉；`Requires` 會讓 docker 重啟時連帶停掉這支，升級在第 3～6 步被腰斬（`systemd/guidant-upgrade-order.service` 檔頭）。

[**開機檢查只 enable 不 --now**：升級中跑它等於自己退回自己（`systemd_units.sh:66`）。開機檢查的 1500 秒要涵蓋退回本身 900 秒再加停容器（`systemd/guidant-upgrade-boot-check.service` 檔頭）。]{.rec}
:::

找不到資料目錄（沒有 `GUIDANT_DATA_DIR`、也沒有安裝紀錄）就不裝，下次升級補上；不猜預設值，猜錯的話 path 單元會監看一個沒人寫的目錄、且不會報錯（`systemd_units.sh:12-13`、`:38-42`）。

## Agent 主機的升級程式 {#agent nav="Agent 主機"}

Agent 升級程式形狀照主系統（步驟名、時限、進度檔同一份契約），在 evidence-agent repo `scripts/install/lib/`。這裡只列差異；跟進流程見 [sequence-diagrams-spec.md](sequence-diagrams-spec.md) 圖 3。

| 項目 | 主系統 | Agent |
|---|---|---|
| 入口 | `guidantai upgrade` | Agent `install.sh upgrade <包>`；系統單元呼叫 `upgrade-host boot-check\|order`（`upgrade_order.sh:112-118`） |
| 第 0 步 | `self_replace` | 無換自己；更新單觸發時第 0 步是 `extract`（解開更新包，`upgrade_order.sh:199-207`） |
| 資料庫複本 | 有（第 3 步） | 無；compose 形態第 3 步備份 `.env` 與 compose 檔到 `<升級目錄>/prev/`（700） |
| 步驟 | 1 預檢／2 停接／3 複製／4 套用／5 健康／6 換接／7 清理／8 退回 | 1 `preflight`／2 `drain`／3 `apply_on_copy`／4 `switch`／5 `health`／6 `cleanup`／8 `rollback`（`upgrade_runner.sh:12-20`） |
| 時限（秒） | 見 §4 | 預檢 120、套用 900、切換 1800、健康 600、清理 600、退回 900；第 4～5 步合計 3600（`upgrade_runner.sh:33-40`） |
| 退回起點 | 第 3 步 | compose 第 3 步、原生第 4 步（`_upr_first_live_step`，`:135`） |
| 升級目錄 | `<資料目錄>/upgrade` | compose＝`<安裝目錄>/upgrade`；原生＝`<AGENT_DATA_DIR>/upgrade`（`:59-66`） |
| 更新單目錄 | `upgrade/orders/`（容器寫） | `upgrade/agent-order/`（容器 uid 1000 可寫）＋`agent-staging/`（root 700） |
| systemd | `guidant-upgrade-*` | `guidant-agent-upgrade-boot-check.service`、`guidant-agent-upgrade-order.path`／`.service`，入口是 `/usr/local/lib/guidant-agent-upgrade/<install.sh>` 副本 |
| 形態限制 | — | 跟進升級只支援 docker compose 形態 |

## 主機目錄與檔案 {#layout nav="目錄"}

`<資料目錄>` 由 `/etc/guidant-ai/install.conf` 的 `GUIDANT_DATA_DIR` 決定（預設 `/srv/guidant-ai`）。

| 路徑 | 誰寫 | 權限 | 用途 |
|---|---|---|---|
| `/usr/local/bin/guidantai`、`guidantai_bugsink.sh` | 第 0 步 | 755／644 root | 維運指令 |
| `/usr/local/lib/guidant/` | 第 0 步 | root | `lib/*.sh` 與 `VERSION` |
| `/etc/guidant/release-keys/<kid>.pub` | 第 0 步（`release_keys_install`） | root；容器唯讀掛 | 發布印樣（Ed25519 公鑰），驗章只用這裡已裝的 |
| `/etc/guidant-ai/install.conf` | 安裝、`ops-cli` 段 | root | 安裝紀錄（資料目錄、compose 檔位置） |
| `/etc/systemd/system/guidant-upgrade-*` | 第 0 步 | 644 | §10 |
| `<資料目錄>/.env`、`guidant.env`、`docker-compose.yml` | 安裝、`config`／`ops-cli` 段、第 6 步 | 600／— | 設定；`.env` 換接後多 `GUIDANT_PGDATA_DIR` |
| `<資料目錄>/pgdata/` | 第 6 步 | postgres | 換接後的資料庫 |
| `<資料目錄>/pgdata-next/`、`pgdata-next.partial/` | 第 3 步 | postgres | 複本（升級中） |
| `<資料目錄>/pgdata-prev-<舊版>[-時間]/` | 第 6 步 | postgres | 升級前快照，只留一份 |
| `<資料目錄>/.upgrade-config-prev/` | 第 3 步 | 700 | 設定快照（`MANIFEST`、`JOB_ID`、`files/`）；含密碼，**不可放 `upgrade/` 底下** |
| `<資料目錄>/.env.upgswitch` | 第 6 步 | 同 `.env` | 換接中 `.env` 的暫存 |
| `<資料目錄>/log/upgrade-migrate-<job_id>.log` | 第 4 步 | 600 | migration 輸出 |
| `<資料目錄>/log/upgrade-<時間>.log`、`install-<時間>.log` | `install.sh` | — | 安裝程式落檔紀錄 |
| `<資料目錄>/upgrade/` | — | 755；容器唯讀掛 | 升級交換區 |
| `upgrade/upgrade-progress.json` | `progress.sh` | 644 | 進度檔 |
| `upgrade/progress.d/` | `progress.sh` | 700 | 進度檔的欄位小檔 |
| `upgrade/.upgrade.lock` | runner、退回、預檢單 | — | 同一台同時只有一個升級 |
| `upgrade/draining` | 第 2 步 | 644 | 停接旗標（主系統只看在不在） |
| `upgrade/last-switch.json` | 第 6 步 | 644 | `{job_id,to,at}`；下次升級開始才刪 |
| `upgrade/orders/` | 主系統容器 | 730 root:1000；容器讀寫掛 | 更新單、`.cancel` 取消旗標 |
| `upgrade/incoming/` | 主系統容器 | 750 1000:1000；容器讀寫掛 | 下載與上傳的包：`guidant-ai-<版>.tar.gz`（`.part`）、`.tar.gz.sha256`、`.release.json`、`.ops.tar.gz`（`.part`） |
| `upgrade/bundles/guidant-ai-<版>/` | 第 4 步 | root | 解開的包；清理後只剩 `agent/` |
| `upgrade/maintenance/` | 第 0 步 | 755／644 | 新版維護頁三支（平放） |
| `upgrade/diag/<job_id>/` | diag 單 | 755／644 | 診斷包，給更新中心下載 |

容器掛載（`docker/production/docker-compose.yml:248-257`、`:523-525`）：api 與 worker 掛 `upgrade:ro`，`orders` 與 `incoming` 以 rw 疊上去，另掛 `/etc/guidant/release-keys:ro`；前門 fe 只掛 `upgrade:ro`。

## 診斷包 {#diag nav="診斷包"}

`guidantai diag` 先在主機蒐料到交換目錄，再交給容器內打包核心產 `guidant-diag-<時間>.tar.gz`；遮罩由容器內那一套做，主機只複製原檔（`lib/diag_upgrade_stage.sh:3-5`）。

| 內容 | 來源 | 位置 |
|---|---|---|
| `docker-ps.txt`、`docker-stats.txt`、`disk.txt` | `docker ps`、`docker stats`、`df -h` | `guidantai:722-727` |
| `guidant.env` | 設定檔原檔（容器內遮罩） | `guidantai:729-736` |
| `upgrade-progress.json` | 進度檔 | `diag_upgrade_stage.sh:28-32` |
| `upgrade-orders.txt` | `orders/*.json` 串接 | `:34-44` |
| `upgrade_log-*` | `log/upgrade-*.log` 最新三支 | `:46` |
| `install_log-*` | `log/install-*.log` 最新三支 | `:47` |
| `last-switch.json` | 換接標記 | `:49-53` |
| `docker-<服務>.log` | 每個服務 `docker logs --since <預設 24h> --tail 5000` | `guidantai:654`、`:742-752` |

拿不到的項目照樣寫進索引（`unavailable` 加原因），不讓整包失敗。從更新中心按「下載診斷包」會寫 `type=diag` 的更新單，產在 `upgrade/diag/<job_id>/`。

## 與前門維護頁的關係 {#maintenance nav="維護頁"}

前門 `guidant-fe` 從頭到尾不停（第 6 步最後才重建）。後端停掉期間它送維護頁，維護頁讀進度檔自己畫進度。

| 項目 | 內容 | 位置（compliance-manager-fe） |
|---|---|---|
| 何時送維護頁 | 後端 502／503／504 一律改送 `/maintenance.html`、回 503 | `nginx.onprem.conf:126` |
| 維護頁從哪來 | 先找 `/guidant/upgrade/maintenance/maintenance.html`（第 0 步從新版包放上去），沒有才用映像內建的；js／css 同理 | `:134-170` |
| 進度檔 | `GET /upgrade-progress.json` alias 到 `/guidant/upgrade/upgrade-progress.json`，`Cache-Control: no-store` | `:181-189` |
| 輪詢 | 每 3 秒讀進度檔 | `public/maintenance/maintenance.js:20-21` |
| 何時放人回去 | 進度檔 `status=running` 或 `result=rollback_failed` 就不放；否則打 `/api/1.0/version`，通了才 reload；成功時帶 `?upgraded=1` | `:331-358` |

::: {.callout .warn}
**陷阱：第 5 步時 `/api/1.0/version` 已經通了，但不能放人**

第 5 步的受檢 api 不叫 `guidant-api`，前門找不到它，所以前門打 version 仍是 502；就算打得到，維護頁也先看進度檔還是 `running` 就不放（`maintenance.js:316-317`）。兩道都在，拿掉任一道都會讓人提早進到還沒換接的版本。
:::

## 升級完補寫系統紀錄 {#syslog nav="系統紀錄"}

升級期間服務停著，主機的紀錄只在主機檔案裡。主系統每分鐘排程在更新紀錄收成終態後，把進度檔每一步各寫一筆 `system_logs`，再寫一筆整次結果；每張單只寫一次（ROOT 租戶 `system_configs` 的 `upgrade_steps_logged_job` 記著）（`app/product_update/service/upgrade_order.py:516-585`）。

| 步 | 白話名 | 事件碼 |
|---|---|---|
| 0 | 換成新版維運指令 | 6239 `PRODUCT_UPDATE_STEP_SELF_REPLACE` |
| 1 | 預檢 | 6240 `PRODUCT_UPDATE_STEP_PREFLIGHT` |
| 2 | 停止接新工作，等進行中的做完 | 6241 `PRODUCT_UPDATE_STEP_DRAIN` |
| 3 | 停服務、複製資料庫 | 6242 `PRODUCT_UPDATE_STEP_DB_COPY` |
| 4 | 在複本上升級 | 6243 `PRODUCT_UPDATE_STEP_APPLY_ON_COPY` |
| 5 | 啟動新版、健康檢查 | 6244 `PRODUCT_UPDATE_STEP_HEALTH` |
| 6 | 開放使用 | 6245 `PRODUCT_UPDATE_STEP_SWITCH` |
| 7 | 清理舊版本 | 6246 `PRODUCT_UPDATE_STEP_CLEANUP` |
| 8 | 自動退回 | 6247 `PRODUCT_UPDATE_STEP_ROLLBACK` |
| — | 整次結果（誰排的、從哪版到哪版、成功／退回） | 6248 `PRODUCT_UPDATE_RESULT` |

事件碼定義在 `common/enum/event_code.py:242-251`。跨版升級每段各一份進度檔、`job_id` 相同，這裡只看得到最後一段的步驟。

## 相關文件 {#refs nav="相關"}

| 文件 | 內容 |
|---|---|
| [design.md](design.md) | 設計定案 D1～D31、端到端驗收、拆卡 |
| [state-machines-spec.md](state-machines-spec.md) | 更新單、Agent 跟進列、進度檔三個狀態機，與畫面顯示的對照 |
| [sequence-diagrams-spec.md](sequence-diagrams-spec.md) | 檢查到就緒、按更新到回報、Agent 跟進、發版四張循序圖 |
| `handoff/fr167-STATE.md` §3 | 已落地的契約（與 design 不同處） |
| `scripts/installer/lib/` | 本頁所有行號的來源 |
