441 lines
13 KiB
Bash
441 lines
13 KiB
Bash
#!/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"
|
||
EXPECTED_VERSION=""
|
||
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
|
||
--expect-version <ver> 期望镜像内 VERSION.txt 版本
|
||
--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
|
||
;;
|
||
--expect-version)
|
||
EXPECTED_VERSION="${2:?--expect-version 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##*/}"
|
||
local first_part="${app_image%%/*}"
|
||
if [[ "$app_image" != */* || ( "$first_part" != "localhost" && "$first_part" != *.* && "$first_part" != *:* ) ]]; then
|
||
add_problem "APP_IMAGE 必须写完整镜像站地址,例如 registry.example.com:5000/flnxi/material/flnxi-material:1.0.1"
|
||
fi
|
||
|
||
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 expected_version="${3:-}"
|
||
local deadline body
|
||
local last_version=""
|
||
deadline=$(( $(date +%s) + timeout ))
|
||
while [[ "$(date +%s)" -le "$deadline" ]]; do
|
||
if body="$(curl -fsS "$health_url" 2>/dev/null)"; then
|
||
if [[ -z "$expected_version" ]]; then
|
||
ok "/api/health 正常"
|
||
return 0
|
||
fi
|
||
last_version="$(printf '%s' "$body" | sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
|
||
if [[ "$last_version" == "$expected_version" ]]; then
|
||
ok "/api/health 正常,版本符合预期:$expected_version"
|
||
return 0
|
||
fi
|
||
fi
|
||
sleep 5
|
||
done
|
||
|
||
if [[ -n "$expected_version" ]]; then
|
||
[[ -n "$last_version" ]] || last_version="未返回"
|
||
fail "/api/health 在 $timeout 秒内没有返回期望版本:实际 $last_version,预期 $expected_version"
|
||
fi
|
||
|
||
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
|
||
if [[ -n "$EXPECTED_VERSION" ]]; then
|
||
[[ -n "$VERSION_OUTPUT" ]] || fail "镜像内未读到 VERSION.txt,无法确认期望版本:$EXPECTED_VERSION"
|
||
[[ "$VERSION_OUTPUT" == "$EXPECTED_VERSION" ]] || fail "应用版本不符合预期:实际 $VERSION_OUTPUT,预期 $EXPECTED_VERSION"
|
||
ok "应用版本符合预期:$EXPECTED_VERSION"
|
||
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" "$EXPECTED_VERSION"
|
||
|
||
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 "首装基础验收完成"
|