evidence-agent 安裝精靈(install.sh)Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal:evidence-agent repo 做一支 deploy/install.sh 安裝精靈,把客戶端 8 步手動 docker-compose 部署收斂成「解壓一包 → ./install.sh → 答 4 題 → 完成」。

Architecture: install.sh(薄進入點:arg 解析 + 串四階段)source install-lib.sh(所有函式,純邏輯與 docker 編排分離)。純邏輯函式可在開發機(macOS bash 3.2)直接跑自帶 assert 測試;docker 編排 / 自我驗證在客戶 Linux 上手動驗證。收 8080 改由「base compose 不 publish、demo override 才加回」達成。

Tech Stack: POSIX sh / bash(相容 bash 3.2 以利 mac 開發機測試)、docker compose v2、既有 jedi-file-upload agent image、自帶 shell assert 測試(無外部框架)。

實作 repo: ~/Projects/Billows/Audit-Manager/evidence-agent/( compliance-manager-be)。動工前先讀該 repo CLAUDE.mddeploy/ 下既有檔。

規範重點(每個 commit 都守):

  • 各 repo 分開 commit、顯式 git add 檔名、禁用 -am、commit 訊息末尾加 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>push 等 user 明示
  • 不切 branch;在 user 當下 branch 工作。
  • 精靈絕不刪 pgdata/filedata/certs;把密碼/token/key 印到 stdout 或寫進 answers.conf;.env 一律 chmod 600

§1

File Structure

evidence-agent/deploy/
├── install.sh                  ← 新增:薄進入點(arg 解析 + 串四階段 + --dry-run)
├── install-lib.sh              ← 新增:所有函式(純邏輯 + docker 編排,可被測試 source)
├── docker-compose.yml          ← 修改:file-agent 不再無條件 publish 8080
├── docker-compose.demo.yml     ← 新增:demo(none 模式)override,加回 8080:8000
├── answers.conf.example        ← 新增:無人值守必填清單範本(無密碼)
├── .env.example                ← 沿用(精靈產生實際 .env)
├── collect-host-id.sh          ← 沿用(精靈自動呼叫)
├── nginx/agent.conf            ← 沿用
├── README.md                   ← 修改:改以 install.sh 為主流程,手動步驟降為附錄
└── test/
    ├── assert.sh               ← 新增:極簡 assert helper(無外部框架)
    └── test_install_lib.sh     ← 新增:對 install-lib.sh 純函式的測試

職責邊界:

  • install-lib.sh 函式分群:log_*(輸出)、pf_*(preflight)、cfg_*(設定收集/讀寫/IP 推導/產密碼)、orch_*(docker 編排 + 憑證輪詢)、vrf_*(自我驗證 + 交棒提示)。
  • 純函式(cfg_*pf_check_portorch_wait_for 的判斷邏輯)不直接呼叫 docker,可在 mac 測;真正碰 docker 的薄 wrapper 函式(orch_compose_upvrf_*)在 Linux 手動驗。
  • 測試 source install-lib.sh 後只測純函式;碰 docker 的函式用 shell function shadow 樁掉(例如測試內 docker(){ ...; })。

相容性注意: 被測試覆蓋的函式一律寫成 bash 3.2 相容(不用關聯陣列 / ${var^^} 等 4.x 語法),確保 mac 開發機跑得動。install.sh 本體可用 bash,但目標執行環境是客戶 Linux。


§2

Task 0: Scaffold + 測試骨架

Files:

  • Create: deploy/test/assert.sh
  • Create: deploy/test/test_install_lib.sh
  • Create: deploy/install-lib.sh(空殼,先放 shebang + 區段註解)
  • Create: deploy/install.sh(空殼,先 source install-lib.sh + echo TODO)

deploy/test/assert.sh:

#!/usr/bin/env bash
# 極簡 assert helper(無外部框架)。每個 assert 印 PASS/FAIL,累計失敗數。
ASSERT_FAILS=0
assert_eq() { # $1=expected $2=actual $3=msg
  if [ "$1" = "$2" ]; then printf 'PASS: %s\n' "$3"
  else printf 'FAIL: %s\n  expected=[%s]\n  actual=  [%s]\n' "$3" "$1" "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1)); fi
}
assert_contains() { # $1=haystack $2=needle $3=msg
  case "$1" in *"$2"*) printf 'PASS: %s\n' "$3";; *) printf 'FAIL: %s\n  [%s] does not contain [%s]\n' "$3" "$1" "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1));; esac
}
assert_not_contains() { # $1=haystack $2=needle $3=msg
  case "$1" in *"$2"*) printf 'FAIL: %s\n  [%s] unexpectedly contains [%s]\n' "$3" "$1" "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1));; *) printf 'PASS: %s\n' "$3";; esac
}
assert_summary() { if [ "$ASSERT_FAILS" -eq 0 ]; then printf '\nALL PASS\n'; else printf '\n%d FAILED\n' "$ASSERT_FAILS"; exit 1; fi; }

