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.md 與 deploy/ 下既有檔。
規範重點(每個 commit 都守):
git add 檔名、禁用 -am、commit 訊息末尾加 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>、push 等 user 明示。pgdata/filedata/certs;不把密碼/token/key 印到 stdout 或寫進 answers.conf;.env 一律 chmod 600。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_port、orch_wait_for 的判斷邏輯)不直接呼叫 docker,可在 mac 測;真正碰 docker 的薄 wrapper 函式(orch_compose_up、vrf_*)在 Linux 手動驗。install-lib.sh 後只測純函式;碰 docker 的函式用 shell function shadow 樁掉(例如測試內 docker(){ ...; })。相容性注意: 被測試覆蓋的函式一律寫成 bash 3.2 相容(不用關聯陣列 / ${var^^} 等 4.x 語法),確保 mac 開發機跑得動。install.sh 本體可用 bash,但目標執行環境是客戶 Linux。
Files:
deploy/test/assert.shdeploy/test/test_install_lib.shdeploy/install-lib.sh(空殼,先放 shebang + 區段註解)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_summarydeploy/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>"Files:
deploy/install-lib.sh(加 log_* / ask_*)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>"Files:
deploy/install-lib.shdeploy/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 ipRun: 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>"Files:
deploy/install-lib.shdeploy/test/test_install_lib.shpw="$(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。
Files:
deploy/install-lib.shdeploy/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"
}Files:
deploy/install-lib.shdeploy/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帶。
Files:
deploy/install-lib.shdeploy/test/test_install_lib.sh說明:pf_has_cmd、pf_check_port(可樁)、pf_check_host_files、pf_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 sspf_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,避免多檔誤選。
Files:
deploy/docker-compose.ymldeploy/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>"Files:
deploy/install-lib.shdeploy/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; }Files:
deploy/install-lib.shdeploy/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,非遺漏。
Files:
deploy/install.sh--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-runExpected:印出四階段、[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>"Files:
deploy/answers.conf.exampledeploy/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=300git 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>"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.tarimage 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 certs 有 agent.crt agent.key ca.crtdocker 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 # 應有 8080docs/changelog/,type=feat)、依需要回填 design/README、Notion 任務 — 依 closing-and-handoff skill,等 user 下令。bash deploy/test/test_install_lib.sh → ALL PASS。./install.sh → 答 4 題 → 完成」全程通,憑證兩段式由精靈自動處理,部署人員不需手動 docker compose 任何一步。.env 600;answers.conf 零密碼。pgdata/filedata/certs 全程不被刪。