#!/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 部署目录,默认当前目录 --compose-file Compose 文件名,默认 docker-compose.prod.yml --env-file 环境文件名,默认 .env --expect-version 期望镜像内 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 健康检查等待秒数,默认 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 "首装基础验收完成"