补齐 NAS 客户首装备份验收脚本
This commit is contained in:
parent
80ad51e3dd
commit
3256524271
@ -1,5 +1,5 @@
|
|||||||
# Base application configuration
|
# Base application configuration
|
||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
|
|
||||||
# Public URLs
|
# Public URLs
|
||||||
SERVER_IP=192.168.x.x
|
SERVER_IP=192.168.x.x
|
||||||
|
|||||||
@ -94,7 +94,7 @@ gongxiangp2-source-reference-old-style-20260513-132942/
|
|||||||
- `flnxi-agent/`:Node.js / Express / Socket.IO
|
- `flnxi-agent/`:Node.js / Express / Socket.IO
|
||||||
- `flnxi-agent-go/`:Go / Gin / Docker SDK
|
- `flnxi-agent-go/`:Go / Gin / Docker SDK
|
||||||
|
|
||||||
Agent 是实施和运维编排工具,不是普通客户日常入口,也不是当前唯一正式安装器。正式客户首装仍以 `docker-compose.prod.yml + .env + scripts/smoke-install.ps1` 为主,Agent 只在交付方案明确采用时启用。
|
Agent 是实施和运维编排工具,不是普通客户日常入口,也不是当前唯一正式安装器。正式客户首装仍以 `docker-compose.prod.yml + .env` 为主;NAS / Linux 现场优先用 `scripts/customer-install.sh` / `scripts/smoke-install.sh` 做检查和验收,Windows 实施机可用对应 `.ps1` 脚本。Agent 只在交付方案明确采用时启用。
|
||||||
|
|
||||||
## 6. 本地开发
|
## 6. 本地开发
|
||||||
|
|
||||||
@ -207,6 +207,10 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
内部维护环境仍保留:
|
内部维护环境仍保留:
|
||||||
|
|
||||||
- `deploy-registry.ps1`
|
- `deploy-registry.ps1`
|
||||||
|
- `scripts/customer-install.sh`
|
||||||
|
- `scripts/smoke-install.sh`
|
||||||
|
- `scripts/backup-restore.sh`
|
||||||
|
- `scripts/customer-acceptance-smoke.mjs`
|
||||||
- `scripts/customer-install.ps1`
|
- `scripts/customer-install.ps1`
|
||||||
- `scripts/smoke-install.ps1`
|
- `scripts/smoke-install.ps1`
|
||||||
- `scripts/backup-restore.ps1`
|
- `scripts/backup-restore.ps1`
|
||||||
@ -215,7 +219,7 @@ docker compose -f docker-compose.prod.yml up -d
|
|||||||
- `发布管理.ps1`
|
- `发布管理.ps1`
|
||||||
- `license-server/deploy.ps1`
|
- `license-server/deploy.ps1`
|
||||||
|
|
||||||
其中 `scripts/customer-install.ps1` 是客户首装分步骤入口,内部调用 `scripts/smoke-install.ps1` 做预检、启动和验收;`scripts/backup-restore.ps1` 用于客户环境备份、恢复和恢复后验收;`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层,也可以设置体积/层数阈值,超限时阻止发布继续。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
|
其中 `scripts/customer-install.sh` 是 NAS / Linux 客户首装分步骤入口,内部调用 `scripts/smoke-install.sh` 做预检、启动和验收;`scripts/backup-restore.sh` 用于 NAS / Linux 客户环境备份、恢复和恢复后验收;`scripts/customer-acceptance-smoke.mjs` 用于客户业务验收,可只读检查,也可用临时测试账号执行一次真实小素材上传;`.ps1` 版本用于 Windows 实施机或已安装 PowerShell 的现场;`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层,也可以设置体积/层数阈值,超限时阻止发布继续。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
|
||||||
|
|
||||||
## 10. 文档入口
|
## 10. 文档入口
|
||||||
|
|
||||||
|
|||||||
@ -9,13 +9,13 @@ param(
|
|||||||
[string]$Action = "DeployApp",
|
[string]$Action = "DeployApp",
|
||||||
|
|
||||||
[string]$Server = "",
|
[string]$Server = "",
|
||||||
[int]$SshPort = 18000,
|
[int]$SshPort = 22,
|
||||||
[string]$Username = "root",
|
[string]$Username = "root",
|
||||||
[SecureString]$Password = $null,
|
[SecureString]$Password = $null,
|
||||||
[string]$KeyPath = "",
|
[string]$KeyPath = "",
|
||||||
[string]$PlinkHostKey = "",
|
[string]$PlinkHostKey = "",
|
||||||
|
|
||||||
[string]$RemotePath = "/root/deploy",
|
[string]$RemotePath = "/opt/flnxi",
|
||||||
[string]$ComposeFile = "docker-compose.prod.yml",
|
[string]$ComposeFile = "docker-compose.prod.yml",
|
||||||
[string]$ComposeService = "app",
|
[string]$ComposeService = "app",
|
||||||
[int]$AppPort = 4320,
|
[int]$AppPort = 4320,
|
||||||
@ -117,7 +117,7 @@ function Show-Help {
|
|||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "主要参数:" -ForegroundColor White
|
Write-Host "主要参数:" -ForegroundColor White
|
||||||
Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入"
|
Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入"
|
||||||
Write-Host " -SshPort SSH端口,默认 18000"
|
Write-Host " -SshPort SSH端口,默认 22;内部测试机或客户自定义端口必须显式传入"
|
||||||
Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入"
|
Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入"
|
||||||
Write-Host " -RegistryUsername Registry账号,默认 flnxi"
|
Write-Host " -RegistryUsername Registry账号,默认 flnxi"
|
||||||
Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入"
|
Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入"
|
||||||
@ -128,7 +128,7 @@ function Show-Help {
|
|||||||
Write-Host " -Version 正式版或 Agent 版本号"
|
Write-Host " -Version 正式版或 Agent 版本号"
|
||||||
Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)"
|
Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)"
|
||||||
Write-Host " -AgentRepository Agent 镜像路径,默认 agent"
|
Write-Host " -AgentRepository Agent 镜像路径,默认 agent"
|
||||||
Write-Host " -RemotePath 远程部署目录,默认 /root/deploy"
|
Write-Host " -RemotePath 远程部署目录,默认 /opt/flnxi;已有客户环境必须传入真实部署目录"
|
||||||
Write-Host " -FullComposeUp 远程执行完整 docker compose up -d;默认只更新 app 服务"
|
Write-Host " -FullComposeUp 远程执行完整 docker compose up -d;默认只更新 app 服务"
|
||||||
Write-Host " -UpdateRemoteEnv 兼容旧参数;DeployApp 现在默认会把 APP_IMAGE 等写入远程 .env"
|
Write-Host " -UpdateRemoteEnv 兼容旧参数;DeployApp 现在默认会把 APP_IMAGE 等写入远程 .env"
|
||||||
Write-Host " -NoUpdateRemoteEnv 只让本次 compose 命令临时生效,不持久写入远程 .env"
|
Write-Host " -NoUpdateRemoteEnv 只让本次 compose 命令临时生效,不持久写入远程 .env"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# FLNXI素材库 - Pro版配置
|
# FLNXI素材库 - 私有化生产配置
|
||||||
# 支持内网穿透,资源存储在MinIO存储桶
|
# 默认使用内置 PostgreSQL + MinIO,也可通过 .env 切换外部数据库或对象存储。
|
||||||
# 从私有镜像仓库拉取镜像,无需源码
|
# 主应用从版本化 Registry 镜像启动,客户现场无需源码。
|
||||||
# ============================================
|
# ============================================
|
||||||
# Registry 项目结构:
|
# Registry 项目结构:
|
||||||
# - flnxi/runtime:PostgreSQL、MinIO 等运行依赖镜像
|
# - flnxi/runtime:PostgreSQL、MinIO 等运行依赖镜像
|
||||||
|
|||||||
@ -48,6 +48,8 @@
|
|||||||
本轮按商业化私有交付目标收口的 8 项阶段记录,只在回看本轮收口时查看,不作为默认入口。
|
本轮按商业化私有交付目标收口的 8 项阶段记录,只在回看本轮收口时查看,不作为默认入口。
|
||||||
- `客户首装升级坑位排查_20260530.md`
|
- `客户首装升级坑位排查_20260530.md`
|
||||||
客户首装、升级、回滚、许可证服务和对象存储的历史排障记录,已被更新验证覆盖,默认看部署说明即可。
|
客户首装、升级、回滚、许可证服务和对象存储的历史排障记录,已被更新验证覆盖,默认看部署说明即可。
|
||||||
|
- `商业化首装演练记录_20260601.md`
|
||||||
|
飞牛 fnOS 虚拟机上的首装验收记录,用于追溯 Linux/NAS 脚本和真实测试环境结论。
|
||||||
- `许可证链路安全评估_20260312.md`
|
- `许可证链路安全评估_20260312.md`
|
||||||
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。
|
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。
|
||||||
|
|
||||||
|
|||||||
@ -242,6 +242,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
|
|
||||||
- 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。
|
- 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。
|
||||||
- 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。
|
- 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。
|
||||||
|
- 客户 SSH 端口、登录用户和部署目录必须按现场显式确认;脚本默认端口是常见的 `22`,默认目录是 `/opt/flnxi`,内部测试机或自定义环境要显式传 `-SshPort`、`-Username`、`-RemotePath`。
|
||||||
- `DeployApp` 远程部署时默认会更新远端 `.env` 的 `APP_IMAGE`、访问地址和运行时镜像;只有临时验证时才使用 `-NoUpdateRemoteEnv`。
|
- `DeployApp` 远程部署时默认会更新远端 `.env` 的 `APP_IMAGE`、访问地址和运行时镜像;只有临时验证时才使用 `-NoUpdateRemoteEnv`。
|
||||||
- `DeployApp` 默认会做远端维护窗口停写入、`.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。
|
- `DeployApp` 默认会做远端维护窗口停写入、`.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。
|
||||||
- 旧的 `deploy-new-fixed.ps1` 仍可作为内部维护参考,但不再作为当前主线部署入口。
|
- 旧的 `deploy-new-fixed.ps1` 仍可作为内部维护参考,但不再作为当前主线部署入口。
|
||||||
@ -282,8 +283,30 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
|
|
||||||
## 6. 部署后冒烟测试
|
## 6. 部署后冒烟测试
|
||||||
|
|
||||||
|
NAS / Linux 客户现场优先使用 `.sh` 脚本。如果当前账号没有 Docker 权限,就用 `sudo bash ...` 执行。
|
||||||
|
|
||||||
启动前可以先做一轮不启动容器的安装前检查:
|
启动前可以先做一轮不启动容器的安装前检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/smoke-install.sh --project-dir /opt/flnxi --preflight-only
|
||||||
|
```
|
||||||
|
|
||||||
|
如果希望给实施人员一个更像向导的入口,可以先只看步骤:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/customer-install.sh --project-dir /opt/flnxi --plan-only
|
||||||
|
```
|
||||||
|
|
||||||
|
确认 `.env`、镜像和数据目录都准备好后,让脚本按步骤预检、启动并验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/customer-install.sh --project-dir /opt/flnxi --prepare-directories --start
|
||||||
|
```
|
||||||
|
|
||||||
|
`customer-install.sh` 只是对 `smoke-install.sh` 的分步骤包装,不替代 `docker-compose.prod.yml`,也不把 Agent 当成唯一安装入口。`--prepare-directories` 在 root / sudo 运行时会把 `uploads/` 和 `data/` 调整为应用容器 UID `1001` 可写,能提前拦截飞牛 / NAS 常见目录权限问题。
|
||||||
|
|
||||||
|
Windows 实施机或已安装 PowerShell 的现场,可以使用对应 `.ps1` 脚本:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -PreflightOnly
|
.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi" -PreflightOnly
|
||||||
```
|
```
|
||||||
@ -322,7 +345,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi"
|
.\scripts\smoke-install.ps1 -ProjectDir "D:\deploy\flnxi"
|
||||||
```
|
```
|
||||||
|
|
||||||
该脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。
|
这些脚本会先做安装前检查,再检查 Compose 配置、容器状态、应用运行镜像、镜像内版本文件、Prisma 迁移状态、MinIO bucket 和 `/api/health`。如果客户使用外部对象存储,`.sh` 脚本加 `--skip-object-storage-check`,`.ps1` 脚本加 `-SkipObjectStorageCheck`,对象存储验收按客户现场方案单独执行。
|
||||||
|
|
||||||
人工验收命令仍保留如下:
|
人工验收命令仍保留如下:
|
||||||
|
|
||||||
@ -336,6 +359,34 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
- [ ] 许可证状态显示正确
|
- [ ] 许可证状态显示正确
|
||||||
- [ ] 容器日志没有持续错误
|
- [ ] 容器日志没有持续错误
|
||||||
|
|
||||||
|
如果需要一条命令做客户业务验收,可在实施机或部署机上运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- \
|
||||||
|
--base-url http://<SERVER_IP>:4320
|
||||||
|
```
|
||||||
|
|
||||||
|
提供临时验收账号后,可以继续验登录、后台权限和模板接口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- \
|
||||||
|
--base-url http://<SERVER_IP>:4320 \
|
||||||
|
--email <smoke-admin-email> \
|
||||||
|
--password <smoke-admin-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
需要真实验证数据库和 MinIO 写入时,加 `--write`。该动作会创建一条小素材验收记录,建议只在测试环境或客户允许的验收窗口执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- \
|
||||||
|
--base-url http://<SERVER_IP>:4320 \
|
||||||
|
--email <smoke-admin-email> \
|
||||||
|
--password <smoke-admin-password> \
|
||||||
|
--write
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把验收账号密码写入文档、Git 或聊天记录;如果使用 `.env.smoke`,该文件也必须留在本机,不进入仓库。
|
||||||
|
|
||||||
## 7. 数据备份与回滚
|
## 7. 数据备份与回滚
|
||||||
|
|
||||||
`deploy-registry.ps1 -Action DeployApp` 会默认做一轮升级前备份和失败回滚。下面命令主要用于人工兜底、外部数据库场景或单独恢复演练。
|
`deploy-registry.ps1 -Action DeployApp` 会默认做一轮升级前备份和失败回滚。下面命令主要用于人工兜底、外部数据库场景或单独恢复演练。
|
||||||
@ -344,6 +395,14 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
|
|
||||||
首选脚本:
|
首选脚本:
|
||||||
|
|
||||||
|
NAS / Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/backup-restore.sh --project-dir /opt/flnxi --action backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows / PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup
|
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup
|
||||||
```
|
```
|
||||||
@ -361,12 +420,18 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
|
|
||||||
备份脚本默认也会在备份期间短暂停止 app,并在备份结束后恢复启动,目的是让数据库、MinIO、`data/`、`uploads/` 尽量处在同一个时间点。只有客户现场已经有其它停写入机制时,才考虑加 `-SkipStopAppForBackup`。
|
备份脚本默认也会在备份期间短暂停止 app,并在备份结束后恢复启动,目的是让数据库、MinIO、`data/`、`uploads/` 尽量处在同一个时间点。只有客户现场已经有其它停写入机制时,才考虑加 `-SkipStopAppForBackup`。
|
||||||
|
|
||||||
|
备份包里会包含 `.env`、数据库内容和客户素材文件,通常也就包含访问密钥、许可证缓存和企业内部资料。备份目录必须放在受控位置,按客户现场要求加密或限权保存,不能提交到 Git,也不能随意发到聊天工具。
|
||||||
|
|
||||||
`DeployApp` 升级时生成的 `backups/deploy-时间戳/RESTORE_PLAN.txt` 是升级恢复指引;`backup-restore.ps1 -Action Backup` 生成的 `flnxi-backup-时间戳/manifest.json` 是完整备份包记录。两者用途不同,客户升级失败时优先看当次升级目录里的 `RESTORE_PLAN.txt`。
|
`DeployApp` 升级时生成的 `backups/deploy-时间戳/RESTORE_PLAN.txt` 是升级恢复指引;`backup-restore.ps1 -Action Backup` 生成的 `flnxi-backup-时间戳/manifest.json` 是完整备份包记录。两者用途不同,客户升级失败时优先看当次升级目录里的 `RESTORE_PLAN.txt`。
|
||||||
|
|
||||||
如果 `DATABASE_URL` 指向外部 PostgreSQL,脚本不会假装自动备份,会在备份目录写入 `EXTERNAL_DATABASE_BACKUP_REQUIRED.txt`,客户现场必须按外部数据库方案单独备份。
|
如果 `DATABASE_URL` 指向外部 PostgreSQL,脚本不会假装自动备份,会在备份目录写入 `EXTERNAL_DATABASE_BACKUP_REQUIRED.txt`,客户现场必须按外部数据库方案单独备份。
|
||||||
|
|
||||||
如果对象存储是外部 S3 / 外部 MinIO,或现场已有独立对象存储快照,可加:
|
如果对象存储是外部 S3 / 外部 MinIO,或现场已有独立对象存储快照,可加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/backup-restore.sh --project-dir /opt/flnxi --action backup --skip-object-storage-backup
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup -SkipObjectStorageBackup
|
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup -SkipObjectStorageBackup
|
||||||
```
|
```
|
||||||
@ -375,6 +440,14 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
|
|
||||||
### 7.2 一键验收
|
### 7.2 一键验收
|
||||||
|
|
||||||
|
NAS / Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup-restore.sh --project-dir /opt/flnxi --action verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows / PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Verify
|
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Verify
|
||||||
```
|
```
|
||||||
@ -385,6 +458,18 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
|
|||||||
|
|
||||||
恢复会覆盖客户数据,必须停写入并确认备份来源正确。脚本要求输入固定确认短语,避免误操作:
|
恢复会覆盖客户数据,必须停写入并确认备份来源正确。脚本要求输入固定确认短语,避免误操作:
|
||||||
|
|
||||||
|
NAS / Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/backup-restore.sh \
|
||||||
|
--project-dir /opt/flnxi \
|
||||||
|
--action restore \
|
||||||
|
--backup-path /opt/flnxi/backups/flnxi-backup-YYYYMMDD-HHMMSS \
|
||||||
|
--confirm-restore RESTORE_FLNXI_DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows / PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\backup-restore.ps1 `
|
.\scripts\backup-restore.ps1 `
|
||||||
-ProjectDir "D:\deploy\flnxi" `
|
-ProjectDir "D:\deploy\flnxi" `
|
||||||
|
|||||||
234
docs/商业化首装演练记录_20260601.md
Normal file
234
docs/商业化首装演练记录_20260601.md
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
# 商业化首装演练记录 2026-06-01
|
||||||
|
|
||||||
|
本文记录本轮在本机飞牛 fnOS 虚拟机上的客户首装验收情况。只沉淀环境事实、验证结果和后续坑位,不记录 SSH 密码、许可证密钥、对象存储密钥或其它敏感信息。
|
||||||
|
|
||||||
|
## 1. 演练结论
|
||||||
|
|
||||||
|
- 飞牛 fnOS 虚拟机可以作为当前商业化首装/验收测试环境。
|
||||||
|
- 当前已有一套 `flnxi-fnos-smoke` 环境运行正常,未覆盖、未删除。
|
||||||
|
- 新增的 NAS / Linux 首装验收脚本 `scripts/customer-install.sh` 和 `scripts/smoke-install.sh` 已在飞牛 VM 上通过只读验收。
|
||||||
|
- 客户首装的真实主入口仍是 `docker-compose.prod.yml + .env`;脚本只是预检、启动和验收包装。
|
||||||
|
|
||||||
|
## 2. 环境现场
|
||||||
|
|
||||||
|
- 虚拟机地址:`192.168.116.128`
|
||||||
|
- 系统:Linux / fnOS,内核 `6.18.18-trim`
|
||||||
|
- Docker Server:`28.5.2`
|
||||||
|
- 可用磁盘:`/vol1` 约 `35G`
|
||||||
|
- 飞牛 Web 管理端:`5666`
|
||||||
|
- 当前测试部署目录:`/vol1/flnxi-customer-smoke`
|
||||||
|
|
||||||
|
当前运行中的 FLNXI 容器:
|
||||||
|
|
||||||
|
- `flnxi-fnos-smoke-app`
|
||||||
|
- `flnxi-fnos-smoke-postgres`
|
||||||
|
- `flnxi-fnos-smoke-minio`
|
||||||
|
|
||||||
|
当前测试端口:
|
||||||
|
|
||||||
|
- 应用:`4320 -> 3000`
|
||||||
|
- MinIO API:`39200 -> 9000`
|
||||||
|
- MinIO Console:`39201 -> 9001`
|
||||||
|
|
||||||
|
## 3. 已验证内容
|
||||||
|
|
||||||
|
### 3.1 远端健康检查
|
||||||
|
|
||||||
|
远端本机访问:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:4320/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:返回 `status: ok`,启动检查执行成功。
|
||||||
|
|
||||||
|
登录页:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://127.0.0.1:4320/login
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:返回 `HTTP/1.1 200 OK`。
|
||||||
|
|
||||||
|
### 3.2 NAS / Linux 首装向导
|
||||||
|
|
||||||
|
已把本轮新增脚本复制到测试部署目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/vol1/flnxi-customer-smoke/scripts/customer-install.sh
|
||||||
|
/vol1/flnxi-customer-smoke/scripts/smoke-install.sh
|
||||||
|
/vol1/flnxi-customer-smoke/scripts/backup-restore.sh
|
||||||
|
/vol1/flnxi-customer-smoke/scripts/customer-acceptance-smoke.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
只看步骤:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /vol1/flnxi-customer-smoke/scripts/customer-install.sh \
|
||||||
|
--project-dir /vol1/flnxi-customer-smoke \
|
||||||
|
--plan-only
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:通过,能按 4 步展示预检、启动方式、验收和结果说明。
|
||||||
|
|
||||||
|
安装前检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /vol1/flnxi-customer-smoke/scripts/smoke-install.sh \
|
||||||
|
--project-dir /vol1/flnxi-customer-smoke \
|
||||||
|
--preflight-only
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:通过。
|
||||||
|
|
||||||
|
只读验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /vol1/flnxi-customer-smoke/scripts/smoke-install.sh \
|
||||||
|
--project-dir /vol1/flnxi-customer-smoke \
|
||||||
|
--health-timeout-seconds 60
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:通过。
|
||||||
|
|
||||||
|
脚本已验证:
|
||||||
|
|
||||||
|
- Compose 配置有效
|
||||||
|
- 三个容器运行正常
|
||||||
|
- 应用镜像为 `flnxi-material:customer-smoke-20260530`
|
||||||
|
- 镜像内 `VERSION.txt` 为 `1.0.0`
|
||||||
|
- `/api/health` 正常
|
||||||
|
- Prisma 迁移状态为最新
|
||||||
|
- MinIO bucket 存在
|
||||||
|
|
||||||
|
### 3.3 NAS / Linux 备份验收
|
||||||
|
|
||||||
|
备份前健康验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /vol1/flnxi-customer-smoke/scripts/backup-restore.sh \
|
||||||
|
--project-dir /vol1/flnxi-customer-smoke \
|
||||||
|
--action verify \
|
||||||
|
--health-timeout-seconds 60
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:通过。
|
||||||
|
|
||||||
|
实际备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /vol1/flnxi-customer-smoke/scripts/backup-restore.sh \
|
||||||
|
--project-dir /vol1/flnxi-customer-smoke \
|
||||||
|
--action backup \
|
||||||
|
--health-timeout-seconds 60
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:备份成功,生成目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/vol1/flnxi-customer-smoke/backups/flnxi-backup-20260601-150351
|
||||||
|
```
|
||||||
|
|
||||||
|
备份内容包括:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- `docker-compose.prod.yml`
|
||||||
|
- `compose-config.txt`
|
||||||
|
- `compose-ps.txt`
|
||||||
|
- `docker-images.txt`
|
||||||
|
- `postgres.sql`
|
||||||
|
- `minio-data.tar.gz`
|
||||||
|
- `data.tar.gz`
|
||||||
|
- `uploads.tar.gz`
|
||||||
|
- `manifest.json`
|
||||||
|
- `README-SENSITIVE.txt`
|
||||||
|
|
||||||
|
补充修正脚本后再次备份,生成目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/vol1/flnxi-customer-smoke/backups/flnxi-backup-20260601-150543
|
||||||
|
```
|
||||||
|
|
||||||
|
这次脚本在备份结束后自动等待健康检查,`/api/health` 再次返回 `status: ok`。
|
||||||
|
|
||||||
|
### 3.4 客户业务 smoke
|
||||||
|
|
||||||
|
本轮新增:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- --base-url http://<SERVER_IP>:4320
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本用于补足“容器活着但业务不一定可用”的空白:
|
||||||
|
|
||||||
|
- 只读模式检查 `/api/health`、`/api/system/init`、`/login` 和未登录后台拦截。
|
||||||
|
- 提供临时验收账号后,检查登录、上传模板、内置模板、默认密码检查和后台用户接口。
|
||||||
|
- 加 `--write` 后,执行一次真实小素材上传,验证数据库和 MinIO/S3 写入。
|
||||||
|
|
||||||
|
本轮在飞牛 VM 上已验证:
|
||||||
|
|
||||||
|
- 只读模式通过。
|
||||||
|
- 使用临时 smoke 管理员登录后,模板接口、默认密码检查和后台用户接口通过。
|
||||||
|
- `--write` 上传被许可证拦截,返回:`当前许可证最多支持 0 个素材,当前已有 0 个素材。请联系管理员处理授权。` 这说明当前 VM 的许可证状态不允许素材写入;脚本能正确把这类业务验收失败暴露出来。后续要验证真实上传,需要先给测试环境配置允许上传的许可证或换到已授权环境。
|
||||||
|
- 临时 smoke 管理员已在验证结束后删除,未作为长期账号保留。
|
||||||
|
|
||||||
|
## 4. 已发现和处理的坑
|
||||||
|
|
||||||
|
### 4.1 不能只提供 PowerShell 首装脚本
|
||||||
|
|
||||||
|
目标客户环境是 NAS / Linux,不能默认现场有 PowerShell。已新增:
|
||||||
|
|
||||||
|
- `scripts/customer-install.sh`
|
||||||
|
- `scripts/smoke-install.sh`
|
||||||
|
- `scripts/backup-restore.sh`
|
||||||
|
- `scripts/customer-acceptance-smoke.mjs`
|
||||||
|
|
||||||
|
文档已同步说明:NAS / Linux 优先 `.sh`,Windows 实施机或已安装 PowerShell 的现场可用 `.ps1`。
|
||||||
|
|
||||||
|
### 4.2 只换目录不够,必须换容器名/卷名/端口
|
||||||
|
|
||||||
|
飞牛 VM 上已经有一套 `flnxi-fnos-smoke`。后续如果做全新目录演练,必须在 `.env` 里同时修改:
|
||||||
|
|
||||||
|
- `COMPOSE_PROJECT_NAME`
|
||||||
|
- `APP_CONTAINER_NAME`
|
||||||
|
- `POSTGRES_CONTAINER_NAME`
|
||||||
|
- `MINIO_CONTAINER_NAME`
|
||||||
|
- `MINIO_INIT_CONTAINER_NAME`
|
||||||
|
- `POSTGRES_VOLUME_NAME`
|
||||||
|
- `MINIO_VOLUME_NAME`
|
||||||
|
- `APP_NETWORK_NAME`
|
||||||
|
- `APP_PORT`
|
||||||
|
- `MINIO_API_PORT`
|
||||||
|
- `MINIO_CONSOLE_PORT`
|
||||||
|
|
||||||
|
否则可能撞旧容器、旧卷、旧网络或端口。
|
||||||
|
|
||||||
|
### 4.3 fnOS 上 Docker 需要 sudo
|
||||||
|
|
||||||
|
当前 `admin` 用户不能直接访问 Docker daemon,脚本需要用 `sudo bash ...` 执行,或把实施账号加入合适的 Docker 权限组。
|
||||||
|
|
||||||
|
### 4.4 admin 家目录提示不阻断
|
||||||
|
|
||||||
|
SSH 登录会提示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Could not chdir to home directory /home/admin: No such file or directory
|
||||||
|
```
|
||||||
|
|
||||||
|
这不影响命令执行,但如果后续做长期自动化,建议在飞牛系统里补齐该用户家目录或换成更标准的实施账号。
|
||||||
|
|
||||||
|
### 4.5 备份后必须等待应用健康
|
||||||
|
|
||||||
|
第一次跑 `backup-restore.sh --action backup` 时,备份本身成功,脚本也重新启动了 app,但外部立刻请求 `/api/health` 会遇到应用刚启动的短暂窗口期。已修正脚本:如果备份前停止过 app,备份结束后会自动执行健康验收,避免实施人员误以为恢复启动失败。
|
||||||
|
|
||||||
|
### 4.6 业务 smoke 能暴露许可证限制
|
||||||
|
|
||||||
|
当前飞牛 VM 的许可证允许素材数为 `0`,所以真实上传 smoke 会失败。这不是脚本失败,而是业务前置条件没有满足。第一版商业化验收时,必须把“许可证是否允许上传”放进验收清单,否则系统容器正常也可能无法交付使用。
|
||||||
|
|
||||||
|
## 5. 下一步建议
|
||||||
|
|
||||||
|
1. 新建独立目录做一次真正“空目录首装”,不要覆盖 `/vol1/flnxi-customer-smoke`。
|
||||||
|
2. 目录建议:`/vol1/flnxi-commercial-rehearsal-20260601`。
|
||||||
|
3. 新 `.env` 必须使用独立容器名、卷名、网络名和端口,例如应用端口可用 `4322`。
|
||||||
|
4. 首装通过后,再做上传、模板、备份、升级和回滚演练。
|
||||||
|
5. 当前 Linux 版已覆盖首装、容器验收、备份和客户业务 smoke;真实上传验收还需要一份允许上传的测试许可证;恢复演练需要在单独演练环境执行,不能覆盖已有 smoke 环境。
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"smoke:customer": "node scripts/customer-acceptance-smoke.mjs",
|
||||||
"check": "npm run type-check && npm run lint && npm run prisma:validate",
|
"check": "npm run type-check && npm run lint && npm run prisma:validate",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
|
|||||||
@ -39,10 +39,18 @@ npm run verify:template-data
|
|||||||
生产构建后的服务端混淆处理。客户交付镜像通过 `Dockerfile` 执行 `npm run build:prod` 时会自动调用;混淆依赖或配置缺失会直接失败,不能静默跳过。
|
生产构建后的服务端混淆处理。客户交付镜像通过 `Dockerfile` 执行 `npm run build:prod` 时会自动调用;混淆依赖或配置缺失会直接失败,不能静默跳过。
|
||||||
- `docker-entrypoint.sh`
|
- `docker-entrypoint.sh`
|
||||||
主应用容器启动入口
|
主应用容器启动入口
|
||||||
|
- `customer-install.sh`
|
||||||
|
NAS / Linux 客户首装向导入口,按步骤调用 `smoke-install.sh` 做预检、启动和验收;不替代生产 Compose,也不依赖 Agent
|
||||||
|
- `smoke-install.sh`
|
||||||
|
NAS / Linux 客户首装后的基础验收脚本,可检查 Compose 配置、容器状态、运行镜像、版本文件、迁移状态、MinIO bucket 和 `/api/health`
|
||||||
|
- `backup-restore.sh`
|
||||||
|
NAS / Linux 客户环境备份、恢复和健康验收脚本;恢复必须显式输入确认短语,避免误覆盖客户数据
|
||||||
|
- `customer-acceptance-smoke.mjs`
|
||||||
|
客户业务验收脚本,可只读检查健康、初始化、登录页和权限拦截,也可登录后验模板、后台接口和真实小素材上传
|
||||||
- `customer-install.ps1`
|
- `customer-install.ps1`
|
||||||
客户首装向导入口,按步骤调用 `smoke-install.ps1` 做预检、启动和验收;不替代生产 Compose,也不依赖 Agent
|
Windows / PowerShell 版客户首装向导入口,逻辑对应 `customer-install.sh`
|
||||||
- `smoke-install.ps1`
|
- `smoke-install.ps1`
|
||||||
客户首装后的基础验收脚本,可检查 Compose 配置、容器状态、运行镜像、版本文件、迁移状态、MinIO bucket 和 `/api/health`
|
Windows / PowerShell 版客户首装后的基础验收脚本,逻辑对应 `smoke-install.sh`
|
||||||
- `backup-restore.ps1`
|
- `backup-restore.ps1`
|
||||||
客户环境备份、恢复和健康验收脚本;恢复必须显式输入确认短语,避免误覆盖客户数据
|
客户环境备份、恢复和健康验收脚本;恢复必须显式输入确认短语,避免误覆盖客户数据
|
||||||
- `measure-image-size.ps1`
|
- `measure-image-size.ps1`
|
||||||
@ -106,7 +114,26 @@ npx tsx scripts/fix-missing-templates.ts
|
|||||||
|
|
||||||
### 客户首装基础验收
|
### 客户首装基础验收
|
||||||
|
|
||||||
更推荐给实施人员的向导入口:
|
NAS / Linux 客户现场优先用 `.sh` 入口;当前账号没有 Docker 权限时,用 `sudo bash ...`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/customer-install.sh --project-dir /opt/flnxi --plan-only
|
||||||
|
sudo bash scripts/customer-install.sh --project-dir /opt/flnxi --prepare-directories --start
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `--plan-only` 只打印四步流程,不启动容器。
|
||||||
|
- `--prepare-directories` 会创建并测试 `uploads/` 和 `data/`;sudo/root 运行时会调整目录为应用容器 UID `1001` 可写。
|
||||||
|
- `--start` 会执行 `docker compose up -d`;如果已经由 NAS 图形界面启动,可以不传。
|
||||||
|
- 这个脚本内部仍调用 `smoke-install.sh`,不引入第二套安装逻辑。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/smoke-install.sh --project-dir /opt/flnxi --preflight-only
|
||||||
|
bash scripts/smoke-install.sh --project-dir /opt/flnxi
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 实施机或已安装 PowerShell 的现场,可以用 `.ps1` 入口:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\customer-install.ps1 -ProjectDir "D:\deploy\flnxi" -PlanOnly
|
.\scripts\customer-install.ps1 -ProjectDir "D:\deploy\flnxi" -PlanOnly
|
||||||
@ -135,6 +162,20 @@ npx tsx scripts/fix-missing-templates.ts
|
|||||||
|
|
||||||
### 客户备份、恢复和验收
|
### 客户备份、恢复和验收
|
||||||
|
|
||||||
|
NAS / Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/backup-restore.sh --project-dir /opt/flnxi --action backup
|
||||||
|
bash scripts/backup-restore.sh --project-dir /opt/flnxi --action verify
|
||||||
|
sudo bash scripts/backup-restore.sh \
|
||||||
|
--project-dir /opt/flnxi \
|
||||||
|
--action restore \
|
||||||
|
--backup-path /opt/flnxi/backups/flnxi-backup-YYYYMMDD-HHMMSS \
|
||||||
|
--confirm-restore RESTORE_FLNXI_DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows / PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\backup-restore.ps1 -ProjectDir "D:\deploy\flnxi" -Action Backup
|
.\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 Verify
|
||||||
@ -148,9 +189,43 @@ npx tsx scripts/fix-missing-templates.ts
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 默认备份 Compose 管理的 PostgreSQL、内置 MinIO 数据卷、`.env`、Compose 文件、版本文件、镜像和容器状态。
|
- 默认备份 Compose 管理的 PostgreSQL、内置 MinIO 数据卷、`.env`、Compose 文件、版本文件、镜像和容器状态。
|
||||||
|
- 备份包会包含客户素材、数据库和 `.env` 密钥,必须限权或加密保存,不能提交到 Git。
|
||||||
- 外部数据库、外部对象存储由客户现场方案单独备份;脚本会写提示文件,不会假装自动覆盖。
|
- 外部数据库、外部对象存储由客户现场方案单独备份;脚本会写提示文件,不会假装自动覆盖。
|
||||||
- 对象存储备份/恢复使用 `busybox:1.36` 作为 helper 镜像;离线客户现场需要提前放入该镜像或通过 `-HelperImage` 指定现场已有镜像。
|
- 对象存储备份/恢复使用 `busybox:1.36` 作为 helper 镜像;离线客户现场需要提前放入该镜像或通过 `-HelperImage` 指定现场已有镜像。
|
||||||
|
|
||||||
|
### 客户业务验收
|
||||||
|
|
||||||
|
只读检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- --base-url http://127.0.0.1:4320
|
||||||
|
```
|
||||||
|
|
||||||
|
登录后检查模板、后台和用户状态接口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- \
|
||||||
|
--base-url http://127.0.0.1:4320 \
|
||||||
|
--email <smoke-admin-email> \
|
||||||
|
--password <smoke-admin-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
真实写入验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:customer -- \
|
||||||
|
--base-url http://127.0.0.1:4320 \
|
||||||
|
--email <smoke-admin-email> \
|
||||||
|
--password <smoke-admin-password> \
|
||||||
|
--write
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `--write` 会创建一条小素材记录,用来验证数据库和 MinIO/S3 确实可写。
|
||||||
|
- 验收账号建议使用临时测试账号;脚本不会主动打印密码,但命令历史和 `.env.smoke` 也要按敏感信息处理。
|
||||||
|
- 没有最高权限的账号可加 `--no-expect-admin`,避免把正常的后台 403 误判成失败。
|
||||||
|
|
||||||
### 镜像体积和分层基线
|
### 镜像体积和分层基线
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
545
scripts/backup-restore.sh
Normal file
545
scripts/backup-restore.sh
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# FLNXI customer backup, restore, and verify helper for NAS/Linux hosts.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="backup"
|
||||||
|
PROJECT_DIR="."
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
ENV_FILE=".env"
|
||||||
|
BACKUP_DIR="backups"
|
||||||
|
BACKUP_PATH=""
|
||||||
|
APP_SERVICE="app"
|
||||||
|
POSTGRES_SERVICE="postgres"
|
||||||
|
MINIO_SERVICE="minio"
|
||||||
|
HELPER_IMAGE="busybox:1.36"
|
||||||
|
SKIP_STOP_APP_FOR_BACKUP=0
|
||||||
|
SKIP_DATABASE_BACKUP=0
|
||||||
|
SKIP_OBJECT_STORAGE_BACKUP=0
|
||||||
|
SKIP_OBJECT_STORAGE_RESTORE=0
|
||||||
|
RESTORE_ENV=0
|
||||||
|
HEALTH_TIMEOUT_SECONDS=120
|
||||||
|
CONFIRM_RESTORE=""
|
||||||
|
RESTORE_CONFIRM_PHRASE="RESTORE_FLNXI_DATA"
|
||||||
|
|
||||||
|
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/backup-restore.sh --action backup|restore|verify [选项]
|
||||||
|
|
||||||
|
常用选项:
|
||||||
|
--project-dir <path> 部署目录,默认当前目录
|
||||||
|
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
|
||||||
|
--env-file <file> 环境文件名,默认 .env
|
||||||
|
--backup-dir <dir> 备份目录名,默认 backups
|
||||||
|
--backup-path <path> 恢复时指定备份目录
|
||||||
|
--helper-image <image> 备份/恢复 MinIO 卷用的小型镜像,默认 busybox:1.36
|
||||||
|
--skip-stop-app-for-backup 备份时不暂停 app 写入
|
||||||
|
--skip-database-backup 跳过数据库备份
|
||||||
|
--skip-object-storage-backup 跳过对象存储备份
|
||||||
|
--skip-object-storage-restore 恢复时跳过对象存储
|
||||||
|
--restore-env 恢复备份中的 .env
|
||||||
|
--confirm-restore RESTORE_FLNXI_DATA 恢复确认短语
|
||||||
|
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
|
||||||
|
-h, --help 显示帮助
|
||||||
|
|
||||||
|
说明:
|
||||||
|
备份包包含 .env、数据库和客户素材,必须限权或加密保存,不能提交到 Git。
|
||||||
|
如果当前账号没有 Docker 权限,请用 sudo 运行本脚本。
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_action() {
|
||||||
|
case "$1" in
|
||||||
|
backup|Backup) printf 'backup' ;;
|
||||||
|
restore|Restore) printf 'restore' ;;
|
||||||
|
verify|Verify) printf 'verify' ;;
|
||||||
|
*) fail "未知动作:$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--action)
|
||||||
|
ACTION="$(normalize_action "${2:?--action requires a value}")"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--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
|
||||||
|
;;
|
||||||
|
--backup-dir)
|
||||||
|
BACKUP_DIR="${2:?--backup-dir requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--backup-path)
|
||||||
|
BACKUP_PATH="${2:?--backup-path 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
|
||||||
|
;;
|
||||||
|
--helper-image)
|
||||||
|
HELPER_IMAGE="${2:?--helper-image requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-stop-app-for-backup)
|
||||||
|
SKIP_STOP_APP_FOR_BACKUP=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-database-backup)
|
||||||
|
SKIP_DATABASE_BACKUP=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-object-storage-backup)
|
||||||
|
SKIP_OBJECT_STORAGE_BACKUP=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-object-storage-restore)
|
||||||
|
SKIP_OBJECT_STORAGE_RESTORE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--restore-env)
|
||||||
|
RESTORE_ENV=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--confirm-restore)
|
||||||
|
CONFIRM_RESTORE="${2:?--confirm-restore requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--health-timeout-seconds)
|
||||||
|
HEALTH_TIMEOUT_SECONDS="${2:?--health-timeout-seconds requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "未知参数:$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
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/['\"]$//"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
printf '%s' "${1:-}" | sed -E 's/\\/\\\\/g;s/"/\\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
is_external_database() {
|
||||||
|
local database_url host
|
||||||
|
database_url="$(read_env_value "DATABASE_URL" "$ENV_PATH")"
|
||||||
|
[[ -z "$database_url" ]] && return 1
|
||||||
|
host="$(printf '%s' "$database_url" | sed -E 's#^[a-zA-Z0-9+.-]+://[^@]*@([^/:?]+).*#\1#')"
|
||||||
|
[[ "$host" != "postgres" && "$host" != "$database_url" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
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" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
run_docker() {
|
||||||
|
docker "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_compose() {
|
||||||
|
run_docker "${COMPOSE_ARGS[@]}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_container_id() {
|
||||||
|
local service="$1"
|
||||||
|
local container_id
|
||||||
|
container_id="$(run_compose ps -q "$service" | tr -d '\r')"
|
||||||
|
[[ -n "$container_id" ]] || fail "未找到服务容器:$service。请先确认服务已启动。"
|
||||||
|
printf '%s' "$container_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
optional_service_container_id() {
|
||||||
|
local service="$1"
|
||||||
|
run_compose ps -q "$service" 2>/dev/null | tr -d '\r' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
service_running() {
|
||||||
|
local service="$1"
|
||||||
|
local container_id status
|
||||||
|
container_id="$(optional_service_container_id "$service")"
|
||||||
|
[[ -n "$container_id" ]] || return 1
|
||||||
|
status="$(run_docker inspect "$container_id" --format '{{.State.Status}}' 2>/dev/null || true)"
|
||||||
|
[[ "$status" == "running" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_app_for_backup() {
|
||||||
|
if [[ "$SKIP_STOP_APP_FOR_BACKUP" -eq 1 ]]; then
|
||||||
|
warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! service_running "$APP_SERVICE"; then
|
||||||
|
info "$APP_SERVICE 服务未运行或不存在,跳过停写入步骤。"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "进入备份维护窗口:暂时停止 $APP_SERVICE 服务,避免备份期间继续写入数据库或素材文件。"
|
||||||
|
run_compose stop "$APP_SERVICE"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_app_after_backup() {
|
||||||
|
info "备份维护窗口结束:恢复启动 $APP_SERVICE 服务。"
|
||||||
|
run_compose up -d "$APP_SERVICE"
|
||||||
|
}
|
||||||
|
|
||||||
|
save_command_output() {
|
||||||
|
local path="$1"
|
||||||
|
shift
|
||||||
|
set +e
|
||||||
|
"$@" >"$path" 2>&1
|
||||||
|
local status=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$status" -ne 0 ]]; then
|
||||||
|
printf '命令执行失败,退出码:%s\n' "$status" >>"$path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_database() {
|
||||||
|
local output_path="$1"
|
||||||
|
info "备份 Compose 内置 PostgreSQL"
|
||||||
|
run_compose up -d "$POSTGRES_SERVICE"
|
||||||
|
local container_id
|
||||||
|
container_id="$(service_container_id "$POSTGRES_SERVICE")"
|
||||||
|
|
||||||
|
run_compose exec -T "$POSTGRES_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'
|
||||||
|
run_docker cp "${container_id}:/tmp/flnxi-backup.sql" "$output_path"
|
||||||
|
[[ -s "$output_path" ]] || fail "数据库备份复制失败:$output_path"
|
||||||
|
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-backup.sql'
|
||||||
|
ok "数据库备份完成:$output_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_database() {
|
||||||
|
local input_path="$1"
|
||||||
|
info "恢复 Compose 内置 PostgreSQL"
|
||||||
|
run_compose up -d "$POSTGRES_SERVICE"
|
||||||
|
local container_id
|
||||||
|
container_id="$(service_container_id "$POSTGRES_SERVICE")"
|
||||||
|
|
||||||
|
run_docker cp "$input_path" "${container_id}:/tmp/flnxi-restore.sql"
|
||||||
|
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql'
|
||||||
|
run_compose exec -T "$POSTGRES_SERVICE" sh -c 'rm -f /tmp/flnxi-restore.sql'
|
||||||
|
ok "数据库恢复完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_object_storage() {
|
||||||
|
local volume_name="$1"
|
||||||
|
local output_path="$2"
|
||||||
|
local backup_dir
|
||||||
|
[[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。"
|
||||||
|
backup_dir="$(dirname "$output_path")"
|
||||||
|
info "备份 Compose 内置 MinIO 数据卷:$volume_name"
|
||||||
|
run_docker volume inspect "$volume_name" >/dev/null
|
||||||
|
run_docker run --rm -v "${volume_name}:/source:ro" -v "${backup_dir}:/backup" "$HELPER_IMAGE" sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ."
|
||||||
|
[[ -s "$output_path" ]] || fail "对象存储备份失败。请确认 helper 镜像可用:$HELPER_IMAGE"
|
||||||
|
ok "对象存储备份完成:$output_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_object_storage() {
|
||||||
|
local volume_name="$1"
|
||||||
|
local input_path="$2"
|
||||||
|
local backup_dir
|
||||||
|
[[ -n "$volume_name" ]] || fail "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。"
|
||||||
|
backup_dir="$(dirname "$input_path")"
|
||||||
|
info "恢复 Compose 内置 MinIO 数据卷:$volume_name"
|
||||||
|
if ! run_docker volume inspect "$volume_name" >/dev/null 2>&1; then
|
||||||
|
run_docker volume create "$volume_name" >/dev/null
|
||||||
|
fi
|
||||||
|
run_docker run --rm -v "${volume_name}:/target" -v "${backup_dir}:/backup:ro" "$HELPER_IMAGE" sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz"
|
||||||
|
ok "对象存储恢复完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_local_directory() {
|
||||||
|
local name="$1"
|
||||||
|
local output_path="$2"
|
||||||
|
[[ -d "$PROJECT_DIR_ABS/$name" ]] || return 1
|
||||||
|
tar -czf "$output_path" -C "$PROJECT_DIR_ABS" "$name"
|
||||||
|
ok "本地目录备份完成:$name -> $output_path"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_local_directory() {
|
||||||
|
local name="$1"
|
||||||
|
local input_path="$2"
|
||||||
|
[[ -f "$input_path" ]] || return 0
|
||||||
|
local target_path before_path
|
||||||
|
target_path="$PROJECT_DIR_ABS/$name"
|
||||||
|
if [[ -d "$target_path" ]]; then
|
||||||
|
before_path="$PROJECT_DIR_ABS/$name.before-restore-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mv "$target_path" "$before_path"
|
||||||
|
ok "$name 恢复前副本:$before_path"
|
||||||
|
fi
|
||||||
|
tar -xzf "$input_path" -C "$PROJECT_DIR_ABS"
|
||||||
|
ok "本地目录恢复完成:$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_health() {
|
||||||
|
local health_url="$1"
|
||||||
|
local timeout="$2"
|
||||||
|
local deadline
|
||||||
|
deadline=$(( $(date +%s) + timeout ))
|
||||||
|
while [[ "$(date +%s)" -le "$deadline" ]]; do
|
||||||
|
if curl -fsS "$health_url" >/dev/null 2>&1; then
|
||||||
|
ok "/api/health 正常"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
fail "/api/health 在 $timeout 秒内没有通过:$health_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_install() {
|
||||||
|
info "检查 Compose 配置"
|
||||||
|
run_compose config --quiet
|
||||||
|
|
||||||
|
info "查看容器状态"
|
||||||
|
run_compose ps
|
||||||
|
|
||||||
|
local public_url
|
||||||
|
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
|
||||||
|
|
||||||
|
if [[ -z "$public_url" ]]; then
|
||||||
|
warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "检查健康接口:${public_url%/}/api/health"
|
||||||
|
wait_health "${public_url%/}/api/health" "$HEALTH_TIMEOUT_SECONDS"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_manifest() {
|
||||||
|
local target_dir="$1"
|
||||||
|
local minio_volume_name="$2"
|
||||||
|
local external_db="$3"
|
||||||
|
local external_object_storage="$4"
|
||||||
|
local data_backed="$5"
|
||||||
|
local uploads_backed="$6"
|
||||||
|
local app_stopped="$7"
|
||||||
|
cat >"$target_dir/manifest.json" <<EOF
|
||||||
|
{
|
||||||
|
"createdAt": "$(date +%Y-%m-%dT%H:%M:%S)",
|
||||||
|
"projectDir": "$(json_escape "$PROJECT_DIR_ABS")",
|
||||||
|
"composeFile": "$(json_escape "$COMPOSE_PATH")",
|
||||||
|
"envFile": "$(json_escape "$ENV_PATH")",
|
||||||
|
"appImage": "$(json_escape "$(read_env_value "APP_IMAGE" "$ENV_PATH")")",
|
||||||
|
"appPublicUrl": "$(json_escape "$(read_env_value "APP_PUBLIC_URL" "$ENV_PATH")")",
|
||||||
|
"postgresService": "$(json_escape "$POSTGRES_SERVICE")",
|
||||||
|
"minioVolumeName": "$(json_escape "$minio_volume_name")",
|
||||||
|
"externalDatabase": $external_db,
|
||||||
|
"externalObjectStorage": $external_object_storage,
|
||||||
|
"localDirectories": {
|
||||||
|
"data": $data_backed,
|
||||||
|
"uploads": $uploads_backed
|
||||||
|
},
|
||||||
|
"appStoppedForConsistentBackup": $app_stopped,
|
||||||
|
"restoreConfirmPhrase": "$RESTORE_CONFIRM_PHRASE"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_action_body() {
|
||||||
|
local timestamp backup_root target_dir minio_volume_name external_db external_object_storage
|
||||||
|
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
backup_root="$PROJECT_DIR_ABS/$BACKUP_DIR"
|
||||||
|
target_dir="$backup_root/flnxi-backup-$timestamp"
|
||||||
|
mkdir -p "$target_dir"
|
||||||
|
|
||||||
|
cp "$COMPOSE_PATH" "$target_dir/docker-compose.prod.yml"
|
||||||
|
[[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$target_dir/.env"
|
||||||
|
[[ -f "$PROJECT_DIR_ABS/VERSION.txt" ]] && cp "$PROJECT_DIR_ABS/VERSION.txt" "$target_dir/VERSION.txt"
|
||||||
|
|
||||||
|
cat >"$target_dir/README-SENSITIVE.txt" <<'EOF'
|
||||||
|
This backup may contain .env secrets, database data, license cache, and customer materials.
|
||||||
|
Store it in a controlled location. Do not commit it to Git.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
save_command_output "$target_dir/compose-config.txt" docker "${COMPOSE_ARGS[@]}" config
|
||||||
|
save_command_output "$target_dir/compose-ps.txt" docker "${COMPOSE_ARGS[@]}" ps
|
||||||
|
save_command_output "$target_dir/docker-images.txt" docker images --digests
|
||||||
|
|
||||||
|
if is_external_database; then external_db=true; else external_db=false; fi
|
||||||
|
if [[ "$SKIP_DATABASE_BACKUP" -eq 1 ]]; then
|
||||||
|
printf '已按参数跳过数据库备份。请确认外部备份已存在。\n' >"$target_dir/DATABASE_BACKUP_SKIPPED.txt"
|
||||||
|
elif [[ "$external_db" == true ]]; then
|
||||||
|
printf '检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。\n' >"$target_dir/EXTERNAL_DATABASE_BACKUP_REQUIRED.txt"
|
||||||
|
else
|
||||||
|
backup_database "$target_dir/postgres.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")"
|
||||||
|
minio_volume_name="${minio_volume_name:-flnxi-minio-data}"
|
||||||
|
if is_external_object_storage; then external_object_storage=true; else external_object_storage=false; fi
|
||||||
|
if [[ "$SKIP_OBJECT_STORAGE_BACKUP" -eq 1 ]]; then
|
||||||
|
printf '已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。\n' >"$target_dir/OBJECT_STORAGE_BACKUP_SKIPPED.txt"
|
||||||
|
elif [[ "$external_object_storage" == true ]]; then
|
||||||
|
printf '检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。\n' >"$target_dir/EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt"
|
||||||
|
else
|
||||||
|
backup_object_storage "$minio_volume_name" "$target_dir/minio-data.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local data_backed uploads_backed
|
||||||
|
data_backed=false
|
||||||
|
uploads_backed=false
|
||||||
|
if backup_local_directory "data" "$target_dir/data.tar.gz"; then data_backed=true; fi
|
||||||
|
if backup_local_directory "uploads" "$target_dir/uploads.tar.gz"; then uploads_backed=true; fi
|
||||||
|
|
||||||
|
write_manifest "$target_dir" "$minio_volume_name" "$external_db" "$external_object_storage" "$data_backed" "$uploads_backed" "$APP_STOPPED_FOR_BACKUP"
|
||||||
|
ok "备份目录:$target_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_action() {
|
||||||
|
APP_STOPPED_FOR_BACKUP=false
|
||||||
|
if stop_app_for_backup; then
|
||||||
|
APP_STOPPED_FOR_BACKUP=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
backup_action_body
|
||||||
|
local status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$APP_STOPPED_FOR_BACKUP" == true ]]; then
|
||||||
|
restart_app_after_backup
|
||||||
|
if [[ "$status" -eq 0 ]]; then
|
||||||
|
verify_install
|
||||||
|
status=$?
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_action() {
|
||||||
|
[[ -n "$BACKUP_PATH" ]] || fail "恢复必须传入 --backup-path。"
|
||||||
|
[[ "$CONFIRM_RESTORE" == "$RESTORE_CONFIRM_PHRASE" ]] || fail "恢复会覆盖客户数据。请显式传入 --confirm-restore $RESTORE_CONFIRM_PHRASE。"
|
||||||
|
|
||||||
|
local source_dir db_backup object_backup env_backup_path minio_volume_name
|
||||||
|
source_dir="$(cd "$BACKUP_PATH" && pwd)"
|
||||||
|
db_backup="$source_dir/postgres.sql"
|
||||||
|
object_backup="$source_dir/minio-data.tar.gz"
|
||||||
|
|
||||||
|
warn "即将恢复备份目录:$source_dir"
|
||||||
|
run_compose stop "$APP_SERVICE" || true
|
||||||
|
|
||||||
|
if [[ "$RESTORE_ENV" -eq 1 && -f "$source_dir/.env" ]]; then
|
||||||
|
env_backup_path="$PROJECT_DIR_ABS/.env.before-restore-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
[[ -f "$ENV_PATH" ]] && cp "$ENV_PATH" "$env_backup_path"
|
||||||
|
cp "$source_dir/.env" "$ENV_PATH"
|
||||||
|
ok ".env 已恢复,恢复前副本:$env_backup_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$db_backup" ]]; then
|
||||||
|
restore_database "$db_backup"
|
||||||
|
else
|
||||||
|
warn "备份目录没有 postgres.sql,跳过数据库恢复。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_external_object_storage; then
|
||||||
|
warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。"
|
||||||
|
elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 0 && -f "$object_backup" ]]; then
|
||||||
|
run_compose stop "$MINIO_SERVICE" || true
|
||||||
|
minio_volume_name="$(read_env_value "MINIO_VOLUME_NAME" "$ENV_PATH")"
|
||||||
|
minio_volume_name="${minio_volume_name:-flnxi-minio-data}"
|
||||||
|
restore_object_storage "$minio_volume_name" "$object_backup"
|
||||||
|
elif [[ "$SKIP_OBJECT_STORAGE_RESTORE" -eq 1 ]]; then
|
||||||
|
warn "已按参数跳过对象存储恢复。"
|
||||||
|
else
|
||||||
|
warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
restore_local_directory "data" "$source_dir/data.tar.gz"
|
||||||
|
restore_local_directory "uploads" "$source_dir/uploads.tar.gz"
|
||||||
|
|
||||||
|
run_compose up -d
|
||||||
|
verify_install
|
||||||
|
ok "恢复和基础验收完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 命令"
|
||||||
|
command -v tar >/dev/null 2>&1 || fail "未找到 tar 命令"
|
||||||
|
|
||||||
|
PROJECT_DIR_ABS="$(cd "$PROJECT_DIR" && pwd)"
|
||||||
|
COMPOSE_PATH="$PROJECT_DIR_ABS/$COMPOSE_FILE"
|
||||||
|
ENV_PATH="$PROJECT_DIR_ABS/$ENV_FILE"
|
||||||
|
[[ -f "$COMPOSE_PATH" ]] || fail "Compose 文件不存在:$COMPOSE_PATH"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR_ABS"
|
||||||
|
COMPOSE_ARGS=(compose -f "$COMPOSE_PATH")
|
||||||
|
if [[ -f "$ENV_PATH" ]]; then
|
||||||
|
COMPOSE_ARGS+=(--env-file "$ENV_PATH")
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
backup)
|
||||||
|
backup_action
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
restore_action
|
||||||
|
;;
|
||||||
|
verify)
|
||||||
|
verify_install
|
||||||
|
;;
|
||||||
|
esac
|
||||||
331
scripts/customer-acceptance-smoke.mjs
Normal file
331
scripts/customer-acceptance-smoke.mjs
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// FLNXI customer acceptance smoke for a deployed app.
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
baseUrl: '',
|
||||||
|
envFile: '.env.smoke',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
write: false,
|
||||||
|
expectAdmin: true,
|
||||||
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index]
|
||||||
|
switch (arg) {
|
||||||
|
case '--base-url':
|
||||||
|
args.baseUrl = argv[++index] || ''
|
||||||
|
break
|
||||||
|
case '--env-file':
|
||||||
|
args.envFile = argv[++index] || ''
|
||||||
|
break
|
||||||
|
case '--email':
|
||||||
|
args.email = argv[++index] || ''
|
||||||
|
break
|
||||||
|
case '--password':
|
||||||
|
args.password = argv[++index] || ''
|
||||||
|
break
|
||||||
|
case '--write':
|
||||||
|
args.write = true
|
||||||
|
break
|
||||||
|
case '--no-expect-admin':
|
||||||
|
args.expectAdmin = false
|
||||||
|
break
|
||||||
|
case '--timeout-ms':
|
||||||
|
args.timeoutMs = Number(argv[++index] || DEFAULT_TIMEOUT_MS)
|
||||||
|
break
|
||||||
|
case '-h':
|
||||||
|
case '--help':
|
||||||
|
printHelp()
|
||||||
|
process.exit(0)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`未知参数:${arg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`FLNXI 客户业务验收 smoke
|
||||||
|
|
||||||
|
用法:
|
||||||
|
node scripts/customer-acceptance-smoke.mjs --base-url http://127.0.0.1:4320 [选项]
|
||||||
|
|
||||||
|
常用选项:
|
||||||
|
--env-file <path> 读取 .env.smoke,默认 .env.smoke
|
||||||
|
--email <email> 验收账号邮箱,也可用 SMOKE_ADMIN_EMAIL
|
||||||
|
--password <password> 验收账号密码,也可用 SMOKE_ADMIN_PASSWORD
|
||||||
|
--write 登录后执行一次真实小素材上传,验证数据库和 MinIO 写入
|
||||||
|
--no-expect-admin 登录账号不是最高权限/管理员时使用
|
||||||
|
--timeout-ms <ms> 单个请求超时,默认 15000
|
||||||
|
|
||||||
|
.env.smoke 支持:
|
||||||
|
SMOKE_BASE_URL=http://127.0.0.1:4320
|
||||||
|
SMOKE_ADMIN_EMAIL=admin@example.com
|
||||||
|
SMOKE_ADMIN_PASSWORD=replace-with-smoke-password
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnvFile(path) {
|
||||||
|
if (!path) return {}
|
||||||
|
try {
|
||||||
|
const content = await readFile(path, 'utf8')
|
||||||
|
const values = {}
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line || line.startsWith('#') || !line.includes('=')) continue
|
||||||
|
const [key, ...rest] = line.split('=')
|
||||||
|
let value = rest.join('=').trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
values[key.trim()] = value
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return {}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl) {
|
||||||
|
const normalized = (baseUrl || '').trim().replace(/\/+$/, '')
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('缺少 --base-url 或 SMOKE_BASE_URL')
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSetCookie(headerValue) {
|
||||||
|
if (!headerValue) return []
|
||||||
|
return headerValue.split(/,(?=\s*[^;,=\s]+=[^;]+)/g).map((value) => value.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
class CookieJar {
|
||||||
|
constructor() {
|
||||||
|
this.cookies = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(response) {
|
||||||
|
const headers = response.headers
|
||||||
|
const setCookies = typeof headers.getSetCookie === 'function'
|
||||||
|
? headers.getSetCookie()
|
||||||
|
: splitSetCookie(headers.get('set-cookie'))
|
||||||
|
|
||||||
|
for (const setCookie of setCookies) {
|
||||||
|
const pair = setCookie.split(';')[0]
|
||||||
|
const separator = pair.indexOf('=')
|
||||||
|
if (separator <= 0) continue
|
||||||
|
this.cookies.set(pair.slice(0, separator), pair.slice(separator + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header() {
|
||||||
|
return Array.from(this.cookies.entries()).map(([key, value]) => `${key}=${value}`).join('; ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(timeoutMs) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
return { controller, timeout }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(baseUrl, path, options, jar, timeoutMs) {
|
||||||
|
const { controller, timeout } = withTimeout(timeoutMs)
|
||||||
|
try {
|
||||||
|
const headers = new Headers(options?.headers || {})
|
||||||
|
if (jar && jar.header()) {
|
||||||
|
headers.set('cookie', jar.header())
|
||||||
|
}
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
redirect: options?.redirect || 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (jar) jar.apply(response)
|
||||||
|
return response
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonSafe(response) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { raw: text.slice(0, 500) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStatus(label, response, expected) {
|
||||||
|
const ok = Array.isArray(expected) ? expected.includes(response.status) : response.status === expected
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`${label} 状态码不符合预期:实际 ${response.status},预期 ${Array.isArray(expected) ? expected.join('/') : expected}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logOk(message) {
|
||||||
|
console.log(`[OK] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPublic(baseUrl, timeoutMs) {
|
||||||
|
const health = await request(baseUrl, '/api/health', { method: 'GET' }, null, timeoutMs)
|
||||||
|
assertStatus('/api/health', health, 200)
|
||||||
|
const healthJson = await readJsonSafe(health)
|
||||||
|
if (healthJson?.status !== 'ok') {
|
||||||
|
throw new Error('/api/health 没有返回 status=ok')
|
||||||
|
}
|
||||||
|
logOk('/api/health 正常')
|
||||||
|
|
||||||
|
const init = await request(baseUrl, '/api/system/init', { method: 'GET' }, null, timeoutMs)
|
||||||
|
assertStatus('/api/system/init', init, 200)
|
||||||
|
await readJsonSafe(init)
|
||||||
|
logOk('/api/system/init 可读取')
|
||||||
|
|
||||||
|
const login = await request(baseUrl, '/login', { method: 'GET' }, null, timeoutMs)
|
||||||
|
assertStatus('/login', login, 200)
|
||||||
|
logOk('/login 可打开')
|
||||||
|
|
||||||
|
const blockedAdmin = await request(baseUrl, '/api/admin/users', { method: 'GET' }, null, timeoutMs)
|
||||||
|
assertStatus('未登录访问 /api/admin/users', blockedAdmin, [401, 403])
|
||||||
|
logOk('未登录访问后台用户接口已被拦截')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(baseUrl, email, password, timeoutMs) {
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error('需要 --email/--password 或 SMOKE_ADMIN_EMAIL/SMOKE_ADMIN_PASSWORD 才能执行登录验收')
|
||||||
|
}
|
||||||
|
|
||||||
|
const jar = new CookieJar()
|
||||||
|
const csrfResponse = await request(baseUrl, '/api/auth/csrf', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('/api/auth/csrf', csrfResponse, 200)
|
||||||
|
const csrf = await readJsonSafe(csrfResponse)
|
||||||
|
if (!csrf?.csrfToken) {
|
||||||
|
throw new Error('未取得 NextAuth CSRF token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
csrfToken: csrf.csrfToken,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: 'false',
|
||||||
|
json: 'true',
|
||||||
|
})
|
||||||
|
|
||||||
|
const callback = await request(
|
||||||
|
baseUrl,
|
||||||
|
'/api/auth/callback/credentials?json=true',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
jar,
|
||||||
|
timeoutMs
|
||||||
|
)
|
||||||
|
assertStatus('账号登录', callback, [200, 302])
|
||||||
|
|
||||||
|
const session = await request(baseUrl, '/api/auth/session', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('/api/auth/session', session, 200)
|
||||||
|
const sessionJson = await readJsonSafe(session)
|
||||||
|
if (!sessionJson?.user?.email) {
|
||||||
|
throw new Error('登录后没有拿到有效 session')
|
||||||
|
}
|
||||||
|
|
||||||
|
logOk('账号登录成功')
|
||||||
|
return jar
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
||||||
|
const templates = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('/api/upload-templates', templates, 200)
|
||||||
|
logOk('普通上传模板接口正常')
|
||||||
|
|
||||||
|
const builtinTemplates = await request(baseUrl, '/api/templates', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('/api/templates', builtinTemplates, 200)
|
||||||
|
logOk('内置模板接口正常')
|
||||||
|
|
||||||
|
const defaultPassword = await request(baseUrl, '/api/user/check-default-password', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('/api/user/check-default-password', defaultPassword, 200)
|
||||||
|
logOk('默认密码检查接口正常')
|
||||||
|
|
||||||
|
const adminUsers = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
if (expectAdmin) {
|
||||||
|
assertStatus('/api/admin/users', adminUsers, 200)
|
||||||
|
logOk('后台用户接口正常')
|
||||||
|
} else {
|
||||||
|
assertStatus('/api/admin/users', adminUsers, [200, 403])
|
||||||
|
logOk('后台用户接口权限表现符合当前账号类型')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUploadSmoke(baseUrl, jar, timeoutMs) {
|
||||||
|
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
|
||||||
|
const png1x1 = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('productModel', `FLNXI客户验收-${stamp}`)
|
||||||
|
form.set('categoryName', '客户验收')
|
||||||
|
form.set('sceneName', '客户验收')
|
||||||
|
form.set('materialType', '3DMAX')
|
||||||
|
form.set('renderer', 'CR')
|
||||||
|
form.set('previewImage', new Blob([png1x1], { type: 'image/png' }), `flnxi-smoke-${stamp}.png`)
|
||||||
|
form.set('zipFile', new Blob([Buffer.from('FLNXI customer smoke file\n')], { type: 'application/zip' }), `flnxi-smoke-${stamp}.zip`)
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
baseUrl,
|
||||||
|
'/api/materials/upload',
|
||||||
|
{ method: 'POST', body: form },
|
||||||
|
jar,
|
||||||
|
timeoutMs
|
||||||
|
)
|
||||||
|
const data = await readJsonSafe(response)
|
||||||
|
if (response.status !== 201) {
|
||||||
|
const reason = data?.error || data?.message || data?.raw || '无错误详情'
|
||||||
|
throw new Error(`真实小素材上传失败:HTTP ${response.status},${reason}`)
|
||||||
|
}
|
||||||
|
const materialId = data?.material?.id || data?.material?.materialId || data?.material?.code || '已创建'
|
||||||
|
logOk(`真实小素材上传成功:${materialId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
const env = await loadEnvFile(args.envFile)
|
||||||
|
const baseUrl = normalizeBaseUrl(args.baseUrl || process.env.SMOKE_BASE_URL || env.SMOKE_BASE_URL || env.APP_PUBLIC_URL || env.NEXTAUTH_URL)
|
||||||
|
const email = args.email || process.env.SMOKE_ADMIN_EMAIL || env.SMOKE_ADMIN_EMAIL
|
||||||
|
const password = args.password || process.env.SMOKE_ADMIN_PASSWORD || env.SMOKE_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
console.log(`[INFO] 验收地址:${baseUrl}`)
|
||||||
|
await checkPublic(baseUrl, args.timeoutMs)
|
||||||
|
|
||||||
|
if (email || password || args.write) {
|
||||||
|
const jar = await login(baseUrl, email, password, args.timeoutMs)
|
||||||
|
await checkAuthenticated(baseUrl, jar, args.expectAdmin, args.timeoutMs)
|
||||||
|
if (args.write) {
|
||||||
|
await writeUploadSmoke(baseUrl, jar, args.timeoutMs)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[INFO] 未提供验收账号,已跳过登录、后台和上传验收。')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[OK] 客户业务验收 smoke 完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[ERROR] ${error?.message || error}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
160
scripts/customer-install.sh
Normal file
160
scripts/customer-install.sh
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# FLNXI customer first-install guide for NAS/Linux hosts.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="."
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
ENV_FILE=".env"
|
||||||
|
START=0
|
||||||
|
PREPARE_DIRECTORIES=0
|
||||||
|
PLAN_ONLY=0
|
||||||
|
SKIP_MIGRATION_STATUS=0
|
||||||
|
SKIP_OBJECT_STORAGE_CHECK=0
|
||||||
|
HEALTH_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
FLNXI NAS/Linux 客户首装向导
|
||||||
|
|
||||||
|
用法:
|
||||||
|
bash scripts/customer-install.sh [选项]
|
||||||
|
|
||||||
|
常用选项:
|
||||||
|
--project-dir <path> 部署目录,默认当前目录
|
||||||
|
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
|
||||||
|
--env-file <file> 环境文件名,默认 .env
|
||||||
|
--start 执行 docker compose up -d
|
||||||
|
--prepare-directories 缺少 uploads/data 时创建并检查权限
|
||||||
|
--plan-only 只打印步骤,不启动容器
|
||||||
|
--skip-migration-status 跳过 Prisma 迁移状态检查
|
||||||
|
--skip-object-storage-check 跳过 MinIO bucket 检查
|
||||||
|
--health-timeout-seconds <sec> 健康检查等待秒数,默认 120
|
||||||
|
-h, --help 显示帮助
|
||||||
|
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
|
||||||
|
;;
|
||||||
|
--start)
|
||||||
|
START=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--prepare-directories)
|
||||||
|
PREPARE_DIRECTORIES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--plan-only)
|
||||||
|
PLAN_ONLY=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
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '[ERROR] 未知参数:%s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
step() {
|
||||||
|
printf '\n[%s/4] %s\n' "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '========================================\n'
|
||||||
|
printf ' FLNXI NAS/Linux 客户首装向导\n'
|
||||||
|
printf '========================================\n'
|
||||||
|
printf '部署目录:%s\n' "$PROJECT_DIR"
|
||||||
|
printf 'Compose:%s\n' "$COMPOSE_FILE"
|
||||||
|
printf '环境文件:%s\n' "$ENV_FILE"
|
||||||
|
|
||||||
|
step 1 "检查部署文件和客户现场配置"
|
||||||
|
printf '会检查 .env、APP_IMAGE、许可证参数、数据目录和常见占位值。\n'
|
||||||
|
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
|
||||||
|
printf '缺少 uploads/data 目录时会自动创建,并在 root/sudo 运行时调整为 UID 1001 可写。\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PLAN_ONLY" -eq 1 ]]; then
|
||||||
|
step 2 "准备启动方式"
|
||||||
|
printf '如果希望脚本启动容器,请重新执行并加 --start。\n'
|
||||||
|
printf '如果已经通过 NAS 图形界面启动,则不加 --start,脚本只做验收。\n'
|
||||||
|
step 3 "运行首装验收"
|
||||||
|
printf '验收内容包括 Compose 配置、容器状态、运行镜像、VERSION.txt、/api/health、Prisma 迁移状态和 MinIO bucket。\n'
|
||||||
|
step 4 "查看结果"
|
||||||
|
printf '所有步骤通过后,浏览器打开 APP_PUBLIC_URL / NEXTAUTH_URL。\n'
|
||||||
|
printf '如失败,根据脚本输出先修 .env、镜像、目录权限或容器日志。\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SMOKE_SCRIPT="$SCRIPT_DIR/smoke-install.sh"
|
||||||
|
[[ -f "$SMOKE_SCRIPT" ]] || {
|
||||||
|
printf '[ERROR] 找不到 smoke-install.sh:%s\n' "$SMOKE_SCRIPT" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
COMMON_ARGS=(
|
||||||
|
--project-dir "$PROJECT_DIR"
|
||||||
|
--compose-file "$COMPOSE_FILE"
|
||||||
|
--env-file "$ENV_FILE"
|
||||||
|
--health-timeout-seconds "$HEALTH_TIMEOUT_SECONDS"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$SKIP_MIGRATION_STATUS" -eq 1 ]]; then
|
||||||
|
COMMON_ARGS+=(--skip-migration-status)
|
||||||
|
fi
|
||||||
|
if [[ "$SKIP_OBJECT_STORAGE_CHECK" -eq 1 ]]; then
|
||||||
|
COMMON_ARGS+=(--skip-object-storage-check)
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREFLIGHT_ARGS=(--preflight-only)
|
||||||
|
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
|
||||||
|
PREFLIGHT_ARGS+=(--prepare-directories)
|
||||||
|
fi
|
||||||
|
bash "$SMOKE_SCRIPT" "${COMMON_ARGS[@]}" "${PREFLIGHT_ARGS[@]}"
|
||||||
|
|
||||||
|
step 2 "确认容器启动方式"
|
||||||
|
if [[ "$START" -eq 1 ]]; then
|
||||||
|
printf '将由脚本执行 docker compose up -d。\n'
|
||||||
|
else
|
||||||
|
printf '未传 --start,默认认为容器已由 NAS 图形界面或其它编排入口启动。\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
step 3 "执行首装启动和验收"
|
||||||
|
VERIFY_ARGS=()
|
||||||
|
if [[ "$START" -eq 1 ]]; then
|
||||||
|
VERIFY_ARGS+=(--start)
|
||||||
|
fi
|
||||||
|
if [[ "$PREPARE_DIRECTORIES" -eq 1 ]]; then
|
||||||
|
VERIFY_ARGS+=(--prepare-directories)
|
||||||
|
fi
|
||||||
|
bash "$SMOKE_SCRIPT" "${COMMON_ARGS[@]}" "${VERIFY_ARGS[@]}"
|
||||||
|
|
||||||
|
step 4 "完成"
|
||||||
|
printf '首装向导完成。下一步请用浏览器打开 APP_PUBLIC_URL / NEXTAUTH_URL,并完成管理员初始化、登录、上传和下载验收。\n'
|
||||||
409
scripts/smoke-install.sh
Normal file
409
scripts/smoke-install.sh
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
#!/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"
|
||||||
|
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 <path> 部署目录,默认当前目录
|
||||||
|
--compose-file <file> Compose 文件名,默认 docker-compose.prod.yml
|
||||||
|
--env-file <file> 环境文件名,默认 .env
|
||||||
|
--start 执行 docker compose up -d
|
||||||
|
--preflight-only 只做安装前检查,不检查容器
|
||||||
|
--prepare-directories 缺少 uploads/data 时创建并检查权限
|
||||||
|
--skip-migration-status 跳过 Prisma 迁移状态检查
|
||||||
|
--skip-object-storage-check 跳过 MinIO bucket 检查
|
||||||
|
--health-timeout-seconds <sec> 健康检查等待秒数,默认 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
|
||||||
|
;;
|
||||||
|
--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##*/}"
|
||||||
|
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 deadline
|
||||||
|
deadline=$(( $(date +%s) + timeout ))
|
||||||
|
while [[ "$(date +%s)" -le "$deadline" ]]; do
|
||||||
|
if curl -fsS "$health_url" >/dev/null 2>&1; then
|
||||||
|
ok "/api/health 正常"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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 "首装基础验收完成"
|
||||||
Loading…
x
Reference in New Issue
Block a user