deploy/test/test_install_lib.sh:

#!/usr/bin/env bash
set -u
DIR="$(cd "$(dirname "$0")" && pwd)"
. "$DIR/assert.sh"
. "$DIR/../install-lib.sh"   # source 函式(install-lib.sh 不得在 source 時執行副作用)
# 後續 Task 在此追加測試
assert_summary

deploy/install-lib.sh 開頭:

#!/usr/bin/env bash
# evidence-agent 安裝精靈函式庫。被 install.sh 與 test 同時 source。
# 關鍵:本檔被 source 時【不得】有副作用(只定義函式),否則測試會誤跑。
# ── log_* ── / ── pf_* ── / ── cfg_* ── / ── orch_* ── / ── vrf_* ──(各 Task 填入)
#!/usr/bin/env bash
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)"
. "$DIR/install-lib.sh"
echo "TODO: wire stages"

Run: bash deploy/test/test_install_lib.sh Expected: 印 ALL PASS(目前無測試案例)。

cd ~/Projects/Billows/Audit-Manager/evidence-agent
git add deploy/test/assert.sh deploy/test/test_install_lib.sh deploy/install-lib.sh deploy/install.sh
git commit -m "feat(install): scaffold 安裝精靈骨架 + 自帶 shell 測試 harness

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

§3

Task 1: log/prompt 輸出函式

Files:

  • Modify: deploy/install-lib.sh(加 log_* / ask_*)
  • Test: deploy/test/test_install_lib.sh
# log_redact 應遮蔽密碼樣式的值
out="$(log_redact "DB_PASSWORD=sup3rs3cret other=ok")"
assert_not_contains "$out" "sup3rs3cret" "log_redact 遮蔽密碼值"
assert_contains "$out" "other=ok" "log_redact 保留非敏感內容"

Run: bash deploy/test/test_install_lib.sh Expected: FAIL log_redact: command not found / 該案例 FAIL。

log_info()  { printf '\033[0;36m[i]\033[0m %s\n' "$*"; }
log_ok()    { printf '\033[0;32m[✓]\033[0m %s\n' "$*"; }
log_warn()  { printf '\033[0;33m[!]\033[0m %s\n' "$*" >&2; }
log_err()   { printf '\033[0;31m[✗]\033[0m %s\n' "$*" >&2; }
# 遮蔽 KEY=VALUE 中疑似敏感欄位的值(印 log 前過濾,永不外漏明文)
log_redact() {
  printf '%s' "$1" | sed -E 's/(PASSWORD|SECRET_KEY|TOKEN|ACCESS_KEY)=[^[:space:]]*/\1=***REDACTED***/g'
}
# 互動提問:$1=提示 $2=預設值(可空);回傳使用者輸入或預設
ask() {
  _p="$1"; _d="${2:-}"
  if [ -n "$_d" ]; then printf '%s [%s]: ' "$_p" "$_d" >&2; else printf '%s: ' "$_p" >&2; fi
  IFS= read -r _ans || true
  if [ -z "$_ans" ]; then printf '%s' "$_d"; else printf '%s' "$_ans"; fi
}

Run: bash deploy/test/test_install_lib.sh Expected: PASS。

git add deploy/install-lib.sh deploy/test/test_install_lib.sh
git commit -m "feat(install): log/ask 輸出函式 + log_redact 敏感值遮蔽

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

§4

Task 2: 預設路由 IP 推導(cfg_derive_ip)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh
# 樁:模擬 `ip route show default` 輸出
ip() { echo "default via 192.168.50.1 dev eth0 src 192.168.50.121 metric 100"; }
assert_eq "192.168.50.121" "$(cfg_derive_ip)" "cfg_derive_ip 從 default route 取 src IP"
unset -f ip

Run: bash deploy/test/test_install_lib.sh Expected: 該案例 FAIL。

# 推導本機對雲端可達的 IP(default route 的 src)。取不到回空字串,由 caller 改問。
cfg_derive_ip() {
  ip route show default 2>/dev/null \
    | sed -nE 's/.* src ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+).*/\1/p' \
    | head -n1
}

Run: bash deploy/test/test_install_lib.sh Expected: PASS。

git add deploy/install-lib.sh deploy/test/test_install_lib.sh
git commit -m "feat(install): cfg_derive_ip 從 default route 推導本機 IP

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

§5

