Files
awoooi/scripts/reboot-recovery/awoooi-startup-110.sh
Your Name d84be37e26
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 48s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
fix(reboot): repair 110 ssh control path on startup
2026-07-02 10:53:16 +08:00

848 lines
35 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 192.168.0.110 重開機自動恢復腳本
# 2026-04-05 ogt: 根據第二次重開機事故建立
# 部署位置: /usr/local/bin/awoooi-startup-110.sh (on 192.168.0.110)
# systemd unit: /etc/systemd/system/awoooi-startup-110.service
#
# 已知問題處理:
# - Docker BoltDB 損壞 (network/files/local-kv.db, volumes/metadata.db)
# - Docker container metadata JSON 損壞dockerd 卡在 startup / loading containers
# - 舊容器使用已不存在的 Docker network (需要 docker rm -f 全部)
# - Harbor nginx 依賴 harbor-log (需要等 harbor-log healthy 後才 compose up)
set -uo pipefail
LOG="/var/log/awoooi-startup-110.log"
DOCKER_START_TIMEOUT_SECONDS="${AWOOOI_DOCKER_START_TIMEOUT_SECONDS:-45}"
DOCKER_ACTIVE_WAIT_ATTEMPTS="${AWOOOI_DOCKER_ACTIVE_WAIT_ATTEMPTS:-18}"
DOCKER_ACTIVE_WAIT_SLEEP_SECONDS="${AWOOOI_DOCKER_ACTIVE_WAIT_SLEEP_SECONDS:-5}"
exec > >(tee -a "$LOG") 2>&1
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
run_bounded() {
local seconds="$1"
shift
if command -v timeout >/dev/null 2>&1; then
timeout "$seconds" "$@"
else
"$@"
fi
}
start_docker_bounded() {
log "bounded start docker.service/docker.socket timeout=${DOCKER_START_TIMEOUT_SECONDS}s"
run_bounded "$DOCKER_START_TIMEOUT_SECONDS" systemctl start docker.socket docker.service || true
}
wait_docker_active() {
local i state
for i in $(seq 1 "$DOCKER_ACTIVE_WAIT_ATTEMPTS"); do
state=$(systemctl is-active docker 2>/dev/null || true)
log "Docker wait ${i}/${DOCKER_ACTIVE_WAIT_ATTEMPTS}: ${state:-unknown}"
[ "$state" = "active" ] && return 0
sleep "$DOCKER_ACTIVE_WAIT_SLEEP_SECONDS"
done
return 1
}
stop_docker_bounded_for_metadata_recovery() {
local state
run_bounded 25 systemctl stop docker.service docker.socket || true
state=$(systemctl is-active docker 2>/dev/null || true)
if [ "$state" = "active" ] || [ "$state" = "activating" ] || [ "$state" = "deactivating" ]; then
log "Docker stop still ${state}; TERM main process for metadata quarantine recovery"
systemctl kill --kill-who=main --signal=TERM docker.service || true
sleep 4
run_bounded 20 systemctl stop docker.service docker.socket || true
fi
}
DOCKER_CORRUPT_METADATA_QUARANTINED_COUNT=0
quarantine_corrupt_docker_container_metadata() {
local ids qdir id src count
DOCKER_CORRUPT_METADATA_QUARANTINED_COUNT=0
ids=$(journalctl -u docker.service --since "-30 min" --no-pager 2>/dev/null \
| awk '/failed to load container|invalid character|json: cannot unmarshal|unexpected EOF/ {
line=$0
while (match(line, /[0-9a-f]{64}/)) {
print substr(line, RSTART, RLENGTH)
line=substr(line, RSTART + RLENGTH)
}
}' \
| sort -u)
if [ -z "$ids" ]; then
log "Docker corrupt container metadata: journal 未找到可 quarantine 的 container id"
return 0
fi
qdir="/var/lib/docker/containers/.awoooi-corrupt-metadata-quarantine-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$qdir"
count=0
for id in $ids; do
case "$id" in
*[!0-9a-f]*|"")
log "跳過不合法 container id: $id"
continue
;;
esac
src="/var/lib/docker/containers/$id"
if [ -d "$src" ]; then
mv "$src" "$qdir/$id"
count=$((count + 1))
log "quarantine corrupt Docker container metadata: $id"
else
log "corrupt metadata candidate already absent: $id"
fi
done
DOCKER_CORRUPT_METADATA_QUARANTINED_COUNT="$count"
log "Docker corrupt metadata quarantine dir=$qdir count=$count"
}
repair_ssh_control_path_metadata() {
local script="${AWOOOI_110_SSH_REPAIR_SCRIPT:-/usr/local/bin/repair-110-ssh-publickey-auth-local.sh}"
local target_user="${AWOOOI_110_SSH_REPAIR_USER:-wooo}"
local reload_ssh="${AWOOOI_110_SSH_RELOAD_AFTER_REPAIR:-0}"
log "[0/6] 修復 110 SSH control path metadata..."
if [ ! -x "$script" ]; then
log "⚠️ SSH control path metadata repair helper missing or not executable: $script"
return 0
fi
if TARGET_USER="$target_user" RELOAD_SSH="$reload_ssh" "$script" --apply; then
log "✅ 110 SSH control path metadata guard completed target_user=$target_user reload_ssh=$reload_ssh"
else
log "⚠️ 110 SSH control path metadata guard failed; continuing startup"
fi
}
log "=== 192.168.0.110 啟動序列開始 ==="
repair_ssh_control_path_metadata
# ──────────────────────────────────────────────
# STEP 1: Docker 修復(若 BoltDB 損壞)
# ──────────────────────────────────────────────
log "[1/5] 檢查 Docker..."
if ! systemctl is-active docker >/dev/null 2>&1; then
log "Docker 未啟動,嘗試啟動..."
start_docker_bounded
wait_docker_active || true
fi
if ! systemctl is-active docker >/dev/null 2>&1; then
log "Docker bounded start 未 active檢查 corrupt container metadata..."
quarantine_corrupt_docker_container_metadata
if [ "$DOCKER_CORRUPT_METADATA_QUARANTINED_COUNT" -gt 0 ]; then
stop_docker_bounded_for_metadata_recovery
start_docker_bounded
wait_docker_active || true
fi
fi
if ! systemctl is-active docker >/dev/null 2>&1; then
log "Docker 啟動失敗,修復 BoltDB..."
# 清除所有已知 BoltDB 損壞點
NETWORK_DB="/var/lib/docker/network/files/local-kv.db"
VOLUMES_DB="/var/lib/docker/volumes/metadata.db"
if [ -f "$NETWORK_DB" ]; then
cp "$NETWORK_DB" "${NETWORK_DB}.bak.$(date +%Y%m%d%H%M%S)"
rm -f "$NETWORK_DB"
log "清除損壞的 local-kv.db"
fi
if [ -f "$VOLUMES_DB" ]; then
cp "$VOLUMES_DB" "${VOLUMES_DB}.bak.$(date +%Y%m%d%H%M%S)"
rm -f "$VOLUMES_DB"
log "清除損壞的 metadata.db"
fi
# 清除 buildkit 快取(也可能損壞)
find /var/lib/docker/buildkit -name "*.db" -delete 2>/dev/null || true
start_docker_bounded
wait_docker_active || true
systemctl is-active docker && log "✅ Docker 修復成功" || { log "❌ Docker 修復失敗"; exit 1; }
else
log "✅ Docker 已 active"
fi
# ──────────────────────────────────────────────
# STEP 2: 清除孤兒容器(舊容器的 network 已不存在)
# ──────────────────────────────────────────────
log "[2/5] 清除孤兒容器..."
STALE=$(docker ps -a --format "{{.Names}}\t{{.Status}}" | grep "Exited (128)\|Exited (137)" | awk '{print $1}')
if [ -n "$STALE" ]; then
log "發現孤兒容器: $(echo $STALE | tr '\n' ' ')"
echo "$STALE" | xargs docker rm -f 2>/dev/null || true
docker network prune -f 2>/dev/null || true
log "✅ 孤兒容器清除完成"
else
log "✅ 無孤兒容器"
fi
# ──────────────────────────────────────────────
# STEP 3: Harbor嚴格時序harbor-log first
# 2026-04-05 Claude Code: 修復 Race Condition —
# 舊邏輯compose up -d 全部 → 等 harbor-log healthy → compose up -d 全部
# 問題:第一次 up 時 harbor-core/db/portal 嘗試連 syslog:1514 失敗 exit
# restart:always 重試直到放棄,第二次 compose up 無效
# 新邏輯:只啟動 harbor-log → 等 healthy → 啟動其餘全部
# 首席架構師 Review I1: 使用 lockfile 防止 watchdog 並行修復
# ──────────────────────────────────────────────
log "[3/5] 啟動 Harbor..."
HARBOR_DIR="/home/wooo/harbor/harbor"
HARBOR_LOCKFILE="/var/lock/harbor-repair.lock"
if [ -f "$HARBOR_DIR/docker-compose.yml" ]; then
cd "$HARBOR_DIR"
# I1: 持有 lockfile防止 harbor-watchdog 同時修復
exec 9>"$HARBOR_LOCKFILE"
flock -w 30 9 || { log "⚠️ 無法取得 Harbor lock跳過watchdog 可能正在修復)"; return 0; }
# Phase 1: 清除任何 Exited 狀態的 Harbor 容器(避免 restart:always 干擾)
# 使用 --filter 取代 grep/awk更精確不受欄位格式影響
HARBOR_EXITED=$(docker ps -a --filter name="harbor-" --filter status=exited --format "{{.Names}}" 2>/dev/null || true)
if [ -n "$HARBOR_EXITED" ]; then
log "Phase 1: 清除 Exited Harbor 容器: $(echo "$HARBOR_EXITED" | tr '\n' ' ')"
echo "$HARBOR_EXITED" | xargs docker rm -f 2>&1 | tail -3 || true
else
log "Phase 1: 無 Exited 容器"
fi
# Phase 2: 只啟動 harbor-logsyslog 接收端,其他容器的前置依賴)
log "Phase 2: 啟動 harbor-log..."
docker compose up -d harbor-log 2>&1 | tail -3
# Phase 3: 等待 harbor-log healthy最多 90 秒)
log "Phase 3: 等待 harbor-log healthy最多 90s..."
for i in $(seq 1 18); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' harbor-log 2>/dev/null || echo "missing")
if [ "$STATUS" = "healthy" ]; then
log "✅ harbor-log healthy (${i}x5s)"
break
fi
sleep 5
done
STATUS=$(docker inspect --format='{{.State.Health.Status}}' harbor-log 2>/dev/null || echo "unknown")
if [ "$STATUS" = "healthy" ]; then
# Phase 4: harbor-log 就緒後才啟動其餘組件
log "Phase 4: 啟動 Harbor 全組件..."
docker compose up -d 2>&1 | tail -5
# S1: 等 30s 讓 harbor-core 完成初始化(之前 5s 不夠)
sleep 30
# 注意Harbor /v2/ 回 401需認證不能用 -f flag會把 401 當失敗)
HTTP=$(curl -s --max-time 5 -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/v2/ 2>/dev/null || echo "0")
if [ "$HTTP" = "401" ] || [ "$HTTP" = "200" ]; then
log "✅ Harbor 就緒 (HTTP $HTTP)"
else
log "⚠️ Harbor API 未就緒 (HTTP $HTTP)harbor-watchdog 會在 60s 後自動修復"
fi
else
log "❌ harbor-log 未 healthy (status=$STATUS),跳過其餘 Harbor 容器"
log " harbor-watchdog 會在 60s 後自動偵測並修復"
fi
else
log "⚠️ 找不到 Harbor compose 檔案"
fi
# ──────────────────────────────────────────────
# STEP 4: 其他服務Gitea, Langfuse, Monitoring
# ──────────────────────────────────────────────
log "[4/6] 啟動其他服務Gitea, Langfuse, Monitoring..."
GITEA_DIR="/home/wooo/gitea"
if [ -f "$GITEA_DIR/docker-compose.yml" ]; then
cd "$GITEA_DIR"
docker compose up -d 2>&1 | tail -3
log "✅ Gitea 啟動指令已發送"
fi
LANGFUSE_DIR="/home/wooo/langfuse"
if [ -f "$LANGFUSE_DIR/docker-compose.yml" ]; then
cd "$LANGFUSE_DIR"
docker compose up -d 2>&1 | tail -3
log "✅ Langfuse 啟動指令已發送"
fi
MONITORING_DIR="/home/wooo/monitoring"
if [ -f "$MONITORING_DIR/docker-compose.yml" ]; then
cd "$MONITORING_DIR"
docker compose up -d 2>&1 | tail -3
log "✅ Monitoring (Prometheus/Grafana/Alertmanager) 啟動指令已發送"
sleep 10
# 驗證 Alertmanager 是否啟動
if curl -sf --max-time 5 http://localhost:9093/-/healthy >/dev/null 2>&1; then
log "✅ Alertmanager healthy"
else
log "⚠️ Alertmanager 尚未就緒,等待 20 秒..."
sleep 20
curl -sf --max-time 5 http://localhost:9093/-/healthy >/dev/null 2>&1 && log "✅ Alertmanager 就緒" || log "❌ Alertmanager 未就緒,需手動檢查"
fi
fi
# ──────────────────────────────────────────────
# STEP 5: SignOz
# ──────────────────────────────────────────────
log "[5/6] 啟動 SignOz..."
SIGNOZ_DIR="/home/wooo/signoz/deploy/docker"
if [ -f "$SIGNOZ_DIR/docker-compose.yaml" ]; then
cd "$SIGNOZ_DIR"
docker compose up -d 2>&1 | tail -5
log "✅ SignOz 啟動指令已發送"
fi
# ──────────────────────────────────────────────
# STEP 6: Gitea Act RunnerCI/CD 核心)
# 2026-04-05 Claude Code: 加入 — 解決重開機後 Gitea runner 離線、CD 失效
# 2026-06-27 Codex: 110 runner labels 收斂,避免接泛用 shared CI。
# 2026-06-27 Codex: 110 是 production / registry / observability 主機;
# legacy runner 預設維持停用降壓controlled drain lane 可在受控授權下啟動。
# ──────────────────────────────────────────────
log "[6/6] 檢查 Gitea Act Runnerlegacy fail-closedcontrolled drain 守門自動啟動)..."
RUNNER_DIR="/home/wooo/act-runner"
RUNNER_SERVICE="gitea-act-runner-host.service"
RUNNER_ENABLE_SENTINEL="/run/awoooi-runner-host-enabled"
CD_LANE_DIR="/home/wooo/awoooi-cd-lane"
CD_LANE_SERVICE="awoooi-cd-lane.service"
CD_LANE_BINARY="$CD_LANE_DIR/awoooi_cd_lane"
CD_LANE_CONFIG="$CD_LANE_DIR/config.yaml"
CD_LANE_DRAIN_DIR="/home/wooo/awoooi-cd-lane-drain"
CD_LANE_DRAIN_SERVICE="awoooi-cd-lane-drain.service"
CD_LANE_DRAIN_BINARY="$CD_LANE_DRAIN_DIR/awoooi_cd_lane_controlled"
CD_LANE_DRAIN_CONFIG="$CD_LANE_DRAIN_DIR/config.yaml"
CD_LANE_ENABLE_SENTINEL="/run/awoooi-cd-lane-enabled"
START_GITEA_RUNNER_ON_BOOT="${AWOOOI_START_GITEA_RUNNER_ON_BOOT:-0}"
START_CONTROLLED_CD_LANE="${AWOOOI_START_CONTROLLED_CD_LANE:-1}"
START_GITEA_RUNNER_ALLOWED=0
START_CD_LANE_ALLOWED=0
RUNNER_FAIL_CLOSED_SERVICES=(
"awoooi-cd-lane.service"
"awoooi-direct-runner-open.service"
"awoooi-direct-runner.service"
"gitea-act-runner-host.service"
"gitea-act-runner-awoooi-controlled.service"
"gitea-awoooi-controlled-runner.service"
"gitea-act-runner-awoooi-open.service"
)
RUNNER_FAIL_CLOSED_BINARY_PATHS=(
"/home/wooo/awoooi-cd-lane/awoooi_cd_lane"
"/home/wooo/act-runner/act_runner"
"/home/wooo/act-runner/act_runner.real-20260628-runner-pressure-guard"
"/home/wooo/act-runner-controlled/act_runner"
"/home/wooo/awoooi-controlled-runner/awoooi_controlled_runner"
)
# Host runner still needs both keys. The direct cd-lane stays fail-closed until
# it is migrated or hard-limited outside this production host pressure lane.
# The drain lane is a narrow post-reboot recovery path; it auto-starts only when
# config/binary/label/restore-source guardrails pass, and can be explicitly
# disabled with AWOOOI_START_CONTROLLED_CD_LANE=0.
if [ "$START_GITEA_RUNNER_ON_BOOT" = "1" ] && [ -e "$RUNNER_ENABLE_SENTINEL" ]; then
START_GITEA_RUNNER_ALLOWED=1
fi
mask_runner_unit_file() {
local unit="$1"
local unit_dir="$2"
local owner_user="${3:-}"
local unit_file="$unit_dir/$unit"
local quarantine_stamp
quarantine_stamp="$(date +%Y%m%d%H%M%S)"
mkdir -p "$unit_dir" >/dev/null 2>&1 || true
if [ -L "$unit_file" ] && [ "$(readlink "$unit_file" 2>/dev/null || true)" = "/dev/null" ]; then
return 0
fi
if [ -e "$unit_file" ] || [ -L "$unit_file" ]; then
chattr -i "$unit_file" >/dev/null 2>&1 || true
mv "$unit_file" "${unit_file}.quarantined-runner-incident-${quarantine_stamp}" >/dev/null 2>&1 || true
fi
ln -s /dev/null "$unit_file" >/dev/null 2>&1 || true
if [ -n "$owner_user" ]; then
chown -h "$owner_user:$owner_user" "$unit_file" >/dev/null 2>&1 || true
fi
}
guard_runner_binary_fail_closed() {
local path="$1"
local tmp
local quarantine_stamp
quarantine_stamp="$(date +%Y%m%d%H%M%S)"
if [ -e "$path" ]; then
chattr -i "$path" >/dev/null 2>&1 || true
if file "$path" 2>/dev/null | grep -qi "ELF"; then
mv "$path" "${path}.quarantined-runner-incident-${quarantine_stamp}" >/dev/null 2>&1 || true
chmod 0400 "${path}.quarantined-runner-incident-${quarantine_stamp}" >/dev/null 2>&1 || true
chattr +i "${path}.quarantined-runner-incident-${quarantine_stamp}" >/dev/null 2>&1 || true
fi
fi
tmp="$(mktemp)"
cat >"$tmp" <<'EOF'
#!/usr/bin/env bash
set -eu
echo "AWOOOI host runner is fail-closed on 110 after 2026-06-28 pressure incident; migrate or rate-limit before enabling." >&2
exit 75
EOF
install -o root -g root -m 0755 "$tmp" "$path" >/dev/null 2>&1 || true
rm -f "$tmp"
chattr +i "$path" >/dev/null 2>&1 || true
}
install_cd_lane_fail_closed_unit() {
local unit_file="/etc/systemd/system/awoooi-cd-lane.service"
local quarantine_stamp
quarantine_stamp="$(date +%Y%m%d%H%M%S)"
systemctl mask awoooi-cd-lane.service >/dev/null 2>&1 || true
if [ -e "$unit_file" ] || [ -L "$unit_file" ]; then
chattr -i "$unit_file" >/dev/null 2>&1 || true
if ! { [ -L "$unit_file" ] && [ "$(readlink "$unit_file" 2>/dev/null || true)" = "/dev/null" ]; }; then
mv "$unit_file" "${unit_file}.quarantined-runner-incident-${quarantine_stamp}" >/dev/null 2>&1 || true
fi
fi
ln -sfn /dev/null "$unit_file" >/dev/null 2>&1 || true
}
install_controlled_cd_lane_unit() {
local unit_file="/etc/systemd/system/$CD_LANE_SERVICE"
local tmp
chattr -i "$unit_file" "$CD_LANE_BINARY" >/dev/null 2>&1 || true
tmp="$(mktemp)"
cat >"$tmp" <<EOF
[Unit]
Description=AWOOOI controlled CD lane
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=wooo
WorkingDirectory=${CD_LANE_DIR}/data
Environment=HOME=/home/wooo
Environment=AWOOOI_CONTROLLED_RUNNER_OPEN=1
Environment=HOST_WEB_BUILD_PRESSURE_ATTEMPTS=1
Environment=HOST_WEB_BUILD_PRESSURE_SLEEP_SECONDS=1
ExecStart=${CD_LANE_BINARY} daemon --config ${CD_LANE_CONFIG}
Restart=always
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=3700
SuccessExitStatus=0 130 143
CPUAccounting=true
CPUQuota=250%
MemoryAccounting=true
MemoryHigh=8G
MemoryMax=12G
TasksAccounting=true
TasksMax=512
IOAccounting=true
IOWeight=100
[Install]
WantedBy=multi-user.target
EOF
install -o root -g root -m 0644 "$tmp" "$unit_file" >/dev/null 2>&1 || true
rm -f "$tmp"
}
install_controlled_cd_lane_drain_unit() {
local unit_file="/etc/systemd/system/$CD_LANE_DRAIN_SERVICE"
local tmp
chattr -i "$unit_file" "$CD_LANE_DRAIN_BINARY" >/dev/null 2>&1 || true
if [ -L "$unit_file" ] && [ "$(readlink "$unit_file" 2>/dev/null || true)" = "/dev/null" ]; then
rm -f "$unit_file" >/dev/null 2>&1 || true
fi
tmp="$(mktemp)"
cat >"$tmp" <<EOF
[Unit]
Description=AWOOOI controlled CD lane drain bypass for old queued guards
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
ConditionPathExists=${CD_LANE_DRAIN_DIR}/data/.runner
[Service]
Type=simple
User=wooo
WorkingDirectory=${CD_LANE_DRAIN_DIR}/data
Environment=HOME=/home/wooo
Environment=AWOOOI_CONTROLLED_RUNNER_OPEN=1
Environment=HOST_WEB_BUILD_PRESSURE_ATTEMPTS=1
Environment=HOST_WEB_BUILD_PRESSURE_SLEEP_SECONDS=1
ExecStart=${CD_LANE_DRAIN_BINARY} daemon --config ${CD_LANE_DRAIN_CONFIG}
Restart=always
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=3700
SuccessExitStatus=0 130 143
CPUAccounting=true
CPUQuota=250%
MemoryAccounting=true
MemoryHigh=8G
MemoryMax=12G
TasksAccounting=true
TasksMax=512
IOAccounting=true
IOWeight=100
[Install]
WantedBy=multi-user.target
EOF
install -o root -g root -m 0644 "$tmp" "$unit_file" >/dev/null 2>&1 || true
rm -f "$tmp"
}
cd_lane_config_path_is_controlled() {
local config_path="$1"
[ -f "$config_path" ] || return 1
grep -Eq '^[[:space:]]+capacity:[[:space:]]*1[[:space:]]*$' "$config_path" || return 1
grep -q 'awoooi-ubuntu:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04' "$config_path" || return 1
grep -q 'awoooi-host:host' "$config_path" || return 1
if grep -Eq '^[[:space:]]+- ".*(ubuntu-latest|stockplatform|headless|playwright)' "$config_path"; then
return 1
fi
return 0
}
cd_lane_config_is_controlled() {
cd_lane_config_path_is_controlled "$CD_LANE_CONFIG"
}
cd_lane_drain_config_is_controlled() {
cd_lane_config_path_is_controlled "$CD_LANE_DRAIN_CONFIG"
}
cd_lane_drain_is_controlled_open() {
local active
active="$(systemctl show "$CD_LANE_DRAIN_SERVICE" -p ActiveState --value 2>/dev/null || true)"
[ "$active" = "active" ] || return 1
cd_lane_drain_config_is_controlled || return 1
file "$CD_LANE_DRAIN_BINARY" 2>/dev/null | grep -qi "ELF" || return 1
return 0
}
cd_lane_drain_is_controlled_available() {
cd_lane_drain_config_is_controlled || return 1
file "$CD_LANE_DRAIN_BINARY" 2>/dev/null | grep -qi "ELF" || return 1
return 0
}
cd_lane_root_restore_sources_left() {
find /root -maxdepth 1 -type d \( \
-name 'awoooi-cd-lane-disabled-*' -o \
-name 'awoooi-cd-lane-drain-disabled-*' \
\) -print 2>/dev/null | wc -l | tr -d " "
}
quarantine_cd_lane_registration_fail_closed() {
local quarantine_dir
local lane_dir
local path
local target
rm -f /run/awoooi-cd-lane-enabled /run/awoooi-cd-lane-controlled-open >/dev/null 2>&1 || true
for lane_dir in "$CD_LANE_DIR" "$CD_LANE_DRAIN_DIR"; do
[ -d "$lane_dir" ] || continue
quarantine_dir="$lane_dir/quarantine-startup-$(date +%Y%m%d%H%M%S)"
chattr -i "$lane_dir" "$lane_dir/data" >/dev/null 2>&1 || true
mkdir -p "$quarantine_dir" >/dev/null 2>&1 || true
while IFS= read -r -d '' path; do
[ -e "$path" ] || continue
chattr -i "$path" >/dev/null 2>&1 || true
target="$quarantine_dir/$(basename "$path")"
mv "$path" "$target" >/dev/null 2>&1 || true
chmod 0400 "$target" >/dev/null 2>&1 || true
chattr +i "$target" >/dev/null 2>&1 || true
done < <(
{
find "$lane_dir" -maxdepth 1 \( -name 'config.yaml' -o -name 'config.yaml.*' -o -name '.runner' -o -name '.runner.*' \) -print0 2>/dev/null
find "$lane_dir/data" -maxdepth 1 \( -name '.runner' -o -name '.runner.*' \) -print0 2>/dev/null
} || true
)
chattr +i "$lane_dir" "$lane_dir/data" >/dev/null 2>&1 || true
done
}
quarantine_cd_lane_root_restore_sources_fail_closed() {
local final_root
local path
local target_dir
final_root="/root/awoooi-runner-restore-sources-disabled-final-$(date +%Y%m%dT%H%M%S%z)"
target_dir="$final_root/cd-lane-restore-sources"
mkdir -p "$target_dir" >/dev/null 2>&1 || true
while IFS= read -r -d '' path; do
[ -d "$path" ] || continue
chattr -R -i "$path" >/dev/null 2>&1 || true
mv "$path" "$target_dir/" >/dev/null 2>&1 || true
done < <(
{
find /root -maxdepth 1 -type d -name 'awoooi-cd-lane-disabled-*' -print0 2>/dev/null
find /root -maxdepth 1 -type d -name 'awoooi-cd-lane-drain-disabled-*' -print0 2>/dev/null
} || true
)
}
apply_cd_lane_fail_closed_guard() {
local unit
for unit in awoooi-cd-lane.service awoooi-cd-lane-drain.service; do
systemctl kill --signal=SIGKILL "$unit" >/dev/null 2>&1 || true
systemctl stop "$unit" >/dev/null 2>&1 || true
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
systemctl disable "$unit" >/dev/null 2>&1 || true
if [ "$unit" = "awoooi-cd-lane.service" ]; then
install_cd_lane_fail_closed_unit
else
systemctl mask "$unit" >/dev/null 2>&1 || mask_runner_unit_file "$unit" "/etc/systemd/system"
mask_runner_unit_file "$unit" "/etc/systemd/system"
fi
done
install_cd_lane_fail_closed_unit
pkill -KILL -f "^${CD_LANE_DIR}/awoooi_cd_lane daemon" >/dev/null 2>&1 || true
pkill -KILL -f "^${CD_LANE_DRAIN_DIR}/awoooi_cd_lane_controlled daemon" >/dev/null 2>&1 || true
quarantine_cd_lane_registration_fail_closed
quarantine_cd_lane_root_restore_sources_fail_closed
guard_runner_binary_fail_closed "$CD_LANE_DIR/awoooi_cd_lane"
guard_runner_binary_fail_closed "$CD_LANE_DRAIN_DIR/awoooi_cd_lane_controlled"
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed awoooi-cd-lane.service awoooi-cd-lane-drain.service >/dev/null 2>&1 || true
}
ensure_cd_lane_fail_closed() {
apply_cd_lane_fail_closed_guard
}
ensure_controlled_cd_lane_open() {
mkdir -p /run >/dev/null 2>&1 || true
touch /run/awoooi-cd-lane-controlled-open /run/awoooi-cd-lane-drain-ok >/dev/null 2>&1 || true
log "✅ controlled cd-lane startup override active; drain lane remains open"
}
ensure_host_runner_fail_closed() {
local unit
local binary
local wooo_uid
for unit in "${RUNNER_FAIL_CLOSED_SERVICES[@]}"; do
systemctl kill --signal=SIGKILL "$unit" >/dev/null 2>&1 || true
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
systemctl disable "$unit" >/dev/null 2>&1 || true
if [ "$unit" = "awoooi-cd-lane.service" ]; then
install_cd_lane_fail_closed_unit
else
systemctl mask "$unit" >/dev/null 2>&1 || mask_runner_unit_file "$unit" "/etc/systemd/system"
mask_runner_unit_file "$unit" "/etc/systemd/system"
fi
done
systemctl daemon-reload >/dev/null 2>&1 || true
if wooo_uid="$(id -u wooo 2>/dev/null)"; then
mkdir -p /home/wooo/.config/systemd/user >/dev/null 2>&1 || true
for unit in "${RUNNER_FAIL_CLOSED_SERVICES[@]}"; do
if [ -d "/run/user/$wooo_uid" ] && command -v runuser >/dev/null 2>&1; then
runuser -u wooo -- env XDG_RUNTIME_DIR="/run/user/$wooo_uid" systemctl --user kill --signal=SIGKILL "$unit" >/dev/null 2>&1 || true
fi
mask_runner_unit_file "$unit" "/home/wooo/.config/systemd/user" "wooo"
done
fi
pkill -KILL -f "^${RUNNER_DIR}/act_runner(\\.real-[^ ]*)? daemon" >/dev/null 2>&1 || true
quarantine_cd_lane_root_restore_sources_fail_closed
for binary in "${RUNNER_FAIL_CLOSED_BINARY_PATHS[@]}"; do
if [ "$START_CD_LANE_ALLOWED" = "1" ] && [ "$binary" = "$CD_LANE_DRAIN_BINARY" ]; then
continue
fi
guard_runner_binary_fail_closed "$binary"
done
}
CD_LANE_ROOT_RESTORE_LEFT="$(cd_lane_root_restore_sources_left)"
if [ "$START_CONTROLLED_CD_LANE" = "1" ]; then
if ! cd_lane_drain_is_controlled_available; then
log "⛔ AWOOOI_START_CONTROLLED_CD_LANE=1 但 controlled drain lane config/binary 未通過;維持 fail-closed"
elif [ "$CD_LANE_ROOT_RESTORE_LEFT" != "0" ]; then
log "⛔ AWOOOI_START_CONTROLLED_CD_LANE=1 但 root restore-source left=${CD_LANE_ROOT_RESTORE_LEFT};維持 fail-closed"
else
START_CD_LANE_ALLOWED=1
log "✅ controlled cd-lane drain preflight passed; legacy runner 仍維持 fail-closed"
fi
fi
if [ -x "$RUNNER_DIR/act_runner" ] && [ -f "$RUNNER_DIR/config.yaml" ]; then
# 若舊的 .runner 配置指向過期 hostname只有在明確允許啟動 runner
# 時才清除重新註冊;預設降壓模式不得碰 registration 狀態。
RUNNER_FILE="$RUNNER_DIR/data/.runner"
if [ "$START_GITEA_RUNNER_ALLOWED" = "1" ] && [ -f "$RUNNER_FILE" ]; then
OLD_URL=$(python3 -c "import json; d=json.load(open('$RUNNER_FILE')); print(d.get('address',''))" 2>/dev/null || echo "")
if [ "$OLD_URL" != "http://192.168.0.110:3001" ]; then
log "⚠️ runner 配置過期 ($OLD_URL),清除重新註冊..."
rm -f "$RUNNER_FILE" || true
fi
fi
# act_runner 預設 shutdown_timeout=0s會在 daemon 重啟時立刻取消
# 正在執行的 CD job。這會造成部署實際完成但 Gitea status 失真。
python3 - "$RUNNER_DIR/config.yaml" <<'PY' || true
import sys
from pathlib import Path
path = Path(sys.argv[1])
lines = path.read_text().splitlines()
if any(line.strip().startswith("shutdown_timeout:") for line in lines):
lines = [
" shutdown_timeout: 1h" if line.strip().startswith("shutdown_timeout:") else line
for line in lines
]
else:
for idx, line in enumerate(lines):
if line.strip().startswith("timeout:") and idx > 0:
lines.insert(idx + 1, " shutdown_timeout: 1h")
break
path.write_text("\n".join(lines) + "\n")
PY
# 110 是 production / registry / observability 主機,不再接泛用
# ubuntu-latest 類 job。泛用 label 會讓 StockPlatform 等其他 repo 的
# build 跑到 110造成 CPU 長時間尖峰。AWOOOI 非 host job 改用
# awoooi-ubuntuCD / post-deploy 保留 awoooi-host。
python3 - "$RUNNER_DIR/config.yaml" <<'PY' || true
import sys
from pathlib import Path
path = Path(sys.argv[1])
lines = path.read_text().splitlines()
output = []
idx = 0
while idx < len(lines):
line = lines[idx]
if line.strip() == "labels:":
output.append(line)
output.append(' - "awoooi-ubuntu:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04"')
output.append(' - "awoooi-host:host"')
idx += 1
while idx < len(lines) and (lines[idx].startswith(" - ") or not lines[idx].strip()):
idx += 1
continue
output.append(line)
idx += 1
path.write_text("\n".join(output) + "\n")
PY
if [ "$START_GITEA_RUNNER_ALLOWED" = "1" ]; then
if systemctl list-unit-files "$RUNNER_SERVICE" >/dev/null 2>&1; then
systemctl enable --now "$RUNNER_SERVICE" >/dev/null 2>&1 || true
elif ! pgrep -f "$RUNNER_DIR/act_runner daemon" >/dev/null; then
nohup "$RUNNER_DIR/run-host-runner.sh" >> "$RUNNER_DIR/host-runner.log" 2>&1 &
fi
else
if [ "$START_GITEA_RUNNER_ON_BOOT" = "1" ]; then
log "⛔ AWOOOI_START_GITEA_RUNNER_ON_BOOT=1 但缺少 $RUNNER_ENABLE_SENTINELrunner fail-closed"
else
log "⏸️ Gitea host runner 維持停用;需同時設定 AWOOOI_START_GITEA_RUNNER_ON_BOOT=1 與建立 $RUNNER_ENABLE_SENTINEL 才允許 startup 啟動"
fi
ensure_host_runner_fail_closed
fi
# 已停用 Docker-wrapped runner避免它搶走 host label job。
# 若手動執行此 recovery script 時仍有 task container送 SIGINT
# 讓 act_runner drain不再接新 job並等手上的 job 收尾。
docker update --restart=no gitea-runner >/dev/null 2>&1 || true
if docker ps --format '{{.Names}}' | grep -Eq '^(GITEA-ACTIONS-|[A-Za-z0-9][A-Za-z0-9_.-]*-(cd|code-review)-[0-9]+-)'; then
log "⚠️ Gitea Actions task container still running; draining docker-wrapped gitea-runner"
docker kill --signal=SIGINT gitea-runner >/dev/null 2>&1 || true
else
docker stop -t 3700 gitea-runner >/dev/null 2>&1 || true
fi
sleep 15
# 驗證 runner 已連線 Gitea
if pgrep -f "$RUNNER_DIR/act_runner daemon" >/dev/null; then
log "⚠️ Gitea host act_runner 目前正在執行;請確認是否為受控限流 / 搬遷後狀態"
elif [ "$START_GITEA_RUNNER_ALLOWED" = "1" ]; then
log "⚠️ Gitea host act_runner 可能尚未啟動,查看: $RUNNER_DIR/host-runner.log"
else
log "✅ Gitea host act_runner 維持 inactive 降壓狀態"
fi
else
log "⚠️ 找不到 act-runner binary/config: $RUNNER_DIR"
fi
if [ "$START_CD_LANE_ALLOWED" = "1" ]; then
install_controlled_cd_lane_drain_unit
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl unmask "$CD_LANE_DRAIN_SERVICE" >/dev/null 2>&1 || true
systemctl enable --now "$CD_LANE_DRAIN_SERVICE" >/dev/null 2>&1 || true
ensure_controlled_cd_lane_open
else
log "✅ controlled cd-lane remains closed because guardrails failed or AWOOOI_START_CONTROLLED_CD_LANE=0"
fi
# ──────────────────────────────────────────────
# STEP 7: SentryError Tracking
# 2026-04-05 Claude Code: 加入 — 解決重開機後 Sentry 未自動啟動
# 安裝位置: /opt/sentry (2026-03-24 已安裝)
# DSN: awoooi-web :2, awoooi-api :3 (見 memory/project_sentry_full_integration.md)
#
# 已知重開機後損壞問題 (2026-04-05 事故記錄):
# - sentry-postgres: WAL 損壞 → pg_resetwal -f
# - sentry-redis: dump.rdb 損壞 → 刪除重建 (redis 是 session cache可丟失)
# - sentry-clickhouse: system table parts 損壞 → 刪除對應 store/ 目錄
# ──────────────────────────────────────────────
log "[7/7] 啟動 Sentry..."
SENTRY_DIR="/opt/sentry"
if [ -d "$SENTRY_DIR" ]; then
cd "$SENTRY_DIR"
# 修復 Sentry PostgreSQL WAL (若損壞)
SENTRY_PG_VOL="sentry-postgres"
PG_RUNNING=$(docker ps --filter name=sentry-self-hosted-postgres-1 --format "{{.Status}}" 2>/dev/null | head -1)
if echo "$PG_RUNNING" | grep -q "Restarting"; then
log "⚠️ sentry-postgres 損壞,嘗試 pg_resetwal 修復..."
docker run --rm -u 999 -v ${SENTRY_PG_VOL}:/var/lib/postgresql/data \
postgres:14 pg_resetwal -f /var/lib/postgresql/data 2>&1 | head -3 || true
log "✅ sentry-postgres WAL 修復完成"
fi
# 修復 Sentry Redis (若 dump.rdb 損壞)
REDIS_STATUS=$(docker inspect sentry-self-hosted-redis-1 --format='{{.State.Status}}' 2>/dev/null || echo "missing")
if [ "$REDIS_STATUS" = "restarting" ]; then
log "⚠️ sentry-redis 損壞,清除 dump.rdb..."
docker run --rm --user root -v sentry-redis:/data alpine \
sh -c 'rm -f /data/dump.rdb && echo cleared' 2>/dev/null || true
log "✅ sentry-redis dump.rdb 已清除"
fi
docker compose up -d 2>&1 | tail -5
log "✅ Sentry 啟動指令已發送 (啟動約需 2-3 分鐘)"
sleep 20
# 非阻塞驗證Sentry 啟動慢,只做快速健康檢查
HTTP_CODE=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" http://localhost:9000/ 2>/dev/null || echo "0")
if echo "$HTTP_CODE" | grep -qE "^(200|302|400)$"; then
log "✅ Sentry 已回應 (HTTP $HTTP_CODE)"
else
log "⚠️ Sentry 尚未就緒(正常現象,通常需 2-3 分鐘HTTP=$HTTP_CODE"
fi
else
log "⚠️ 找不到 Sentry 目錄: $SENTRY_DIR"
fi
# ──────────────────────────────────────────────
# 完成
# ──────────────────────────────────────────────
log "=== 192.168.0.110 啟動序列完成 ==="
log "Harbor: http://192.168.0.110:5000"
log "Gitea: http://192.168.0.110:3001"
log "Grafana: http://192.168.0.110:3002"
log "Alertmanager: http://192.168.0.110:9093"
log "Gitea Runner: systemctl --user status gitea-act-runner-host.service"
log "Sentry: http://192.168.0.110:9000"
exit 0