diff --git a/Dockerfile b/Dockerfile index e8b0b62..7a1adfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -287,7 +287,7 @@ RUN mkdir -p /app/uploads /app/data && \ # 复制启动脚本 COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./docker-entrypoint.sh -RUN chmod +x ./docker-entrypoint.sh +RUN sed -i 's/\r$//' ./docker-entrypoint.sh && chmod +x ./docker-entrypoint.sh # 切换到非 root 用户 USER nextjs diff --git a/README.md b/README.md index 3bf5b4c..0940271 100644 --- a/README.md +++ b/README.md @@ -203,11 +203,13 @@ docker compose -f docker-compose.prod.yml up -d 内部维护环境仍保留: - `deploy-registry.ps1` +- `scripts/smoke-install.ps1` +- `scripts/backup-restore.ps1` - `deploy-new-fixed.ps1` - `发布管理.ps1` - `license-server/deploy.ps1` -这些脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。 +其中 `scripts/smoke-install.ps1` 用于首装后的基础验收,`scripts/backup-restore.ps1` 用于客户环境备份、恢复和恢复后验收。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。 ## 10. 文档入口 diff --git a/docs/README.md b/docs/README.md index 16b1d1f..b9d06e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ ## 2. 部署与交付 - `deployment/部署说明.md` - 主应用标准部署、快速步骤、部署前检查、验证和回滚说明。 + 主应用标准部署、快速步骤、部署前检查、首装验收、备份恢复和回滚说明。 - `deployment/多品牌 NAS 与 Linux 交付差异清单.md` 面向不同 NAS / Linux 客户现场的部署前采集清单。 - `初次部署与出厂设置指南.md` diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index b864d67..99a176a 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -261,6 +261,22 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString ## 6. 部署后冒烟测试 +首装后优先使用脚本做基础验收: + +```powershell +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -Start +``` + +如果服务已经由 NAS 图形界面或其他编排入口启动,可以去掉 `-Start`,只做检查: + +```powershell +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" +``` + +该脚本会检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。 + +人工验收命令仍保留如下: + - [ ] `/api/health` 正常 - [ ] 登录 / 退出正常 - [ ] 首次初始化或激活流程正常 @@ -275,7 +291,62 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString `deploy-registry.ps1 -Action DeployApp` 会默认做一轮升级前备份和失败回滚。下面命令主要用于人工兜底、外部数据库场景或单独恢复演练。 -### 7.1 手动备份数据库 +### 7.1 一键备份 + +首选脚本: + +```powershell +.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup +``` + +默认备份内容: + +- `.env` +- `docker-compose.prod.yml` +- `VERSION.txt`,如果部署目录里存在 +- `compose-config.txt`、`compose-ps.txt`、`docker-images.txt` +- Compose 管理的 PostgreSQL:`postgres.sql` +- Compose 管理的 MinIO 数据卷:`minio-data.tar.gz` +- `manifest.json`:记录备份时间、应用镜像、访问地址、服务名等关键信息 + +如果 `DATABASE_URL` 指向外部 PostgreSQL,脚本不会假装自动备份,会在备份目录写入 `EXTERNAL_DATABASE_BACKUP_REQUIRED.txt`,客户现场必须按外部数据库方案单独备份。 + +如果对象存储是外部 S3 / 外部 MinIO,或现场已有独立对象存储快照,可加: + +```powershell +.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup -SkipObjectStorageBackup +``` + +内置 MinIO 备份/恢复默认使用 `busybox:1.36` 作为 helper 镜像。离线客户现场需要提前把该镜像放进 Docker,或通过 `-HelperImage` 指定现场已有的 Linux 工具镜像。 + +### 7.2 一键验收 + +```powershell +.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Verify +``` + +该动作会检查 Compose 配置、容器状态和 `/api/health`,适合备份前、恢复后、升级后快速确认系统是否可用。 + +### 7.3 一键恢复 + +恢复会覆盖客户数据,必须停写入并确认备份来源正确。脚本要求输入固定确认短语,避免误操作: + +```powershell +.\scripts\backup-restore.ps1 ` + -ProjectDir "D:\deploy\flnxi" ` + -Action Restore ` + -BackupPath "D:\deploy\flnxi\backups\flnxi-backup-YYYYMMDD-HHMMSS" ` + -ConfirmRestore RESTORE_FLNXI_DATA +``` + +说明: + +- 默认恢复 `postgres.sql` 和 `minio-data.tar.gz`,然后启动服务并执行健康验收。 +- 如果只恢复数据库、不恢复对象存储,加 `-SkipObjectStorageRestore`。 +- 如果需要把备份包中的 `.env` 一起恢复,加 `-RestoreEnv`;脚本会先把当前 `.env` 另存为 `.env.before-restore-时间戳`。 +- 外部数据库和外部对象存储不受该脚本自动恢复,必须按客户现场备份方案操作。 + +### 7.4 手动备份数据库 ```bash docker exec flnxi-postgres pg_dump \ @@ -287,7 +358,7 @@ docker exec flnxi-postgres pg_dump \ 再把备份文件复制到宿主机或外部备份位置。外部数据库不受 Compose 脚本管理,必须按客户现场数据库方案单独备份。 -### 7.2 手动回滚应用 +### 7.5 手动回滚应用 如使用镜像部署,回滚到上一镜像版本时,直接改完整 `APP_IMAGE`。不要只改 `IMAGE_TAG`,因为完整 `APP_IMAGE` 的优先级更高。 @@ -304,7 +375,7 @@ curl http://:4320/api/health 回滚前先确认数据库是否需要同时回滚。只回滚应用镜像不等于回滚数据库结构;如果本次升级包含数据库迁移,必须先确认备份和恢复路径。 -### 7.3 手动恢复数据库 +### 7.6 手动恢复数据库 恢复前必须确认已停止会写入数据库的应用容器,并确认备份文件正确。下面给两种常见方式,二选一即可。 diff --git a/docs/客户首装升级坑位排查_20260530.md b/docs/客户首装升级坑位排查_20260530.md index 9c8acdb..133d1fd 100644 --- a/docs/客户首装升级坑位排查_20260530.md +++ b/docs/客户首装升级坑位排查_20260530.md @@ -13,6 +13,8 @@ - `license-server/.env.example` 已补 `LICENSE_REQUEST_SIGNING_SECRET`,示例数据库地址已改为 Compose 服务名 `postgres`。 - Go Agent 健康检查超时会返回失败,升级健康失败后会尽力恢复旧容器。 - `deploy-registry.ps1` 的回滚保护已提前到远端 `.env` 写入前,并明确提示“自动回滚不恢复数据库”。 +- 新增 `scripts/smoke-install.ps1` 和 `scripts/backup-restore.ps1`,把首装基础验收、备份、恢复和恢复后健康检查从零散手工命令收口成可执行脚本。 +- 真实 Docker 首装模拟暴露出生产镜像入口脚本可能因 Windows 换行在 Linux 容器内无法执行,已在 `Dockerfile` 中强制转为 Linux 换行。 - 部署文档已补内置/外部 PostgreSQL、内置/外部 MinIO、NAS 挂载目录权限、历史 ACL 审计和 Podman 口径。 - 许可证外部接入文档和历史安全评估已补当前签名要求和历史快照提示。 @@ -130,6 +132,23 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 当前处理: - 已修复:发布脚本回滚输出和部署文档都明确“自动回滚只恢复应用镜像和 `.env`,不自动恢复数据库”;手动数据库恢复命令已补 `docker cp` 和标准输入两条可执行路径。 +- 已补第二阶段产品化入口:`scripts/backup-restore.ps1` 支持 `Backup`、`Restore`、`Verify`。恢复必须显式传入 `RESTORE_FLNXI_DATA`,避免误覆盖客户数据;外部数据库和外部对象存储不会假装自动恢复。 + +### P0-08 生产镜像入口脚本在 Linux 容器内可能找不到 + +客户影响: + +客户按 Compose 首装后,`postgres` 和 `minio` 正常,主应用容器不断重启,日志只有 `./docker-entrypoint.sh: not found`。这会让首装直接卡死。 + +证据: + +- 本机 Docker 客户模拟环境第一次启动 `flnxi-material:customer-smoke-20260530` 时,主应用容器反复重启,日志显示 `/usr/local/bin/docker-entrypoint.sh: exec: line 11: ./docker-entrypoint.sh: not found`。 +- `Dockerfile` 会复制 `scripts/docker-entrypoint.sh` 到 Linux 镜像。如果该脚本在 Windows 工作区里带 CRLF 换行,Linux 会把 shebang 解析失败,表现成文件“找不到”。 + +当前处理: + +- 已修复:`Dockerfile` 复制启动脚本后执行 `sed -i 's/\r$//' ./docker-entrypoint.sh && chmod +x ./docker-entrypoint.sh`。 +- 已补测试:`scripts/__tests__/docker-entrypoint.test.ts` 锁定生产镜像必须转换启动脚本换行。 ## 3. P1:会造成交付不稳或现场误操作 @@ -225,8 +244,27 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 当前处理: - 已补文档提醒:部署说明和坑位清单明确外部 S3、历史 bucket policy、对象级 ACL、历史公开链接需单独只读审计。 +- 已补脚本层第一步:`scripts/backup-restore.ps1` 默认备份 Compose 管理的 MinIO 数据卷;外部 S3 / MinIO 需要客户现场独立备份。 - 后续仍建议补自动审计脚本或 SOP。 +### P1-09 飞牛 fnOS 临时测试环境尚不能自动部署 + +证据: + +- 本机 VMware 当前运行 `E:\FeiNiu\fnOS.vmx`。 +- 当前 fnOS IP 为 `192.168.116.128`,`5666`、`5667` 管理端口可达,`22` 端口开放,`4320` 不通。 +- `vmrun getGuestIPAddress` 提示 VMware Tools 未运行,不能只依赖 VMware Tools 获取 IP。 + +影响: + +飞牛系统可以作为后续 NAS 实机测试环境,但当前没有 SSH 凭据或容器终端权限,无法自动复制部署文件、执行 `docker compose up -d`、查看日志、跑备份恢复演练。 + +当前处理: + +- 已确认 fnOS 可作为“人工配合的 NAS 实机环境”。 +- 在拿到 SSH 或容器终端前,第一阶段自动首装验收先使用本机 Docker 隔离环境推进。 +- 后续接入 fnOS 时,优先执行 `scripts/smoke-install.ps1` 和 `scripts/backup-restore.ps1` 做同一套验收,不另写一套飞牛专用口径。 + ### P1-07 上传预览图和 PSD 展示图仍有大图内存风险 证据: @@ -340,6 +378,11 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj ## 5. 已做验证 - 本轮使用 multi-agent 做只读并行排查,分别覆盖首装入口、升级回滚、文档真相。 +- 本轮使用 multi-agent 和本机真实端口扫描确认 fnOS 测试环境:VM 在线,`192.168.116.128:5666/5667` 可达,`22` 开放,`4320` 当前未运行应用;由于缺少 SSH / 容器终端权限,未在 fnOS 内执行部署。 +- 新增脚本结构测试覆盖 `scripts/backup-restore.ps1` 和 `scripts/smoke-install.ps1` 的关键安全约束。 +- 使用本机 Docker 隔离目录完成客户首装模拟:`APP_PORT=4321`、`MINIO_API_PORT=39200`、`MINIO_CONSOLE_PORT=39201`,应用镜像 `flnxi-material:customer-smoke-20260530`,首装验收脚本确认 `/api/health`、迁移状态、版本文件和 MinIO bucket 均通过。 +- 使用同一隔离环境完成备份、恢复和恢复后健康验收:`scripts/backup-restore.ps1 -Action Backup` 生成 `postgres.sql` 和 `minio-data.tar.gz`,`-Action Restore -ConfirmRestore RESTORE_FLNXI_DATA` 恢复成功。 +- 使用临时本机许可证模拟服务完成免费版激活,并用登录后的管理员会话上传一条“材质”素材,接口返回 `201 Created`。 - 本轮修复后执行 `docker compose -f docker-compose.prod.yml --env-file .env.example config`,在补入真实必填密钥的前提下确认 `DATABASE_URL` 会展开到内置 `postgres` 服务,`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` 会从 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 回填。 - 本轮修复后执行 `docker compose -f license-server/docker-compose.yml --env-file license-server/.env.example config`,确认示例环境文件已包含 `LICENSE_REQUEST_SIGNING_SECRET`,并展开到 `@postgres:5432`。 - 执行 `npm test -- --runTestsByPath scripts/__tests__/docker-entrypoint.test.ts scripts/__tests__/deploy-registry-script.test.ts license-server/lib/__tests__/env-example.test.ts --runInBand`,3 个测试套件、9 个测试通过。 diff --git a/scripts/README.md b/scripts/README.md index aa5ef14..e06769f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -43,6 +43,10 @@ npm run verify:template-data 主应用容器启动入口 - `generate-shared-test-env.ps1` 为共享测试机生成隔离后的 `.env`,避免容器名、卷名、网络名与其他项目冲突 +- `smoke-install.ps1` + 客户首装后的基础验收脚本,可检查 Compose 配置、容器状态、运行镜像、版本文件、迁移状态、MinIO bucket 和 `/api/health` +- `backup-restore.ps1` + 客户环境备份、恢复和健康验收脚本;恢复必须显式输入确认短语,避免误覆盖客户数据 ### 数据检查/修复相关 @@ -87,15 +91,47 @@ npx tsx scripts/fix-missing-templates.ts ```powershell .\scripts\generate-shared-test-env.ps1 ` -OutputPath "$env:TEMP\flnxi_sucai-test.env" ` + -AppImage "registry.example.com/flnxi/material/flnxi-material:1.0.1" ` -LicenseServerUrl "https://your-license-server.example.com" ``` 说明: - `LicenseServerUrl` 不再内置默认值,必须显式传入。 +- `AppImage` 不再内置默认值,测试环境也必须固定完整镜像版本。 - 示例地址只是占位,执行前必须替换为当前真实可用的许可证服务地址。 - `https://license.flnxi.com` 不能默认当作当前可用入口。 +### 客户首装基础验收 + +```powershell +.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -Start +``` + +说明: + +- `-Start` 会执行 `docker compose up -d`;如果服务已经由 NAS 图形界面启动,可以不传,只做检查。 +- 如果客户使用外部对象存储,可加 `-SkipObjectStorageCheck`。 +- 如果刚启动后数据库迁移还没完成,脚本会在健康接口处等待一段时间,失败时输出应用日志帮助排查。 + +### 客户备份、恢复和验收 + +```powershell +.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup +.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Verify +.\scripts\backup-restore.ps1 ` + -ProjectDir "D:\deploy\flnxi" ` + -Action Restore ` + -BackupPath "D:\deploy\flnxi\backups\flnxi-backup-YYYYMMDD-HHMMSS" ` + -ConfirmRestore RESTORE_FLNXI_DATA +``` + +说明: + +- 默认备份 Compose 管理的 PostgreSQL、内置 MinIO 数据卷、`.env`、Compose 文件、版本文件、镜像和容器状态。 +- 外部数据库、外部对象存储由客户现场方案单独备份;脚本会写提示文件,不会假装自动覆盖。 +- 对象存储备份/恢复使用 `busybox:1.36` 作为 helper 镜像;离线客户现场需要提前放入该镜像或通过 `-HelperImage` 指定现场已有镜像。 + ### 手工触发单个视频转码 ```bash diff --git a/scripts/__tests__/backup-restore-script.test.ts b/scripts/__tests__/backup-restore-script.test.ts new file mode 100644 index 0000000..aa0f535 --- /dev/null +++ b/scripts/__tests__/backup-restore-script.test.ts @@ -0,0 +1,48 @@ +import fs from 'fs' +import path from 'path' + +describe('scripts/backup-restore.ps1', () => { + const script = fs.readFileSync( + path.join(process.cwd(), 'scripts/backup-restore.ps1'), + 'utf8' + ) + + it('恢复客户数据必须显式确认,不能静默覆盖', () => { + expect(script).toContain('[ValidateSet("Backup", "Restore", "Verify")]') + expect(script).toContain('$RestoreConfirmPhrase = "RESTORE_FLNXI_DATA"') + expect(script).toContain('$ConfirmRestore -ne $RestoreConfirmPhrase') + expect(script).toContain('恢复会覆盖客户数据') + }) + + it('备份包固定保存部署文件、环境文件、状态和清单', () => { + expect(script).toContain('docker-compose.prod.yml') + expect(script).toContain('Copy-Item -LiteralPath $envPath') + expect(script).toContain('compose-config.txt') + expect(script).toContain('compose-ps.txt') + expect(script).toContain('docker-images.txt') + expect(script).toContain('manifest.json') + }) + + it('内置数据库走 pg_dump/psql,外部数据库不假装自动备份', () => { + expect(script).toContain('Test-ExternalDatabase') + expect(script).toContain('EXTERNAL_DATABASE_BACKUP_REQUIRED.txt') + expect(script).toContain('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"') + expect(script).toContain('psql -v ON_ERROR_STOP=1') + }) + + it('内置 MinIO 数据卷有备份和恢复路径', () => { + expect(script).toContain('Backup-ObjectStorage') + expect(script).toContain('Restore-ObjectStorage') + expect(script).toContain('minio-data.tar.gz') + expect(script).toContain('MINIO_VOLUME_NAME') + expect(script).toContain('$HelperImage = "busybox:1.36"') + }) + + it('验收动作会检查 Compose、容器状态和健康接口', () => { + expect(script).toContain('"config", "--quiet"') + expect(script).toContain('@("ps")') + expect(script).toContain('/api/health') + expect(script).toContain('$HealthTimeoutSeconds = 120') + expect(script).toContain('没有通过:$healthUrl') + }) +}) diff --git a/scripts/__tests__/docker-entrypoint.test.ts b/scripts/__tests__/docker-entrypoint.test.ts index 4b2f9dd..e4ae51c 100644 --- a/scripts/__tests__/docker-entrypoint.test.ts +++ b/scripts/__tests__/docker-entrypoint.test.ts @@ -8,6 +8,12 @@ describe('scripts/docker-entrypoint.sh', () => { path.join(process.cwd(), 'scripts/docker-entrypoint.sh'), 'utf8' ) + const dockerfile = fs.readFileSync(path.join(process.cwd(), 'Dockerfile'), 'utf8') + + it('生产镜像复制启动脚本后必须转成 Linux 换行', () => { + expect(dockerfile).toContain("sed -i 's/\\r$//' ./docker-entrypoint.sh") + expect(dockerfile).toContain('chmod +x ./docker-entrypoint.sh') + }) it('数据库迁移失败时必须停止容器启动', () => { expect(entrypoint).toContain('require_real_env "NEXTAUTH_SECRET"') diff --git a/scripts/__tests__/smoke-install-script.test.ts b/scripts/__tests__/smoke-install-script.test.ts new file mode 100644 index 0000000..157f0e2 --- /dev/null +++ b/scripts/__tests__/smoke-install-script.test.ts @@ -0,0 +1,35 @@ +import fs from 'fs' +import path from 'path' + +describe('scripts/smoke-install.ps1', () => { + const script = fs.readFileSync( + path.join(process.cwd(), 'scripts/smoke-install.ps1'), + 'utf8' + ) + + it('首装验收可以选择启动 Compose,但默认也能只做检查', () => { + expect(script).toContain('[switch]$Start') + expect(script).toContain('"up", "-d"') + expect(script).toContain('"config", "--quiet"') + expect(script).toContain('@("ps")') + }) + + it('验收运行镜像、版本文件、迁移状态和健康接口', () => { + expect(script).toContain('Wait-ServiceRunning') + expect(script).toContain('docker inspect $appContainerId --format') + expect(script).toContain('cat VERSION.txt') + expect(script).toContain('npx", "prisma", "migrate", "status"') + expect(script).toContain('/api/health') + }) + + it('验收内置对象存储 bucket,并允许外部对象存储跳过', () => { + expect(script).toContain('[switch]$SkipObjectStorageCheck') + expect(script).toContain('S3_BUCKET') + expect(script).toContain('test -d /data/$bucket') + }) + + it('验收失败时会读取应用日志辅助排查', () => { + expect(script).toContain('logs --tail 80') + expect(script).toContain('读取应用日志失败') + }) +}) diff --git a/scripts/backup-restore.ps1 b/scripts/backup-restore.ps1 new file mode 100644 index 0000000..ad0ca80 --- /dev/null +++ b/scripts/backup-restore.ps1 @@ -0,0 +1,400 @@ +param( + [ValidateSet("Backup", "Restore", "Verify")] + [string]$Action = "Backup", + + [string]$ProjectDir = ".", + [string]$ComposeFile = "docker-compose.prod.yml", + [string]$EnvFile = ".env", + [string]$BackupDir = "backups", + [string]$BackupPath, + + [string]$AppService = "app", + [string]$PostgresService = "postgres", + [string]$MinioService = "minio", + [string]$HelperImage = "busybox:1.36", + + [switch]$SkipDatabaseBackup, + [switch]$SkipObjectStorageBackup, + [switch]$SkipObjectStorageRestore, + [switch]$RestoreEnv, + + [int]$HealthTimeoutSeconds = 120, + + [string]$ConfirmRestore +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RestoreConfirmPhrase = "RESTORE_FLNXI_DATA" + +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" +} + +function Write-Ok { + param([string]$Message) + Write-Host "[OK] $Message" +} + +function Write-Warn { + param([string]$Message) + Write-Warning $Message +} + +function Resolve-ExistingFile { + param([string]$Path, [string]$Label) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "$Label 不存在:$Path" + } + + return (Resolve-Path -LiteralPath $Path).Path +} + +function Resolve-ProjectPath { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "项目目录不存在:$Path" + } + + return (Resolve-Path -LiteralPath $Path).Path +} + +function Read-EnvFile { + param([string]$Path) + + $values = @{} + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $values + } + + foreach ($line in Get-Content -LiteralPath $Path) { + if ($line -match '^\s*#' -or $line -notmatch '=') { + continue + } + + $parts = $line -split '=', 2 + $key = $parts[0].Trim() + if ([string]::IsNullOrWhiteSpace($key)) { + continue + } + + $value = $parts[1].Trim() + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + $values[$key] = $value + } + + return $values +} + +function Get-EnvValue { + param( + [hashtable]$EnvValues, + [string]$Name, + [string]$Default = "" + ) + + if ($EnvValues.ContainsKey($Name)) { + return $EnvValues[$Name] + } + + return $Default +} + +function Invoke-DockerCompose { + param([string[]]$Arguments) + + & docker @Arguments + if ($LASTEXITCODE -ne 0) { + throw "docker 命令失败,退出码:$LASTEXITCODE" + } +} + +function Get-ComposeArgs { + param([string]$ComposeFilePath, [string]$EnvFilePath) + + $args = @("compose", "-f", $ComposeFilePath) + if (Test-Path -LiteralPath $EnvFilePath -PathType Leaf) { + $args += @("--env-file", $EnvFilePath) + } + return $args +} + +function Get-ServiceContainerId { + param([string[]]$ComposeArgs, [string]$Service) + + $containerId = (& docker @ComposeArgs ps -q $Service).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) { + throw "未找到服务容器:$Service。请先确认服务已启动。" + } + + return $containerId +} + +function Test-ExternalDatabase { + param([hashtable]$EnvValues) + + $databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL" + if ([string]::IsNullOrWhiteSpace($databaseUrl)) { + return $false + } + + return ($databaseUrl -notmatch '@postgres:5432' -and $databaseUrl -notmatch '@[^/]*postgres[^/]*:5432') +} + +function Backup-Database { + param([string[]]$ComposeArgs, [string]$Service, [string]$OutputPath) + + Write-Info "备份 Compose 内置 PostgreSQL" + Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service)) + $containerId = Get-ServiceContainerId -ComposeArgs $ComposeArgs -Service $Service + $containerBackupPath = "/tmp/flnxi-backup.sql" + + Invoke-DockerCompose ($ComposeArgs + @( + "exec", "-T", $Service, + "sh", "-c", + 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges -f /tmp/flnxi-backup.sql && test -s /tmp/flnxi-backup.sql' + )) + + & docker cp "${containerId}:${containerBackupPath}" $OutputPath + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { + throw "数据库备份复制失败:$OutputPath" + } + + Invoke-DockerCompose ($ComposeArgs + @("exec", "-T", $Service, "sh", "-c", "rm -f $containerBackupPath")) + Write-Ok "数据库备份完成:$OutputPath" +} + +function Restore-Database { + param([string[]]$ComposeArgs, [string]$Service, [string]$InputPath) + + Write-Info "恢复 Compose 内置 PostgreSQL" + Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service)) + $containerId = Get-ServiceContainerId -ComposeArgs $ComposeArgs -Service $Service + $containerRestorePath = "/tmp/flnxi-restore.sql" + + & docker cp $InputPath "${containerId}:${containerRestorePath}" + if ($LASTEXITCODE -ne 0) { + throw "数据库备份文件复制到容器失败" + } + + Invoke-DockerCompose ($ComposeArgs + @( + "exec", "-T", $Service, + "sh", "-c", + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql' + )) + Invoke-DockerCompose ($ComposeArgs + @("exec", "-T", $Service, "sh", "-c", "rm -f $containerRestorePath")) + Write-Ok "数据库恢复完成" +} + +function Backup-ObjectStorage { + param([string]$VolumeName, [string]$OutputPath, [string]$HelperImage) + + if ([string]::IsNullOrWhiteSpace($VolumeName)) { + throw "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。" + } + + Write-Info "备份 Compose 内置 MinIO 数据卷:$VolumeName" + $backupDir = Split-Path -Parent $OutputPath + & docker volume inspect $VolumeName *> $null + if ($LASTEXITCODE -ne 0) { + throw "未找到 MinIO 数据卷:$VolumeName" + } + + & docker run --rm -v "${VolumeName}:/source:ro" -v "${backupDir}:/backup" $HelperImage sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ." + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { + throw "对象存储备份失败。请确认 helper 镜像可用:$HelperImage" + } + + Write-Ok "对象存储备份完成:$OutputPath" +} + +function Restore-ObjectStorage { + param([string]$VolumeName, [string]$InputPath, [string]$HelperImage) + + if ([string]::IsNullOrWhiteSpace($VolumeName)) { + throw "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。" + } + + $backupDir = Split-Path -Parent $InputPath + Write-Info "恢复 Compose 内置 MinIO 数据卷:$VolumeName" + & docker volume inspect $VolumeName *> $null + if ($LASTEXITCODE -ne 0) { + & docker volume create $VolumeName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "创建 MinIO 数据卷失败:$VolumeName" + } + } + + & docker run --rm -v "${VolumeName}:/target" -v "${backupDir}:/backup:ro" $HelperImage sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz" + if ($LASTEXITCODE -ne 0) { + throw "对象存储恢复失败。请确认 helper 镜像可用:$HelperImage" + } + + Write-Ok "对象存储恢复完成" +} + +function Save-CommandOutput { + param([string]$Path, [scriptblock]$Command) + + try { + & $Command *>&1 | Out-File -LiteralPath $Path -Encoding utf8 + } catch { + "命令执行失败:$($_.Exception.Message)" | Out-File -LiteralPath $Path -Encoding utf8 + } +} + +function Invoke-Verify { + param([string[]]$ComposeArgs, [hashtable]$EnvValues, [int]$TimeoutSeconds) + + Write-Info "检查 Compose 配置" + Invoke-DockerCompose ($ComposeArgs + @("config", "--quiet")) + + Write-Info "查看容器状态" + Invoke-DockerCompose ($ComposeArgs + @("ps")) + + $publicUrl = Get-EnvValue $EnvValues "APP_PUBLIC_URL" + if ([string]::IsNullOrWhiteSpace($publicUrl)) { + $publicUrl = Get-EnvValue $EnvValues "NEXTAUTH_URL" + } + + if ([string]::IsNullOrWhiteSpace($publicUrl)) { + Write-Warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。" + return + } + + $healthUrl = $publicUrl.TrimEnd("/") + "/api/health" + Write-Info "检查健康接口:$healthUrl" + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 10 + if ($response.StatusCode -eq 200) { + Write-Ok "/api/health 正常" + return + } + } catch { + Start-Sleep -Seconds 5 + } + } while ((Get-Date) -lt $deadline) + + throw "/api/health 在 $TimeoutSeconds 秒内没有通过:$healthUrl" +} + +$resolvedProjectDir = Resolve-ProjectPath $ProjectDir +Push-Location $resolvedProjectDir +try { + $composePath = Resolve-ExistingFile $ComposeFile "Compose 文件" + $envPath = Join-Path $resolvedProjectDir $EnvFile + $envValues = Read-EnvFile $envPath + $composeArgs = Get-ComposeArgs -ComposeFilePath $composePath -EnvFilePath $envPath + + switch ($Action) { + "Backup" { + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $backupRoot = Join-Path $resolvedProjectDir $BackupDir + $targetDir = Join-Path $backupRoot "flnxi-backup-$timestamp" + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + + Copy-Item -LiteralPath $composePath -Destination (Join-Path $targetDir "docker-compose.prod.yml") -Force + if (Test-Path -LiteralPath $envPath -PathType Leaf) { + Copy-Item -LiteralPath $envPath -Destination (Join-Path $targetDir ".env") -Force + } + if (Test-Path -LiteralPath "VERSION.txt" -PathType Leaf) { + Copy-Item -LiteralPath "VERSION.txt" -Destination (Join-Path $targetDir "VERSION.txt") -Force + } + + Save-CommandOutput -Path (Join-Path $targetDir "compose-config.txt") -Command { & docker @composeArgs config } + Save-CommandOutput -Path (Join-Path $targetDir "compose-ps.txt") -Command { & docker @composeArgs ps } + Save-CommandOutput -Path (Join-Path $targetDir "docker-images.txt") -Command { & docker images --digests } + + $externalDb = Test-ExternalDatabase $envValues + if ($SkipDatabaseBackup) { + "已按参数跳过数据库备份。请确认外部备份已存在。" | Out-File -LiteralPath (Join-Path $targetDir "DATABASE_BACKUP_SKIPPED.txt") -Encoding utf8 + } elseif ($externalDb) { + "检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。" | Out-File -LiteralPath (Join-Path $targetDir "EXTERNAL_DATABASE_BACKUP_REQUIRED.txt") -Encoding utf8 + } else { + Backup-Database -ComposeArgs $composeArgs -Service $PostgresService -OutputPath (Join-Path $targetDir "postgres.sql") + } + + $minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data" + if ($SkipObjectStorageBackup) { + "已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" | Out-File -LiteralPath (Join-Path $targetDir "OBJECT_STORAGE_BACKUP_SKIPPED.txt") -Encoding utf8 + } else { + Backup-ObjectStorage -VolumeName $minioVolumeName -OutputPath (Join-Path $targetDir "minio-data.tar.gz") -HelperImage $HelperImage + } + + $manifest = [ordered]@{ + createdAt = (Get-Date).ToString("s") + projectDir = $resolvedProjectDir + composeFile = $composePath + envFile = $envPath + appImage = Get-EnvValue $envValues "APP_IMAGE" + appPublicUrl = Get-EnvValue $envValues "APP_PUBLIC_URL" + postgresService = $PostgresService + minioVolumeName = $minioVolumeName + externalDatabase = $externalDb + restoreConfirmPhrase = $RestoreConfirmPhrase + } + $manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $targetDir "manifest.json") -Encoding utf8 + Write-Ok "备份目录:$targetDir" + } + + "Restore" { + if ([string]::IsNullOrWhiteSpace($BackupPath)) { + throw "恢复必须传入 -BackupPath。" + } + if ($ConfirmRestore -ne $RestoreConfirmPhrase) { + throw "恢复会覆盖客户数据。请显式传入 -ConfirmRestore $RestoreConfirmPhrase。" + } + + $sourceDir = Resolve-ProjectPath $BackupPath + $dbBackup = Join-Path $sourceDir "postgres.sql" + $objectBackup = Join-Path $sourceDir "minio-data.tar.gz" + + Write-Warn "即将恢复备份目录:$sourceDir" + Invoke-DockerCompose ($composeArgs + @("stop", $AppService)) + + if ($RestoreEnv -and (Test-Path -LiteralPath (Join-Path $sourceDir ".env") -PathType Leaf)) { + $envBackupPath = Join-Path $resolvedProjectDir (".env.before-restore-" + (Get-Date -Format "yyyyMMdd-HHmmss")) + if (Test-Path -LiteralPath $envPath -PathType Leaf) { + Copy-Item -LiteralPath $envPath -Destination $envBackupPath -Force + } + Copy-Item -LiteralPath (Join-Path $sourceDir ".env") -Destination $envPath -Force + $envValues = Read-EnvFile $envPath + Write-Ok ".env 已恢复,恢复前副本:$envBackupPath" + } + + if (Test-Path -LiteralPath $dbBackup -PathType Leaf) { + Restore-Database -ComposeArgs $composeArgs -Service $PostgresService -InputPath $dbBackup + } else { + Write-Warn "备份目录没有 postgres.sql,跳过数据库恢复。" + } + + if (-not $SkipObjectStorageRestore -and (Test-Path -LiteralPath $objectBackup -PathType Leaf)) { + Invoke-DockerCompose ($composeArgs + @("stop", $MinioService)) + $minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data" + Restore-ObjectStorage -VolumeName $minioVolumeName -InputPath $objectBackup -HelperImage $HelperImage + } elseif ($SkipObjectStorageRestore) { + Write-Warn "已按参数跳过对象存储恢复。" + } else { + Write-Warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。" + } + + Invoke-DockerCompose ($composeArgs + @("up", "-d")) + Invoke-Verify -ComposeArgs $composeArgs -EnvValues (Read-EnvFile $envPath) -TimeoutSeconds $HealthTimeoutSeconds + Write-Ok "恢复和基础验收完成" + } + + "Verify" { + Invoke-Verify -ComposeArgs $composeArgs -EnvValues $envValues -TimeoutSeconds $HealthTimeoutSeconds + } + } +} finally { + Pop-Location +} diff --git a/scripts/generate-shared-test-env.ps1 b/scripts/generate-shared-test-env.ps1 index 1ccdcfd..e728ffd 100644 --- a/scripts/generate-shared-test-env.ps1 +++ b/scripts/generate-shared-test-env.ps1 @@ -8,8 +8,10 @@ param( [int]$AgentGoPort = 9091, [string]$ComposeProjectName = "flnxi_sucai_test", [string]$ContainerPrefix = "flnxi-sucai-test", + [string]$AppImage, [string]$LicenseServerUrl, [string]$LicenseProjectCode = "flnxi-sucai", + [string]$LicenseRequestSigningSecret, [string]$InitAdminEmail = "admin@test.flnxi.local" ) @@ -20,6 +22,10 @@ if ([string]::IsNullOrWhiteSpace($LicenseServerUrl)) { throw "必须显式传入 -LicenseServerUrl,避免把过期或错误的许可证地址写进测试环境。" } +if ([string]::IsNullOrWhiteSpace($AppImage)) { + throw "必须显式传入 -AppImage,测试环境也要固定完整镜像版本,不能依赖 latest。" +} + function New-RandomString { param( [int]$Length = 32, @@ -105,6 +111,9 @@ $minioRootPassword = New-RandomString -Length 24 $nextAuthSecret = New-RandomString -Length 48 $localEncryptionKey = New-RandomString -Length 48 $systemInitToken = New-RandomString -Length 40 +if ([string]::IsNullOrWhiteSpace($LicenseRequestSigningSecret)) { + $LicenseRequestSigningSecret = New-RandomString -Length 48 +} $s3Bucket = "flnxi-sucai-test" $appUrl = "http://${ServerIp}:${AppPort}" $s3PublicDomain = "http://${ServerIp}:${MinioApiPort}/${s3Bucket}" @@ -131,6 +140,9 @@ MINIO_CONSOLE_PORT=$MinioConsolePort AGENT_PORT=$AgentPort AGENT_GO_PORT=$AgentGoPort +# Versioned application image +APP_IMAGE=$AppImage + # Public URLs SERVER_IP=$ServerIp APP_PUBLIC_URL=$appUrl @@ -140,6 +152,7 @@ NEXTAUTH_SECRET=$nextAuthSecret # License LICENSE_SERVER_URL=$LicenseServerUrl LICENSE_PROJECT_CODE=$LicenseProjectCode +LICENSE_REQUEST_SIGNING_SECRET=$LicenseRequestSigningSecret LICENSE_PUBLIC_KEY="$licensePublicKey" LOCAL_ENCRYPTION_KEY=$localEncryptionKey diff --git a/scripts/smoke-install.ps1 b/scripts/smoke-install.ps1 new file mode 100644 index 0000000..97acea7 --- /dev/null +++ b/scripts/smoke-install.ps1 @@ -0,0 +1,195 @@ +param( + [string]$ProjectDir = ".", + [string]$ComposeFile = "docker-compose.prod.yml", + [string]$EnvFile = ".env", + [string]$AppService = "app", + [string]$PostgresService = "postgres", + [string]$MinioService = "minio", + [switch]$Start, + [switch]$SkipMigrationStatus, + [switch]$SkipObjectStorageCheck, + [int]$HealthTimeoutSeconds = 120 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" +} + +function Write-Ok { + param([string]$Message) + Write-Host "[OK] $Message" +} + +function Read-EnvFile { + param([string]$Path) + + $values = @{} + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $values + } + + foreach ($line in Get-Content -LiteralPath $Path) { + if ($line -match '^\s*#' -or $line -notmatch '=') { + continue + } + + $parts = $line -split '=', 2 + $key = $parts[0].Trim() + if ([string]::IsNullOrWhiteSpace($key)) { + continue + } + + $value = $parts[1].Trim() + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + $values[$key] = $value + } + + return $values +} + +function Get-EnvValue { + param([hashtable]$EnvValues, [string]$Name, [string]$Default = "") + + if ($EnvValues.ContainsKey($Name)) { + return $EnvValues[$Name] + } + + return $Default +} + +function Invoke-DockerCompose { + param([string[]]$Arguments) + + & docker @Arguments + if ($LASTEXITCODE -ne 0) { + throw "docker 命令失败,退出码:$LASTEXITCODE" + } +} + +function Get-ServiceContainerId { + param([string[]]$ComposeArgs, [string]$Service) + + $containerId = (& docker @ComposeArgs ps -q $Service).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) { + throw "未找到服务容器:$Service。" + } + + return $containerId +} + +function Wait-Health { + param([string]$HealthUrl, [int]$TimeoutSeconds) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $response = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 10 + if ($response.StatusCode -eq 200) { + Write-Ok "/api/health 正常" + return + } + } catch { + Start-Sleep -Seconds 5 + } + } while ((Get-Date) -lt $deadline) + + throw "/api/health 在 $TimeoutSeconds 秒内没有通过:$HealthUrl" +} + +function Wait-ServiceRunning { + param([string]$ContainerId, [string]$Service, [int]$TimeoutSeconds) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $status = (& docker inspect $ContainerId --format '{{.State.Status}}').Trim() + if ($LASTEXITCODE -eq 0 -and $status -eq "running") { + Write-Ok "$Service 容器已运行" + return + } + Start-Sleep -Seconds 5 + } while ((Get-Date) -lt $deadline) + + throw "$Service 容器在 $TimeoutSeconds 秒内没有稳定运行。" +} + +$resolvedProjectDir = (Resolve-Path -LiteralPath $ProjectDir).Path +Push-Location $resolvedProjectDir +try { + if (-not (Test-Path -LiteralPath $ComposeFile -PathType Leaf)) { + throw "Compose 文件不存在:$ComposeFile" + } + + $composePath = (Resolve-Path -LiteralPath $ComposeFile).Path + $envPath = Join-Path $resolvedProjectDir $EnvFile + $envValues = Read-EnvFile $envPath + $composeArgs = @("compose", "-f", $composePath) + if (Test-Path -LiteralPath $envPath -PathType Leaf) { + $composeArgs += @("--env-file", $envPath) + } + + Write-Info "检查 Compose 配置" + Invoke-DockerCompose ($composeArgs + @("config", "--quiet")) + + if ($Start) { + Write-Info "启动客户安装编排" + Invoke-DockerCompose ($composeArgs + @("up", "-d")) + } + + Write-Info "检查容器状态" + Invoke-DockerCompose ($composeArgs + @("ps")) + + $appContainerId = Get-ServiceContainerId -ComposeArgs $composeArgs -Service $AppService + Wait-ServiceRunning -ContainerId $appContainerId -Service $AppService -TimeoutSeconds $HealthTimeoutSeconds + + $runningImage = (& docker inspect $appContainerId --format '{{.Config.Image}}').Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runningImage)) { + throw "无法读取应用运行镜像。" + } + Write-Ok "应用运行镜像:$runningImage" + + $versionOutput = & docker @composeArgs exec -T $AppService sh -c 'cat VERSION.txt 2>/dev/null || true' + $version = "$versionOutput".Trim() + if (-not [string]::IsNullOrWhiteSpace($version)) { + Write-Ok "应用版本文件:$version" + } else { + Write-Host "[WARN] 镜像内未读到 VERSION.txt,请确认发布镜像是否包含版本文件。" + } + + $publicUrl = Get-EnvValue $envValues "APP_PUBLIC_URL" + if ([string]::IsNullOrWhiteSpace($publicUrl)) { + $publicUrl = Get-EnvValue $envValues "NEXTAUTH_URL" + } + if ([string]::IsNullOrWhiteSpace($publicUrl)) { + throw "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,无法验收健康接口。" + } + + Wait-Health -HealthUrl ($publicUrl.TrimEnd("/") + "/api/health") -TimeoutSeconds $HealthTimeoutSeconds + + if (-not $SkipMigrationStatus) { + Write-Info "检查数据库迁移状态" + Invoke-DockerCompose ($composeArgs + @("exec", "-T", $AppService, "npx", "prisma", "migrate", "status", "--schema=prisma/schema.prisma")) + } + + if (-not $SkipObjectStorageCheck) { + $bucket = Get-EnvValue $envValues "S3_BUCKET" "flnxi-materials" + Write-Info "检查 MinIO bucket:$bucket" + Invoke-DockerCompose ($composeArgs + @("exec", "-T", $MinioService, "sh", "-c", "test -d /data/$bucket")) + Write-Ok "对象存储 bucket 存在:$bucket" + } + + Write-Info "最近应用日志" + & docker @composeArgs logs --tail 80 $AppService + if ($LASTEXITCODE -ne 0) { + throw "读取应用日志失败。" + } + + Write-Ok "首装基础验收完成" +} finally { + Pop-Location +}