Task 3: 強密碼產生(cfg_gen_password)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh
pw="$(cfg_gen_password)"
assert_eq "32" "$(printf '%s' "$pw" | wc -c | tr -d ' ')" "cfg_gen_password 長度 32"
# 只含 URL-safe 字元(避免 DB URL 解析問題;雖然 config.py 會 quote_plus,仍從源頭避雷)
clean="$(printf '%s' "$pw" | tr -d 'A-Za-z0-9_-')"
assert_eq "" "$clean" "cfg_gen_password 只含 [A-Za-z0-9_-]"

Run: bash deploy/test/test_install_lib.sh Expected: 該案例 FAIL。

# 產生 32 字元 URL-safe 隨機密碼。用 LC_ALL=C 處理二進位以相容 mac/Linux。
cfg_gen_password() {
  LC_ALL=C tr -dc 'A-Za-z0-9_-' < /dev/urandom 2>/dev/null | head -c 32
}

Run: bash deploy/test/test_install_lib.sh Expected: PASS。


§6

Task 4: 讀 answers.conf / 套預設(cfg_load_answers)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh

說明:answers.conf 是無密碼的 KEY=value 檔。cfg_load_answers <file> 把值載入成 shell 變數(CFG_<KEY>),供互動模式當預設、無人值守模式直接用。重跑時也讀既有 .env(可含密碼)當預設。

tmp="$(mktemp)"; printf 'STORAGE_TYPE=minio\nCLOUD_ENDPOINT=https://c.example.com\n# comment\n\n' > "$tmp"
cfg_load_answers "$tmp"
assert_eq "minio" "${CFG_STORAGE_TYPE:-}" "cfg_load_answers 載入 STORAGE_TYPE"
assert_eq "https://c.example.com" "${CFG_CLOUD_ENDPOINT:-}" "cfg_load_answers 載入 CLOUD_ENDPOINT"
rm -f "$tmp"
# 安全解析 KEY=value(略過註解/空行);不 source(避免任意執行)。設成 CFG_<KEY>。
cfg_load_answers() {
  [ -f "$1" ] || return 0
  while IFS= read -r _line || [ -n "$_line" ]; do
    case "$_line" in ''|\#*) continue;; esac
    _k="${_line%%=*}"; _v="${_line#*=}"
    case "$_k" in *[!A-Za-z0-9_]*) continue;; esac   # 非法 key 名跳過
    eval "CFG_${_k}=\$_v"
  done < "$1"
}

§7

Task 5: 寫 .env(chmod 600)與 answers.conf(無密碼)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh

說明:cfg_write_env <dir> 用收集好的 CFG_* 變數寫出 <dir>/.env(含密碼,chmod 600);cfg_write_answers <dir><dir>/answers.conf(排除所有密碼/secret/token 欄位)。

