优化客户镜像与安装前检查

This commit is contained in:
WuYongJie 2026-06-01 10:02:30 +08:00
parent 77cc027b9e
commit 70a2a20006
9 changed files with 216 additions and 6 deletions

View File

@ -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

View File

@ -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)')

View File

@ -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" },

View File

@ -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`,对象存储验收按客户现场方案单独执行。
人工验收命令仍保留如下:

View File

@ -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`。
- 如果刚启动后数据库迁移还没完成,脚本会在健康接口处等待一段时间,失败时输出应用日志帮助排查。

View File

@ -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\')')

View File

@ -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')

View File

@ -1,4 +1,4 @@
param(
param(
[ValidateSet("Backup", "Restore", "Verify")]
[string]$Action = "Backup",

View File

@ -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"