From 67e279a1d546670feb8661f957f19f1f6bab06a5 Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 30 Jul 2026 11:52:54 +0800 Subject: [PATCH] rebuild(deploy): add Portainer preflight stack --- next/.env.example | 1 + next/compose.preflight.yaml | 49 ++++++++++++++ next/compose.yaml | 7 +- next/docs/deployment.md | 4 ++ next/docs/final/cutover-checklist.md | 2 + next/docs/portainer-preflight.md | 96 ++++++++++++++++++++++++++++ next/tests/test_deployment_assets.py | 28 ++++++++ 7 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 next/compose.preflight.yaml create mode 100644 next/docs/portainer-preflight.md create mode 100644 next/tests/test_deployment_assets.py diff --git a/next/.env.example b/next/.env.example index 449808e..edf9edc 100644 --- a/next/.env.example +++ b/next/.env.example @@ -1,5 +1,6 @@ APP_ENCRYPTION_KEY=replace-with-a-persistent-fernet-key APP_IMAGE=xiaobai-review-next:preflight APP_PUBLISH_PORT=8876 +APP_STORAGE_PATH=./data APP_LOG_LEVEL=INFO APP_TIMEZONE=Asia/Shanghai diff --git a/next/compose.preflight.yaml b/next/compose.preflight.yaml new file mode 100644 index 0000000..1fd8f80 --- /dev/null +++ b/next/compose.preflight.yaml @@ -0,0 +1,49 @@ +x-runtime: &runtime + build: + context: . + image: ${APP_IMAGE:-xiaobai-review-next:preflight} + init: true + environment: + APP_ENV: production + APP_HOST: 0.0.0.0 + APP_PORT: 8765 + APP_DATA_DIR: /app/data + APP_DATABASE_PATH: /app/data/xiaobai.db + APP_PRIVATE_MENTOR_SKILLS_DIR: /app/data/private-mentor-skills + APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set} + APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO} + APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai} + volumes: + - ${APP_STORAGE_PATH:?APP_STORAGE_PATH must be a dedicated preflight directory}:/app/data + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +services: + migration: + <<: *runtime + restart: "no" + command: + - python + - -m + - tools.legacy_migration + - --source + - /app/data/legacy-review.db + - --target + - /app/data/xiaobai.db + - --report + - /app/data/migration-report.json + + web: + <<: *runtime + restart: unless-stopped + ports: + - "${APP_PUBLISH_PORT:-8876}:8765" + depends_on: + migration: + condition: service_completed_successfully + stop_grace_period: 30s diff --git a/next/compose.yaml b/next/compose.yaml index faf58d4..d23bc49 100644 --- a/next/compose.yaml +++ b/next/compose.yaml @@ -7,8 +7,6 @@ services: restart: unless-stopped ports: - "${APP_PUBLISH_PORT:-8876}:8765" - env_file: - - .env environment: APP_ENV: production APP_HOST: 0.0.0.0 @@ -16,8 +14,11 @@ services: APP_DATA_DIR: /app/data APP_DATABASE_PATH: /app/data/xiaobai.db APP_PRIVATE_MENTOR_SKILLS_DIR: /app/data/private-mentor-skills + APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set} + APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO} + APP_TIMEZONE: ${APP_TIMEZONE:-Asia/Shanghai} volumes: - - ./data:/app/data + - ${APP_STORAGE_PATH:-./data}:/app/data read_only: true tmpfs: - /tmp:size=64m,mode=1777 diff --git a/next/docs/deployment.md b/next/docs/deployment.md index 005ba9a..ffa452a 100644 --- a/next/docs/deployment.md +++ b/next/docs/deployment.md @@ -22,6 +22,10 @@ docker compose up -d ``` 启动后先检查`http://NAS地址:8876/api/health`,再访问`http://NAS地址:8876/`完成真实账号抽验。 + +不使用 SSH、通过 Portainer 从 Gitea 部署时,使用专用的 +[Portainer 网页端预检](portainer-preflight.md)。该流程会在独立目录迁移旧库后才启动 +`8876` Web 容器,不需要把 `.env` 或数据库提交到仓库。 宿主`data`目录必须允许镜像中的非root用户写入;若启动日志出现权限错误,先用 `docker compose run --rm web id`确认容器UID/GID,再只修正该绑定目录的所有权,不得改为root运行容器。 diff --git a/next/docs/final/cutover-checklist.md b/next/docs/final/cutover-checklist.md index ad4c3ea..94f0db0 100644 --- a/next/docs/final/cutover-checklist.md +++ b/next/docs/final/cutover-checklist.md @@ -12,6 +12,8 @@ - [ ] 记录实际`APP_IMAGE`标签,确认该标签可在不重新构建的情况下用于回退演练。 - [ ] 用户明确确认最终切换窗口。 +不使用 SSH 的 NAS 按 [Portainer 网页端预检](../portainer-preflight.md)完成以上 Docker 阻断项。 + ## 切换步骤 1. 记录旧镜像、容器配置、端口和旧数据库校验和,创建最终一致性备份。 diff --git a/next/docs/portainer-preflight.md b/next/docs/portainer-preflight.md new file mode 100644 index 0000000..792cbe6 --- /dev/null +++ b/next/docs/portainer-preflight.md @@ -0,0 +1,96 @@ +# Portainer 网页端预检 + +本流程适用于不使用 SSH、通过 Portainer 管理 `192.168.200.11` Docker 的环境。 +它只在 `8876` 启动重建版,不停止、不修改现有 `8765` 容器,也不授权正式切换。 + +## 1. 准备独立数据目录 + +通过 NAS 文件管理器创建只供预检使用的目录,例如: + +```text +/volume1/docker/xiaobai-next-preflight +``` + +实际路径以 NAS 的 Docker 共享目录为准。不得指向旧容器当前使用的数据目录。 + +先停止旧系统写入或创建 SQLite 一致性备份,再把备份复制到该目录并固定命名为: + +```text +legacy-review.db +``` + +同时把管理员私有 Skill 放入该目录的 `private-mentor-skills/`。不要把 `.env`、数据库、 +访问令牌或私有 Skill 提交到 Gitea。`APP_ENCRYPTION_KEY`必须使用旧系统现有值,不得重新生成。 + +## 2. 创建 Git Stack + +在 Portainer 中依次进入 `Stacks`、`Add stack`、`Git Repository`,填写: + +| 项目 | 值 | +|---|---| +| Name | `xiaobai-next-preflight` | +| Repository URL | `http://192.168.200.36:3200/leefer/xiaobaifupan.git` | +| Repository reference | `refs/heads/main` | +| Compose path | `next/compose.preflight.yaml` | + +私有 Gitea 仓库使用 Portainer 的仓库认证字段填写用户名和访问令牌,不把凭据拼进 URL。 +关闭自动更新;预检阶段只部署已经记录 Git 提交号的版本。 + +在 Stack 环境变量中填写: + +| 变量 | 值 | +|---|---| +| `APP_ENCRYPTION_KEY` | 与旧数据库配套的原始 Fernet 密钥 | +| `APP_STORAGE_PATH` | 第 1 步创建的 NAS 绝对路径 | +| `APP_IMAGE` | `xiaobai-review-next:preflight-` | +| `APP_PUBLISH_PORT` | `8876` | +| `APP_LOG_LEVEL` | `INFO` | +| `APP_TIMEZONE` | `Asia/Shanghai` | + +`APP_STORAGE_PATH`是强制项,预检编排不会默认为正式数据目录。Portainer 展开 Compose 时若 +缺少密钥或目录会直接拒绝部署,不会启动一个使用临时密钥或错误数据卷的容器。 + +## 3. 部署与迁移判定 + +点击 `Deploy the stack`。该编排先运行一次性 `migration` 服务,只有迁移退出码为 0, +`web` 服务才会启动。迁移器只读打开 `legacy-review.db`,写入独立的 `xiaobai.db`, +重复部署使用同一目录时保持幂等。 + +Portainer 中应看到: + +- `migration`容器状态为正常退出,退出码 0; +- `web`容器持续运行并最终显示 healthy; +- 数据目录出现 `xiaobai.db`和`migration-report.json`; +- 旧 `8765`容器仍在运行,配置和数据卷没有变化。 + +迁移失败时不要手工修改目标库。保留 `migration`日志,删除预检目录中的失败目标副本后, +从最新一致旧库副本重新部署。 + +## 4. 网页验收 + +访问: + +```text +http://192.168.200.11:8876/ +http://192.168.200.11:8876/api/health +``` + +使用真实管理员账号抽验登录、管理员与会员双标识、行情日期、自选、复盘、模型配置、 +问师记录、问天历史、选股历史和策略跟踪。再从 Portainer 重启 `web` 容器,确认数据仍在。 + +在 Portainer 的容器详情中确认: + +- 镜像标签与记录的 Git 提交一致; +- 容器用户不是 root; +- Root filesystem 为只读; +- Capabilities 已全部移除; +- `/app/data`指向独立预检目录; +- 端口只有宿主 `8876`映射到容器 `8765`。 + +完成日间、夜间、1080P、4K 和 390px 抽验后,将结果登记到 +[`final/cutover-checklist.md`](final/cutover-checklist.md)。正式端口和流量仍保持不变。 + +## 5. 撤销预检 + +Portainer 删除 `xiaobai-next-preflight` Stack 即可停止预检容器。不要勾选删除旧正式容器、 +旧数据卷或共享目录。独立预检目录保留到正式切换完成;它既可作为问题取证,也能避免重新迁移。 diff --git a/next/tests/test_deployment_assets.py b/next/tests/test_deployment_assets.py new file mode 100644 index 0000000..fafb23c --- /dev/null +++ b/next/tests/test_deployment_assets.py @@ -0,0 +1,28 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_formal_compose_accepts_portainer_environment_without_env_file() -> None: + compose = ROOT.joinpath("compose.yaml").read_text(encoding="utf-8") + + assert "env_file:" not in compose + assert "APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:?" in compose + assert "${APP_STORAGE_PATH:-./data}:/app/data" in compose + assert '"${APP_PUBLISH_PORT:-8876}:8765"' in compose + assert "read_only: true" in compose + assert "no-new-privileges:true" in compose + assert "cap_drop:\n - ALL" in compose + + +def test_preflight_migrates_before_web_and_cannot_default_to_formal_storage() -> None: + compose = ROOT.joinpath("compose.preflight.yaml").read_text(encoding="utf-8") + + storage_guard = "${APP_STORAGE_PATH:?APP_STORAGE_PATH must be a dedicated preflight directory}" + assert storage_guard in compose + assert "/app/data/legacy-review.db" in compose + assert "/app/data/xiaobai.db" in compose + assert "/app/data/migration-report.json" in compose + assert "condition: service_completed_successfully" in compose + assert '"${APP_PUBLISH_PORT:-8876}:8765"' in compose + assert "8765:8765" not in compose