d="$(mktemp -d)"
CFG_STORAGE_TYPE=minio CFG_DB_PASSWORD="sup3rs3cret" CFG_MINIO_SECRET_KEY="mk-secret" \
CFG_REGISTRATION_TOKEN="tok-123" CFG_CLOUD_ENDPOINT="https://c.example.com" \
CFG_AGENT_BASE_URL="https://192.168.50.121:8443" CFG_MINIO_ENDPOINT="192.168.50.121:9000" \
CFG_MINIO_ACCESS_KEY="admin" CFG_MINIO_BUCKET="guidant-ai" CFG_AGENT_IMAGE="evidence-agent:0.1.0"
cfg_write_env "$d"
cfg_write_answers "$d"
# .env 含密碼且權限 600
assert_contains "$(cat "$d/.env")" "DB_PASSWORD=sup3rs3cret" ".env 含 DB 密碼"
assert_eq "600" "$(stat -f '%Lp' "$d/.env" 2>/dev/null || stat -c '%a' "$d/.env")" ".env 權限 600"
# answers.conf 不含任何密碼/secret/token
ans="$(cat "$d/answers.conf")"
assert_not_contains "$ans" "sup3rs3cret" "answers.conf 不含 DB 密碼"
assert_not_contains "$ans" "mk-secret" "answers.conf 不含 MINIO_SECRET_KEY"
assert_not_contains "$ans" "tok-123" "answers.conf 不含 REGISTRATION_TOKEN"
assert_contains "$ans" "STORAGE_TYPE=minio" "answers.conf 保留非敏感欄位"
rm -rf "$d"
cfg_write_env() {
  _d="$1"; _f="$_d/.env"
  umask 077                       # 確保新檔預設不可被他人讀
  {
    printf 'AGENT_IMAGE=%s\n' "${CFG_AGENT_IMAGE:-evidence-agent:0.1.0}"
    printf 'STORAGE_TYPE=%s\n' "${CFG_STORAGE_TYPE:-minio}"
    printf 'MINIO_ENDPOINT=%s\n' "${CFG_MINIO_ENDPOINT:-}"
    printf 'MINIO_ACCESS_KEY=%s\n' "${CFG_MINIO_ACCESS_KEY:-}"
    printf 'MINIO_SECRET_KEY=%s\n' "${CFG_MINIO_SECRET_KEY:-}"
    printf 'MINIO_BUCKET=%s\n' "${CFG_MINIO_BUCKET:-}"
    printf 'BASE_DIR=%s\n' "${CFG_BASE_DIR:-/data/upload}"
    printf 'DB_PASSWORD=%s\n' "${CFG_DB_PASSWORD:-}"
    printf 'AGENT_AUTH_MODE=%s\n' "${CFG_AGENT_AUTH_MODE:-full}"
    printf 'CLOUD_ENDPOINT=%s\n' "${CFG_CLOUD_ENDPOINT:-}"
    printf 'REGISTRATION_TOKEN=%s\n' "${CFG_REGISTRATION_TOKEN:-}"
    printf 'AGENT_BASE_URL=%s\n' "${CFG_AGENT_BASE_URL:-}"
    printf 'HEARTBEAT_INTERVAL_SEC=%s\n' "${CFG_HEARTBEAT_INTERVAL_SEC:-300}"
  } > "$_f"
  chmod 600 "$_f"
}
# answers.conf:只寫非敏感欄位(白名單),方便複製到別台 / 版控。
cfg_write_answers() {
  _d="$1"; _f="$_d/answers.conf"
  {
    printf '# evidence-agent 無人值守安裝答案(不含任何密碼/secret/token)\n'
    printf '# 用法:./install.sh --config answers.conf --non-interactive\n'
    printf 'STORAGE_TYPE=%s\n' "${CFG_STORAGE_TYPE:-minio}"
    printf 'MINIO_ENDPOINT=%s\n' "${CFG_MINIO_ENDPOINT:-}"
    printf 'MINIO_ACCESS_KEY=%s\n' "${CFG_MINIO_ACCESS_KEY:-}"
    printf 'MINIO_BUCKET=%s\n' "${CFG_MINIO_BUCKET:-}"
    printf 'BASE_DIR=%s\n' "${CFG_BASE_DIR:-/data/upload}"
    printf 'CLOUD_ENDPOINT=%s\n' "${CFG_CLOUD_ENDPOINT:-}"
    printf 'AGENT_BASE_URL=%s\n' "${CFG_AGENT_BASE_URL:-}"
    printf 'HEARTBEAT_INTERVAL_SEC=%s\n' "${CFG_HEARTBEAT_INTERVAL_SEC:-300}"
  } > "$_f"
}

注意:answers.conf 白名單刻意不含 DB_PASSWORD / MINIO_ACCESS_KEY? — MINIO_ACCESS_KEY 是帳號非密碼,保留;MINIO_SECRET_KEY / DB_PASSWORD / REGISTRATION_TOKEN 一律排除。無人值守重跑時這三項仍需互動補或從既有 .env 帶。


§8

Task 6: Preflight 檢查(pf_*)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh

說明:pf_has_cmdpf_check_port(可樁)、pf_check_host_filespf_ensure_image(樁 docker)。純判斷可測;真碰 docker 的薄包裝在 Linux 手動驗。

assert_eq "0" "$(pf_has_cmd sh; echo $?)" "pf_has_cmd 偵測存在的指令"
assert_eq "1" "$(pf_has_cmd __definitely_no_such_cmd__; echo $?)" "pf_has_cmd 偵測不存在的指令"
# pf_check_port 用樁判斷:樁 ss 回有人 listen 8443 → 應回非 0(被占用)
ss() { echo "LISTEN 0 0 0.0.0.0:8443 0.0.0.0:*"; }
assert_eq "1" "$(pf_check_port 8443; echo $?)" "pf_check_port 偵測 8443 被占用"
ss() { echo ""; }
assert_eq "0" "$(pf_check_port 8443; echo $?)" "pf_check_port 偵測 8443 空閒"
unset -f ss
pf_has_cmd() { command -v "$1" >/dev/null 2>&1; }
# port 被 listen 回 1(占用),空閒回 0。優先 ss,退而求其次 netstat。
pf_check_port() {
  _p="$1"
  if pf_has_cmd ss; then
    ss -ltn 2>/dev/null | grep -qE "[:.]$_p[[:space:]]" && return 1 || return 0
  elif pf_has_cmd netstat; then
    netstat -ltn 2>/dev/null | grep -qE "[:.]$_p[[:space:]]" && return 1 || return 0
  fi
  return 0   # 無工具可查時不擋(保守放行,README 提醒人工確認)
}
# host 指紋來源檔可讀(Linux only;mac 上會 false,僅 Linux 部署時呼叫)
pf_check_host_files() {
  [ -r /sys/class/dmi/id/product_uuid ] && [ -r /etc/machine-id ]
}
# 確保 image 在;不在則 docker load 同目錄精確檔名 image tar。
pf_ensure_image() {
  _img="$1"; _dir="$2"
  if docker image inspect "$_img" >/dev/null 2>&1; then return 0; fi
  _tar="$_dir/$(printf '%s' "$_img" | tr ':' '-').image.tar"   # evidence-agent-0.1.0.image.tar
  if [ -f "$_tar" ]; then log_info "載入 image: $_tar"; docker load -i "$_tar"; else
    log_err "找不到 image $_img,也找不到 $_tar"; return 1; fi
}

