From 70a2a20006c4c9f4fb68d04ed68e5f111d6a87d1 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 1 Jun 2026 10:02:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=A2=E6=88=B7=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E4=B8=8E=E5=AE=89=E8=A3=85=E5=89=8D=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 7 +- __tests__/production-build-hardening.test.ts | 7 + deploy-registry.ps1 | 1 + docs/deployment/部署说明.md | 16 +- scripts/README.md | 3 + .../__tests__/deploy-registry-script.test.ts | 6 + .../__tests__/smoke-install-script.test.ts | 11 ++ scripts/backup-restore.ps1 | 2 +- scripts/smoke-install.ps1 | 169 +++++++++++++++++- 9 files changed, 216 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7a1adfd..a98dc18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -245,8 +245,11 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public # 2. 额外静态资源(如默认 Logo) COPY --from=builder --chown=nextjs:nodejs /app/logo ./public/logo -# 3. Prisma schema 和迁移文件(运行时可能需要) -COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +# 3. Prisma schema 和迁移文件(运行时执行 migrate deploy 必需) +# 不复制 prisma/engine-cache、dev.db 等构建/开发产物,避免客户镜像重复携带大体积缓存。 +RUN mkdir -p ./prisma +COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/schema.prisma +COPY --from=builder --chown=nextjs:nodejs /app/prisma/migrations ./prisma/migrations # 4. 版本文件(许可证系统版本检查) COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt diff --git a/__tests__/production-build-hardening.test.ts b/__tests__/production-build-hardening.test.ts index 4adb5db..3e3065c 100644 --- a/__tests__/production-build-hardening.test.ts +++ b/__tests__/production-build-hardening.test.ts @@ -19,6 +19,13 @@ describe('production build hardening', () => { expect(dockerignore).toContain('!scripts/docker-entrypoint.sh') }) + it('生产运行镜像只复制 Prisma 运行迁移所需文件', () => { + expect(dockerfile).toContain('/app/prisma/schema.prisma ./prisma/schema.prisma') + expect(dockerfile).toContain('/app/prisma/migrations ./prisma/migrations') + expect(dockerfile).not.toContain('/app/prisma ./prisma') + expect(dockerfile).not.toMatch(/COPY\s+--from=builder[\s\S]*\/app\/prisma\s+\.\/prisma/) + }) + it('混淆依赖缺失时不能静默跳过', () => { expect(buildScript).toContain('生产混淆不能跳过') expect(buildScript).toContain('process.exit(1)') diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 index 203c58a..ba14d40 100644 --- a/deploy-registry.ps1 +++ b/deploy-registry.ps1 @@ -319,6 +319,7 @@ function Get-RuntimeImageSpecs { @{ Source = "postgres:16-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/postgres:16-alpine"; Repository = "runtime/postgres"; Tag = "16-alpine" }, @{ Source = "minio/minio:RELEASE.2025-04-22T22-12-26Z"; Mirror = "quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z"; Repository = "runtime/minio"; Tag = "RELEASE.2025-04-22T22-12-26Z" }, @{ Source = "minio/mc:RELEASE.2025-04-16T18-13-26Z"; Mirror = "quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z"; Repository = "runtime/minio-mc"; Tag = "RELEASE.2025-04-16T18-13-26Z" }, + @{ Source = "busybox:1.36"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/busybox:1.36"; Repository = "runtime/busybox"; Tag = "1.36" }, @{ Source = "alpine:3.21"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/alpine:3.21"; Repository = "runtime/alpine"; Tag = "3.21" }, @{ Source = "node:18-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/node:18-alpine"; Repository = "runtime/node"; Tag = "18-alpine" }, @{ Source = "node:20-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/node:20-alpine"; Repository = "runtime/node"; Tag = "20-alpine" }, diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 0530817..fe6807a 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -263,6 +263,20 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString ## 6. 部署后冒烟测试 +启动前可以先做一轮不启动容器的安装前检查: + +```powershell +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -PreflightOnly +``` + +如果是新目录,还希望脚本顺手创建 `uploads/` 和 `data/`,可以加: + +```powershell +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -PreflightOnly -PrepareDirectories +``` + +这一步会提前拦截 `.env` 示例值、`latest` 镜像、缺失数据目录和明显错误的访问地址,避免等容器启动失败后再排查。 + 首装后优先使用脚本做基础验收: ```powershell @@ -275,7 +289,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString .\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" ``` -该脚本会检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。 +该脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。 人工验收命令仍保留如下: diff --git a/scripts/README.md b/scripts/README.md index 25e064f..233d0f3 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -103,11 +103,14 @@ npx tsx scripts/fix-missing-templates.ts ### 客户首装基础验收 ```powershell +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -PreflightOnly .\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -Start ``` 说明: +- `-PreflightOnly` 只做安装前检查,不启动容器;会拦截 `.env` 示例值、`latest` 镜像、缺失数据目录等常见坑。 +- 新目录可加 `-PrepareDirectories`,让脚本创建 `uploads/` 和 `data/` 并做写入测试。 - `-Start` 会执行 `docker compose up -d`;如果服务已经由 NAS 图形界面启动,可以不传,只做检查。 - 如果客户使用外部对象存储,可加 `-SkipObjectStorageCheck`。 - 如果刚启动后数据库迁移还没完成,脚本会在健康接口处等待一段时间,失败时输出应用日志帮助排查。 diff --git a/scripts/__tests__/deploy-registry-script.test.ts b/scripts/__tests__/deploy-registry-script.test.ts index 3626bd0..fb265bf 100644 --- a/scripts/__tests__/deploy-registry-script.test.ts +++ b/scripts/__tests__/deploy-registry-script.test.ts @@ -39,6 +39,12 @@ describe('deploy-registry.ps1', () => { expect(script).toContain('npx prisma migrate status --schema=prisma/schema.prisma') }) + it('运行时镜像同步包含对象存储备份 helper', () => { + expect(script).toContain('Source = "busybox:1.36"') + expect(script).toContain('Repository = "runtime/busybox"') + expect(script).toContain('Tag = "1.36"') + }) + it('远端 shell 变量应以单引号字符串保留,避免被 PowerShell 提前展开', () => { expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" up -d --pull never\')') expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" ps\')') diff --git a/scripts/__tests__/smoke-install-script.test.ts b/scripts/__tests__/smoke-install-script.test.ts index 157f0e2..5f9aef5 100644 --- a/scripts/__tests__/smoke-install-script.test.ts +++ b/scripts/__tests__/smoke-install-script.test.ts @@ -9,11 +9,22 @@ describe('scripts/smoke-install.ps1', () => { it('首装验收可以选择启动 Compose,但默认也能只做检查', () => { expect(script).toContain('[switch]$Start') + expect(script).toContain('[switch]$PreflightOnly') + expect(script).toContain('[switch]$PrepareDirectories') expect(script).toContain('"up", "-d"') expect(script).toContain('"config", "--quiet"') expect(script).toContain('@("ps")') }) + it('启动前先拦截占位配置、latest 镜像和缺失数据目录', () => { + expect(script).toContain('Invoke-InstallPreflight') + expect(script).toContain('Test-PlaceholderValue') + expect(script).toContain('APP_IMAGE 不能使用 latest') + expect(script).toContain('DATABASE_URL 仍是示例地址') + expect(script).toContain('缺少 $Name 目录') + expect(script).toContain('安装前检查完成,未启动或检查容器。') + }) + it('验收运行镜像、版本文件、迁移状态和健康接口', () => { expect(script).toContain('Wait-ServiceRunning') expect(script).toContain('docker inspect $appContainerId --format') diff --git a/scripts/backup-restore.ps1 b/scripts/backup-restore.ps1 index ad0ca80..3b1ffd8 100644 --- a/scripts/backup-restore.ps1 +++ b/scripts/backup-restore.ps1 @@ -1,4 +1,4 @@ -param( +param( [ValidateSet("Backup", "Restore", "Verify")] [string]$Action = "Backup", diff --git a/scripts/smoke-install.ps1 b/scripts/smoke-install.ps1 index 97acea7..0a3cc6f 100644 --- a/scripts/smoke-install.ps1 +++ b/scripts/smoke-install.ps1 @@ -1,4 +1,4 @@ -param( +param( [string]$ProjectDir = ".", [string]$ComposeFile = "docker-compose.prod.yml", [string]$EnvFile = ".env", @@ -6,6 +6,8 @@ param( [string]$PostgresService = "postgres", [string]$MinioService = "minio", [switch]$Start, + [switch]$PreflightOnly, + [switch]$PrepareDirectories, [switch]$SkipMigrationStatus, [switch]$SkipObjectStorageCheck, [int]$HealthTimeoutSeconds = 120 @@ -24,6 +26,11 @@ function Write-Ok { Write-Host "[OK] $Message" } +function Write-Warn { + param([string]$Message) + Write-Warning $Message +} + function Read-EnvFile { param([string]$Path) @@ -53,6 +60,157 @@ function Read-EnvFile { return $values } +function Test-PlaceholderValue { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $true + } + + $trimmed = $Value.Trim() + if ($trimmed -match '^(replace-with|your-|<|>)') { + return $true + } + + if ($trimmed -match 'example\.com' -or $trimmed -match '192\.168\.x\.x' -or $trimmed -match '\bx\.x\b') { + return $true + } + + return $false +} + +function Add-Problem { + param( + [System.Collections.Generic.List[string]]$Problems, + [string]$Message + ) + + $Problems.Add($Message) | Out-Null +} + +function Test-RequiredEnv { + param( + [hashtable]$EnvValues, + [System.Collections.Generic.List[string]]$Problems, + [string]$Name + ) + + $value = Get-EnvValue $EnvValues $Name + if (Test-PlaceholderValue $value) { + Add-Problem $Problems "$Name 还不是客户现场真实值" + } +} + +function Test-AppImage { + param( + [hashtable]$EnvValues, + [System.Collections.Generic.List[string]]$Problems + ) + + $appImage = Get-EnvValue $EnvValues "APP_IMAGE" + if (Test-PlaceholderValue $appImage) { + Add-Problem $Problems "APP_IMAGE 还不是完整客户版本镜像" + return + } + + $imageName = ($appImage -split '/')[-1] + if ($imageName -match ':latest$') { + Add-Problem $Problems "APP_IMAGE 不能使用 latest,必须固定到明确版本号" + } + + if ($appImage -notmatch '@sha256:' -and $imageName -notmatch ':.+') { + Add-Problem $Problems "APP_IMAGE 缺少版本标签,例如 :1.0.1" + } + + if ($appImage -match '/material-dev/') { + Write-Warn "APP_IMAGE 当前指向 material-dev 测试镜像;正式客户交付应使用 material 正式镜像。" + } +} + +function Test-DirectoryReady { + param( + [string]$ProjectDir, + [System.Collections.Generic.List[string]]$Problems, + [string]$Name, + [switch]$Prepare + ) + + $path = Join-Path $ProjectDir $Name + if (-not (Test-Path -LiteralPath $path -PathType Container)) { + if ($Prepare) { + New-Item -ItemType Directory -Path $path -Force | Out-Null + Write-Ok "已创建目录:$Name" + } else { + Add-Problem $Problems "缺少 $Name 目录;可先创建并确保应用容器用户可写" + return + } + } + + $testFile = Join-Path $path ".flnxi-write-test" + try { + "ok" | Out-File -LiteralPath $testFile -Encoding utf8 -Force + Remove-Item -LiteralPath $testFile -Force + } catch { + Add-Problem $Problems "$Name 目录当前不可写:$($_.Exception.Message)" + } +} + +function Invoke-InstallPreflight { + param( + [string]$ProjectDir, + [string]$EnvPath, + [hashtable]$EnvValues, + [switch]$PrepareDirectories + ) + + Write-Info "执行安装前检查" + $problems = [System.Collections.Generic.List[string]]::new() + + if (-not (Test-Path -LiteralPath $EnvPath -PathType Leaf)) { + Add-Problem $problems ".env 文件不存在,请先从 .env.example 复制并填写客户现场值" + } + + foreach ($name in @( + "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", + "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", + "S3_PUBLIC_DOMAIN" + )) { + Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name + } + + Test-AppImage -EnvValues $EnvValues -Problems $problems + + $databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL" + if (-not [string]::IsNullOrWhiteSpace($databaseUrl) -and (Test-PlaceholderValue $databaseUrl)) { + Add-Problem $problems "DATABASE_URL 仍是示例地址;内置 PostgreSQL 请留空,外部 PostgreSQL 请填真实连接串" + } + + $appPublicUrl = (Get-EnvValue $EnvValues "APP_PUBLIC_URL").TrimEnd("/") + $nextAuthUrl = (Get-EnvValue $EnvValues "NEXTAUTH_URL").TrimEnd("/") + if (-not [string]::IsNullOrWhiteSpace($appPublicUrl) -and -not [string]::IsNullOrWhiteSpace($nextAuthUrl) -and $appPublicUrl -ne $nextAuthUrl) { + Write-Warn "APP_PUBLIC_URL 与 NEXTAUTH_URL 不一致,请确认最终浏览器访问入口。" + } + + Test-DirectoryReady -ProjectDir $ProjectDir -Problems $problems -Name "uploads" -Prepare:$PrepareDirectories + Test-DirectoryReady -ProjectDir $ProjectDir -Problems $problems -Name "data" -Prepare:$PrepareDirectories + + if ($problems.Count -gt 0) { + $message = "安装前检查未通过:`n - " + ($problems -join "`n - ") + throw $message + } + + Write-Ok "安装前检查通过" +} + function Get-EnvValue { param([hashtable]$EnvValues, [string]$Name, [string]$Default = "") @@ -133,6 +291,12 @@ try { $composeArgs += @("--env-file", $envPath) } + Invoke-InstallPreflight -ProjectDir $resolvedProjectDir -EnvPath $envPath -EnvValues $envValues -PrepareDirectories:$PrepareDirectories + if ($PreflightOnly) { + Write-Ok "安装前检查完成,未启动或检查容器。" + return + } + Write-Info "检查 Compose 配置" Invoke-DockerCompose ($composeArgs + @("config", "--quiet")) @@ -153,7 +317,8 @@ try { } Write-Ok "应用运行镜像:$runningImage" - $versionOutput = & docker @composeArgs exec -T $AppService sh -c 'cat VERSION.txt 2>/dev/null || true' + $versionCommand = "cat VERSION.txt 2>/dev/null || true" + $versionOutput = & docker @composeArgs exec -T $AppService sh -c $versionCommand $version = "$versionOutput".Trim() if (-not [string]::IsNullOrWhiteSpace($version)) { Write-Ok "应用版本文件:$version"