优化客户镜像与安装前检查
This commit is contained in:
parent
77cc027b9e
commit
70a2a20006
@ -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
|
||||
|
||||
@ -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)')
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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`,对象存储验收按客户现场方案单独执行。
|
||||
|
||||
人工验收命令仍保留如下:
|
||||
|
||||
|
||||
@ -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`。
|
||||
- 如果刚启动后数据库迁移还没完成,脚本会在健康接口处等待一段时间,失败时输出应用日志帮助排查。
|
||||
|
||||
@ -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\')')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
param(
|
||||
param(
|
||||
[ValidateSet("Backup", "Restore", "Verify")]
|
||||
[string]$Action = "Backup",
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user