回應 review rec #3:image tar 用精確檔名(由 image tag 推導,:-),不 glob,避免多檔誤選。


§9

Task 7: base compose 收 8080 + demo override

Files:

  • Modify: deploy/docker-compose.yml
  • Create: deploy/docker-compose.demo.yml

說明:full 模式 file-agent 不需要對外 8080(控制面 agent 主動撥出、資料面走 nginx:8443,enroll 期間亦然)。所以 base compose 移除 file-agent 的 ports: 8080:8000;demo(none 模式測試)才用 override 加回。回應 review rec #1/#2。

    ports:
      - "8080:8000"

nginx 仍以 service 名 file-agent:8000 在 compose 內網 proxy,不受影響。在該處補註解:

    # full 模式不對外 publish 8080:控制面 agent 主動撥出、資料面走 nginx:8443。
    # demo(none 模式)測試需要直打 8080 時,疊用 docker-compose.demo.yml。
# demo / 測試用(AGENT_AUTH_MODE=none,雲端直打 8080,不經 mTLS)。
# 用法:docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d
# ⚠️ 正式環境一律 full,不要疊這個 override(會繞過 mTLS)。
services:
  file-agent:
    ports:
      - "8080:8000"

Run(full,預設):cd deploy && docker compose config | grep -A3 'file-agent:' — 確認 file-agent 8080 published。 Run(demo):docker compose -f docker-compose.yml -f docker-compose.demo.yml config | grep '8080' — 確認 8080。

若開發機無 docker,標記為 Linux 手動驗證項,於 Task 12 e2e 清單一併驗。

git add deploy/docker-compose.yml deploy/docker-compose.demo.yml
git commit -m "feat(install): full 模式 base compose 不對外 publish 8080,demo override 才加回

控制面 agent 主動撥出、資料面走 nginx:8443,enroll 期間亦不需 8080。
解決安裝精靈 review 的 8080 繞過 mTLS 與 enroll 期 port 衝突疑慮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

§10

Task 8: 啟動編排 + 憑證輪詢(orch_*)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh(只測輪詢判斷邏輯,docker 動作樁掉)

說明:核心價值。orch_wait_cert <certs_dir> <timeout_sec> <interval> 輪詢 agent.crt 出現(代表 enroll 成功),逾時回非 0。orch_up_agent / orch_up_nginx 是薄 docker compose 包裝(Linux 手動驗)。

d="$(mktemp -d)"; mkdir -p "$d/certs"
# 還沒有 agent.crt → 1 秒 timeout 應逾時回非 0
assert_eq "1" "$(orch_wait_cert "$d/certs" 1 1; echo $?)" "orch_wait_cert 無憑證逾時回非 0"
# 放上 agent.crt → 應立即回 0
: > "$d/certs/agent.crt"
assert_eq "0" "$(orch_wait_cert "$d/certs" 2 1; echo $?)" "orch_wait_cert 憑證就緒回 0"
rm -rf "$d"
# 輪詢 certs/agent.crt 出現(= 雲端簽回憑證、enroll 成功)。逾時回 1。
orch_wait_cert() {
  _dir="$1"; _timeout="${2:-120}"; _interval="${3:-3}"; _waited=0
  while [ "$_waited" -lt "$_timeout" ]; do
    [ -f "$_dir/agent.crt" ] && return 0
    sleep "$_interval"; _waited=$((_waited+_interval))
  done
  return 1
}
# 以下為 docker 薄包裝(Linux 手動驗;DRY_RUN 時只印不執行)。
orch_compose() { if [ "${DRY_RUN:-0}" = "1" ]; then log_info "[dry-run] docker compose $*"; else docker compose "$@"; fi; }
orch_up_agent() { orch_compose up -d file-agent agent-db; }
orch_up_nginx() { orch_compose --profile full up -d nginx; }
orch_dump_agent_log() { orch_compose logs --tail 50 file-agent; }

