flnxi-Wu/scripts/smoke-install.sh
2026-06-01 15:26:33 +08:00

410 lines
11 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.

#!/usr/bin/env bash
# FLNXI customer install smoke check for NAS/Linux hosts.
set -euo pipefail
PROJECT_DIR="."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env"
APP_SERVICE="app"
POSTGRES_SERVICE="postgres"
MINIO_SERVICE="minio"
START=0
PREFLIGHT_ONLY=0
PREPARE_DIRECTORIES=0
SKIP_MIGRATION_STATUS=0
SKIP_OBJECT_STORAGE_CHECK=0
HEALTH_TIMEOUT_SECONDS=120
PROBLEMS=()
info() {
printf '[INFO] %s\n' "$1"
}
ok() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1" >&2
}
fail() {
printf '[ERROR] %s\n' "$1" >&2
exit 1
}
usage() {
cat <<'EOF'
FLNXI NAS/Linux 首装验收脚本
用法:
bash scripts/smoke-install.sh [选项]
常用选项:
--project-dir <path> 部署目录,默认当前目录
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
--env-file <file> 环境文件名,默认 .env
--start 执行 docker compose up -d
--preflight-only 只做安装前检查,不检查容器
--prepare-directories 缺少 uploads/data 时创建并检查权限
--skip-migration-status 跳过 Prisma 迁移状态检查
--skip-object-storage-check 跳过 MinIO bucket 检查
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
-h, --help 显示帮助
说明:
如果当前账号没有 Docker 权限,请用 sudo 运行本脚本。
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir)
PROJECT_DIR="${2:?--project-dir requires a value}"
shift 2
;;
--compose-file)
COMPOSE_FILE="${2:?--compose-file requires a value}"
shift 2
;;
--env-file)
ENV_FILE="${2:?--env-file requires a value}"
shift 2
;;
--app-service)
APP_SERVICE="${2:?--app-service requires a value}"
shift 2
;;
--postgres-service)
POSTGRES_SERVICE="${2:?--postgres-service requires a value}"
shift 2
;;
--minio-service)
MINIO_SERVICE="${2:?--minio-service requires a value}"
shift 2
;;
--start)
START=1
shift
;;
--preflight-only)
PREFLIGHT_ONLY=1
shift
;;
--prepare-directories)
PREPARE_DIRECTORIES=1
shift
;;
--skip-migration-status)
SKIP_MIGRATION_STATUS=1
shift
;;
--skip-object-storage-check)
SKIP_OBJECT_STORAGE_CHECK=1
shift
;;
--health-timeout-seconds)
HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "未知参数:$1"
;;
esac
done
add_problem() {
PROBLEMS+=("$1")
}
read_env_value() {
local key="$1"
local path="$2"
local line
if [[ ! -f "$path" ]]; then
return 0
fi
line="$(grep -E "^[[:space:]]*${key}=" "$path" | tail -n 1 || true)"
[[ -z "$line" ]] && return 0
printf '%s' "$line" | sed -E "s/^[^=]*=//;s/\r$//;s/^['\"]//;s/['\"]$//"
}
is_placeholder_value() {
local value="${1:-}"
[[ -z "${value//[[:space:]]/}" ]] && return 0
[[ "$value" =~ ^(replace-with|your-|<|>) ]] && return 0
[[ "$value" == *"example.com"* ]] && return 0
[[ "$value" == *"192.168.x.x"* ]] && return 0
[[ "$value" =~ (^|[^0-9])x\.x([^0-9]|$) ]] && return 0
return 1
}
required_env() {
local name="$1"
local value
value="$(read_env_value "$name" "$ENV_PATH")"
if is_placeholder_value "$value"; then
add_problem "$name 还不是客户现场真实值"
fi
}
test_app_image() {
local app_image image_name
app_image="$(read_env_value "APP_IMAGE" "$ENV_PATH")"
if is_placeholder_value "$app_image"; then
add_problem "APP_IMAGE 还不是完整客户版本镜像"
return
fi
image_name="${app_image##*/}"
if [[ "$image_name" == *":latest" ]]; then
add_problem "APP_IMAGE 不能使用 latest,必须固定到明确版本号"
fi
if [[ "$app_image" != *"@sha256:"* && "$image_name" != *":"* ]]; then
add_problem "APP_IMAGE 缺少版本标签,例如 :1.0.1"
fi
if [[ "$app_image" == *"/material-dev/"* ]]; then
warn "APP_IMAGE 当前指向 material-dev 测试镜像;正式客户交付应使用 material 正式镜像。"
fi
}
is_external_object_storage() {
local endpoint host
endpoint="$(read_env_value "S3_ENDPOINT" "$ENV_PATH")"
[[ -z "$endpoint" ]] && return 1
host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')"
[[ "$host" != "minio" ]]
}
test_directory_ready() {
local name="$1"
local path="$PROJECT_DIR_ABS/$name"
if [[ ! -d "$path" ]]; then
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
mkdir -p "$path"
ok "已创建目录:$name"
else
add_problem "缺少 $name 目录;可先创建并确保应用容器用户可写"
return
fi
fi
if [[ "$PREPARE_DIRECTORIES" -eq 1 && "$(id -u)" == "0" ]]; then
chown 1001:1001 "$path" || warn "无法调整 $name 目录属主,请人工确认 UID 1001 可写。"
chmod 755 "$path" || warn "无法调整 $name 目录权限,请人工确认 UID 1001 可写。"
fi
if ! touch "$path/.flnxi-write-test" 2>/dev/null; then
add_problem "$name 目录当前账号不可写;请检查部署目录权限"
else
rm -f "$path/.flnxi-write-test"
fi
local owner_uid owner_gid mode other_write
owner_uid="$(stat -c '%u' "$path" 2>/dev/null || echo unknown)"
owner_gid="$(stat -c '%g' "$path" 2>/dev/null || echo unknown)"
mode="$(stat -c '%a' "$path" 2>/dev/null || echo 0)"
other_write="${mode: -1}"
if [[ "$owner_uid" != "1001" && "$owner_gid" != "1001" && "$other_write" != "2" && "$other_write" != "3" && "$other_write" != "6" && "$other_write" != "7" ]]; then
add_problem "$name 目录可能不是应用容器用户 UID 1001 可写;建议执行 sudo chown 1001:1001 '$path'"
fi
}
install_preflight() {
info "执行安装前检查"
if [[ ! -f "$ENV_PATH" ]]; then
add_problem ".env 文件不存在,请先从 .env.example 复制并填写客户现场值"
fi
local required_names=(
APP_PUBLIC_URL
NEXTAUTH_URL
NEXTAUTH_SECRET
LICENSE_SERVER_URL
LICENSE_PROJECT_CODE
LICENSE_REQUEST_SIGNING_SECRET
LICENSE_PUBLIC_KEY
LOCAL_ENCRYPTION_KEY
POSTGRES_PASSWORD
S3_PUBLIC_DOMAIN
)
local name
for name in "${required_names[@]}"; do
required_env "$name"
done
test_app_image
local database_url
database_url="$(read_env_value "DATABASE_URL" "$ENV_PATH")"
if [[ -n "$database_url" ]] && is_placeholder_value "$database_url"; then
add_problem "DATABASE_URL 仍是示例地址;内置 PostgreSQL 请留空,外部 PostgreSQL 请填真实连接串"
fi
if is_external_object_storage; then
for name in S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
required_env "$name"
done
else
for name in MINIO_ROOT_USER MINIO_ROOT_PASSWORD S3_BUCKET; do
required_env "$name"
done
fi
local app_public_url nextauth_url
app_public_url="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")"
nextauth_url="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")"
app_public_url="${app_public_url%/}"
nextauth_url="${nextauth_url%/}"
if [[ -n "$app_public_url" && -n "$nextauth_url" && "$app_public_url" != "$nextauth_url" ]]; then
warn "APP_PUBLIC_URL 与 NEXTAUTH_URL 不一致,请确认最终浏览器访问入口。"
fi
test_directory_ready "uploads"
test_directory_ready "data"
if [[ "${#PROBLEMS[@]}" -gt 0 ]]; then
printf '[ERROR] 安装前检查未通过:\n' >&2
printf ' - %s\n' "${PROBLEMS[@]}" >&2
exit 1
fi
ok "安装前检查通过"
}
run_docker() {
docker "$@"
}
run_compose() {
run_docker "${COMPOSE_ARGS[@]}" "$@"
}
get_service_container_id() {
local service="$1"
local container_id
container_id="$(run_compose ps -q "$service" | tr -d '\r')"
if [[ -z "$container_id" ]]; then
fail "未找到服务容器:$service。"
fi
printf '%s' "$container_id"
}
wait_service_running() {
local container_id="$1"
local service="$2"
local timeout="$3"
local deadline status
deadline=$(( $(date +%s) + timeout ))
while [[ "$(date +%s)" -le "$deadline" ]]; do
status="$(run_docker inspect "$container_id" --format '{{.State.Status}}' 2>/dev/null || true)"
if [[ "$status" == "running" ]]; then
ok "$service 容器已运行"
return 0
fi
sleep 5
done
fail "$service 容器在 $timeout 秒内没有稳定运行。"
}
wait_health() {
local health_url="$1"
local timeout="$2"
local deadline
deadline=$(( $(date +%s) + timeout ))
while [[ "$(date +%s)" -le "$deadline" ]]; do
if curl -fsS "$health_url" >/dev/null 2>&1; then
ok "/api/health 正常"
return 0
fi
sleep 5
done
fail "/api/health 在 $timeout 秒内没有通过:$health_url"
}
command -v docker >/dev/null 2>&1 || fail "未找到 docker 命令"
docker compose version >/dev/null 2>&1 || fail "当前 Docker 不支持 compose v2,请先安装 Docker Compose V2"
command -v curl >/dev/null 2>&1 || fail "未找到 curl 命令"
PROJECT_DIR_ABS="$(cd "$PROJECT_DIR" && pwd)"
COMPOSE_PATH="$PROJECT_DIR_ABS/$COMPOSE_FILE"
ENV_PATH="$PROJECT_DIR_ABS/$ENV_FILE"
cd "$PROJECT_DIR_ABS"
[[ -f "$COMPOSE_PATH" ]] || fail "Compose 文件不存在:$COMPOSE_PATH"
COMPOSE_ARGS=(compose -f "$COMPOSE_PATH")
if [[ -f "$ENV_PATH" ]]; then
COMPOSE_ARGS+=(--env-file "$ENV_PATH")
fi
install_preflight
if [[ "$PREFLIGHT_ONLY" -eq 1 ]]; then
ok "安装前检查完成,未启动或检查容器。"
exit 0
fi
info "检查 Compose 配置"
run_compose config --quiet
if [[ "$START" -eq 1 ]]; then
info "启动客户安装编排"
run_compose up -d
fi
info "检查容器状态"
run_compose ps
APP_CONTAINER_ID="$(get_service_container_id "$APP_SERVICE")"
wait_service_running "$APP_CONTAINER_ID" "$APP_SERVICE" "$HEALTH_TIMEOUT_SECONDS"
RUNNING_IMAGE="$(run_docker inspect "$APP_CONTAINER_ID" --format '{{.Config.Image}}' | tr -d '\r')"
[[ -n "$RUNNING_IMAGE" ]] || fail "无法读取应用运行镜像。"
ok "应用运行镜像:$RUNNING_IMAGE"
VERSION_OUTPUT="$(run_compose exec -T "$APP_SERVICE" sh -c 'cat VERSION.txt 2>/dev/null || true' | tr -d '\r')"
if [[ -n "$VERSION_OUTPUT" ]]; then
ok "应用版本文件:$VERSION_OUTPUT"
else
warn "镜像内未读到 VERSION.txt,请确认发布镜像是否包含版本文件。"
fi
PUBLIC_URL="$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")"
if [[ -z "$PUBLIC_URL" ]]; then
PUBLIC_URL="$(read_env_value "NEXTAUTH_URL" "$ENV_PATH")"
fi
[[ -n "$PUBLIC_URL" ]] || fail "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,无法验收健康接口。"
wait_health "${PUBLIC_URL%/}/api/health" "$HEALTH_TIMEOUT_SECONDS"
if [[ "$SKIP_MIGRATION_STATUS" -eq 0 ]]; then
info "检查数据库迁移状态"
run_compose exec -T "$APP_SERVICE" npx prisma migrate status --schema=prisma/schema.prisma
fi
if [[ "$SKIP_OBJECT_STORAGE_CHECK" -eq 0 ]]; then
if is_external_object_storage; then
warn "检测到外部对象存储,跳过内置 MinIO bucket 检查;请按客户现场对象存储方案单独验收。"
else
BUCKET="$(read_env_value "S3_BUCKET" "$ENV_PATH")"
BUCKET="${BUCKET:-flnxi-materials}"
info "检查 MinIO bucket:$BUCKET"
run_compose exec -T "$MINIO_SERVICE" sh -c "test -d '/data/$BUCKET'"
ok "对象存储 bucket 存在:$BUCKET"
fi
fi
info "最近应用日志"
run_compose logs --tail 80 "$APP_SERVICE"
ok "首装基础验收完成"