§11

Task 9: 自我驗證 + 交棒(vrf_*)

Files:

  • Modify: deploy/install-lib.sh
  • Test: deploy/test/test_install_lib.sh(交棒訊息可測;碰 curl/docker 的樁掉)

說明:採設計 Q4(c)。vrf_local_health(樁 curl)、vrf_local_tls(本機 curl :8443 預期 400)、vrf_print_handoff <base_url>(印雲端下一步)。

out="$(vrf_print_handoff 'https://192.168.50.121:8443')"
assert_contains "$out" "https://192.168.50.121:8443" "交棒訊息含 base_url"
assert_contains "$out" "storage-config" "交棒訊息提示回雲端設 storage-config"
# vrf_local_tls 回傳碼:400/000 視為通,其餘(含 500/502 死站)視為失敗(stub curl)
curl() { echo 400; }; assert_eq "0" "$(vrf_local_tls; echo $?)" "vrf_local_tls 400 視為通"
curl() { echo 000; }; assert_eq "0" "$(vrf_local_tls; echo $?)" "vrf_local_tls 000(握手失敗)放行"
curl() { echo 500; }; assert_eq "1" "$(vrf_local_tls; echo $?)" "vrf_local_tls 500 視為失敗"
unset -f curl
# /health 200 視為 OK(樁:測試可 shadow curl)
vrf_local_health() { _code="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/health 2>/dev/null || echo 000)"; [ "$_code" = "200" ]; }
# 本機打 nginx:8443,預期被 mTLS 擋成 400 → 證明 TLS+port 通。
# 400=mTLS 正常擋;000=curl 對 mTLS 握手失敗(部分環境如此)也放行;其餘(500/502/死站)視為失敗。
vrf_local_tls() {
  _code="$(curl -sk -o /dev/null -w '%{http_code}' https://localhost:8443/ 2>/dev/null || echo 000)"
  case "$_code" in 400|000) return 0;; *) return 1;; esac
}
vrf_certs_present() { _d="$1"; [ -f "$_d/agent.crt" ] && [ -f "$_d/agent.key" ] && [ -f "$_d/ca.crt" ]; }
vrf_print_handoff() {
  _base="$1"
  cat <<EOF
─────────────────────────────────────────────
安裝完成(本機這側驗證通過)。下一步請在雲端做:
  1) 用該 tenant 帳號登入前端 → 儲存設定(storage-config) → storage_type 選 remote_agent
     → 選這台 agent(base_url = $_base)→ 存檔
  2) 從雲端那台(有 client 憑證)跑三層認證驗證:
       不帶憑證 → 400 / 帶憑證 → 200 / /blob 不帶 JWT → 401
     (指令見 deploy/README.md §9-1)
─────────────────────────────────────────────
EOF
}

vrf_local_tls 的 400/000 都放行:有些環境 curl 對 mTLS 握手失敗回 000,不應誤判失敗;真正的資料面驗證交棒雲端。 說明:設計 §④ 列的「agent log 出現 agent enrolled + heartbeat」這項刻意不做成 vrf 函式 — enroll 失敗已在 Task 10 ③ 的 orch_wait_cert 逾時 + orch_dump_agent_log 涵蓋;log 人工檢查交棒給交棒訊息 / README §7,非遺漏。


§12

Task 10: install.sh 主流程串接 + arg 解析

Files:

  • Modify: deploy/install.sh
  • Test: 以 --dry-run 手動跑(不需 docker)

說明:串四階段 + 解析 --config <file> / --non-interactive / --dry-run / -h。互動收集 4 類設定(storage / cloud_endpoint / token / db 密碼),自動推導 IP 並確認,寫 .env + answers.conf,編排啟動,自我驗證。

#!/usr/bin/env bash
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)"
. "$DIR/install-lib.sh"

CONFIG_FILE=""; NON_INTERACTIVE=0; DRY_RUN=0
while [ $# -gt 0 ]; do case "$1" in
  --config) CONFIG_FILE="$2"; shift 2;;
  --non-interactive) NON_INTERACTIVE=1; shift;;
  --dry-run) DRY_RUN=1; shift;;
  -h|--help) echo "用法: ./install.sh [--config answers.conf] [--non-interactive] [--dry-run]"; exit 0;;
  *) log_err "未知參數: $1"; exit 2;;
esac; done
export DRY_RUN

# 載入既有設定當預設(重跑友善):先 .env 後 answers.conf 後 --config
cfg_load_answers "$DIR/.env"
cfg_load_answers "$DIR/answers.conf"
[ -n "$CONFIG_FILE" ] && cfg_load_answers "$CONFIG_FILE"

# ① Preflight(dry-run 跳過所有環境相依檢查,方便 mac 上驗流程)
log_info "① Preflight 檢查"
if [ "$DRY_RUN" != "1" ]; then
  pf_has_cmd docker || { log_err "未找到 docker,請先安裝 Docker 與 compose plugin"; exit 1; }
  docker compose version >/dev/null 2>&1 || { log_err "未找到 docker compose v2 plugin"; exit 1; }
  pf_check_host_files || { log_err "讀不到 /sys/class/dmi/id/product_uuid 或 /etc/machine-id(agent 必須跑在 Linux)"; exit 1; }
  pf_check_port 8443 || { log_err "port 8443 已被占用,請先釋放再重跑"; exit 1; }
  pf_ensure_image "${CFG_AGENT_IMAGE:-evidence-agent:0.1.0}" "$DIR/.." || exit 1
fi

# ② 設定收集
log_info "② 設定收集"
if [ "$NON_INTERACTIVE" = "0" ]; then
  CFG_STORAGE_TYPE="$(ask '儲存模式 (minio/local)' "${CFG_STORAGE_TYPE:-minio}")"
  if [ "$CFG_STORAGE_TYPE" = "minio" ]; then
    CFG_MINIO_ENDPOINT="$(ask 'MinIO endpoint (LAN IP:9000)' "${CFG_MINIO_ENDPOINT:-}")"
    CFG_MINIO_ACCESS_KEY="$(ask 'MinIO access key' "${CFG_MINIO_ACCESS_KEY:-}")"
    CFG_MINIO_SECRET_KEY="$(ask 'MinIO secret key' "${CFG_MINIO_SECRET_KEY:-}")"
    CFG_MINIO_BUCKET="$(ask 'MinIO bucket' "${CFG_MINIO_BUCKET:-guidant-ai}")"
  fi
  CFG_CLOUD_ENDPOINT="$(ask '雲端 base URL (不含 /api/1.0)' "${CFG_CLOUD_ENDPOINT:-}")"
  CFG_REGISTRATION_TOKEN="$(ask '註冊 token (雲端管理頁產生)' "${CFG_REGISTRATION_TOKEN:-}")"
  if [ -z "${CFG_DB_PASSWORD:-}" ]; then
    _g="$(ask 'agent DB 密碼 (留空=自動產生強密碼)' '')"
    [ -z "$_g" ] && _g="$(cfg_gen_password)" && log_ok "已自動產生 agent DB 密碼(寫入 .env,不顯示)"
    CFG_DB_PASSWORD="$_g"
  fi
  _ip="$(cfg_derive_ip)"
  CFG_AGENT_BASE_URL="$(ask '本機對雲端可達 URL (必須 https + 8443)' "${CFG_AGENT_BASE_URL:-https://${_ip}:8443}")"
fi
CFG_AGENT_AUTH_MODE=full
# 必填校驗
for _req in CLOUD_ENDPOINT REGISTRATION_TOKEN AGENT_BASE_URL DB_PASSWORD; do
  eval "_v=\${CFG_$_req:-}"; [ -n "$_v" ] || { log_err "缺必填: $_req"; exit 2; }
done
case "$CFG_AGENT_BASE_URL" in https://*:8443) :;; *) log_err "AGENT_BASE_URL 必須是 https://<ip>:8443"; exit 2;; esac
cfg_write_env "$DIR"; cfg_write_answers "$DIR"
log_ok "已寫出 $DIR/.env (600) 與 $DIR/answers.conf (無密碼)"

# ③ 啟動編排
log_info "③ 啟動編排"
[ "$DRY_RUN" = "1" ] || sh "$DIR/collect-host-id.sh"
( cd "$DIR" && orch_up_agent )   # orch_* 經 orch_compose 在 DRY_RUN 下自我跳過(只印不執行)
log_info "等待 agent 向雲端註冊、簽回憑證 ..."
if [ "$DRY_RUN" != "1" ] && ! ( cd "$DIR" && orch_wait_cert "$DIR/certs" 180 3 ); then
  log_err "等不到 certs/agent.crt(enroll 失敗)。以下為 agent log:"; ( cd "$DIR" && orch_dump_agent_log ); exit 1
fi
( cd "$DIR" && orch_up_nginx )

# ④ 自我驗證 + 交棒
log_info "④ 自我驗證"
if [ "$DRY_RUN" != "1" ]; then
  vrf_certs_present "$DIR/certs" && log_ok "憑證已落地" || log_warn "憑證不齊,請查 agent log"
  vrf_local_health && log_ok "file-agent /health 200" || log_warn "/health 未回 200"
  vrf_local_tls && log_ok "本機 :8443 TLS 通(mTLS 擋為預期)" || log_warn ":8443 未回預期碼(檢查 nginx 是否起來)"
fi
vrf_print_handoff "$CFG_AGENT_BASE_URL"

Run:

cd ~/Projects/Billows/Audit-Manager/evidence-agent/deploy
printf 'STORAGE_TYPE=local\nCLOUD_ENDPOINT=https://c.example.com\nAGENT_BASE_URL=https://192.168.50.121:8443\n' > /tmp/ans.conf
# 補密碼/token(非互動需要)
CFG_DB_PASSWORD=x CFG_REGISTRATION_TOKEN=y bash install.sh --config /tmp/ans.conf --non-interactive --dry-run

Expected:印出四階段、[dry-run] docker compose ...、最後印交棒訊息;實際呼叫 docker。

dry-run 的 preflight 跳過已內建於 Step 1 的 if [ "$DRY_RUN" != "1" ] 區塊(含 docker / host-files / port / image 全跳),正式安裝不跳。collect-host-id / orch_wait_cert / 自我驗證也都在 DRY_RUN != 1 下才實跑。

Run: bash deploy/test/test_install_lib.sh Expected: ALL PASS

git add deploy/install.sh deploy/install-lib.sh
git commit -m "feat(install): install.sh 主流程串接四階段 + arg 解析 + dry-run

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

§13

Task 11: answers.conf.example + README 改寫

Files:

  • Create: deploy/answers.conf.example
  • Modify: deploy/README.md
# evidence-agent 無人值守安裝答案範本(複製成 answers.conf;不含任何密碼/secret/token)
# 密碼類(DB_PASSWORD / MINIO_SECRET_KEY / REGISTRATION_TOKEN)安裝時互動補,或由精靈自動產生。
STORAGE_TYPE=minio
MINIO_ENDPOINT=192.168.50.121:9000
MINIO_ACCESS_KEY=admin
MINIO_BUCKET=guidant-ai
CLOUD_ENDPOINT=https://cloud.example.com
AGENT_BASE_URL=https://192.168.50.121:8443
HEARTBEAT_INTERVAL_SEC=300
git add deploy/answers.conf.example deploy/README.md
git commit -m "docs(install): answers.conf 範本 + README 改以 install.sh 為主流程

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

§14

Task 12: Linux e2e 手動驗證清單(無法在開發機跑)

Files: 無(驗證 only)。在一台客戶型 Linux server(已裝 docker)上跑。

cd ~/Projects/Billows/Audit-Manager/evidence-agent
docker save evidence-agent:0.1.0 -o evidence-agent-0.1.0.image.tar
tar czf evidence-agent-deploy-0.1.0.tar.gz \
  deploy/install.sh deploy/install-lib.sh deploy/docker-compose.yml deploy/docker-compose.demo.yml \
  deploy/nginx deploy/collect-host-id.sh deploy/.env.example deploy/answers.conf.example deploy/README.md \
  evidence-agent-0.1.0.image.tar

image tar 命名須與 pf_ensure_image 推導一致:evidence-agent-0.1.0.image.tar

tar xzf evidence-agent-deploy-0.1.0.tar.gz && cd deploy && ./install.sh

驗:preflight 過 → 答 4 題 → 自動 load image / collect-host-id / 起 agent → 等到憑證 → 起 nginx → 自我驗證印交棒。

    • ls certsagent.crt agent.key ca.crt
    • docker compose ps file-agent 8080 對外、nginx 8443 有
    • .env 權限 600;answers.conf grep -E 'PASSWORD|SECRET_KEY|TOKEN' → 無命中
    • docker compose config 確認 file-agent 無 published 8080
    • 再跑一次 ./install.sh:讀既有 .env/answers.conf 當預設、不刪 pgdata/filedata/certs、可順利再起。
docker compose -f docker-compose.yml -f docker-compose.demo.yml config | grep 8080   # 應有 8080
    • 回雲端 FE 設 storage-config 指向本台 → 上傳 / 下載 / 預覽都成功。
    • 從雲端跑三層認證(400/200/401)。
    • changelog(docs/changelog/,type=feat)、依需要回填 design/README、Notion 任務 — 依 closing-and-handoff skill,等 user 下令

§15

完成準則(Definition of Done)

  • bash deploy/test/test_install_lib.shALL PASS
  • Linux 上「解壓一包 → ./install.sh → 答 4 題 → 完成」全程通,憑證兩段式由精靈自動處理,部署人員不需手動 docker compose 任何一步。
  • full 模式 file-agent 不對外 8080;.env 600;answers.conf 零密碼。
  • 重跑冪等,pgdata/filedata/certs 全程不被刪。