diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 0d665db..0000000 --- a/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -.git -.gitnexus -.claude -.local -node_modules -web/node_modules -server/node_modules -web/dist -server/dist -deploy/env/**/*.local -*.log -playwright-report -test-results diff --git a/CHANGELOG.md b/CHANGELOG.md index b04e000..f262cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目采用语义化版本。功能级变化请从 `docs/features/` 按功能编号追溯。 +## Unreleased + +- 项目生产交付统一为 Ubuntu 源码部署,移除 Dockerfile、Compose、镜像校验和容器测试入口。 +- 本地 Redis、MySQL 5.7.42 与 RocketMQ 5.x 均作为外部服务,项目不再提供自动启动或容器兜底。 +- 新增源码发布门禁,校验 Web/Server 构建产物与 systemd、Nginx 配置一致性。 + ## 0.0.7 - 2026-08-13 - 将项目唯一消息队列技术基线由 RabbitMQ 调整为 RocketMQ 5.x。 diff --git a/README.md b/README.md index ae32321..417068f 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,17 @@ A read-only A-share market overview and analysis tool. - `web/`: Vue 3 market terminal frontend. - `server/`: NestJS API、Scheduler 与 Worker 三个独立进程。 -- `deploy/`: MySQL 5.7、Redis、RocketMQ 5.x 的配置和双部署入口。 +- `deploy/`: MySQL 5.7、Redis、RocketMQ 5.x 的配置契约,以及 systemd/Nginx 源码部署模板。 - `docs/`: Product and implementation specifications. The first implementation slice is the market overview screen in `web/`. ## 环境与部署 -- [本地开发环境](docs/deployment/local-development.md):Compose 只启动 Redis,应用从源码运行。 -- [Docker Compose 生产部署](docs/deployment/docker-compose-production.md):Web/Server 不可变镜像与外部配置。 -- [Ubuntu 源码生产部署](docs/deployment/ubuntu-source-production.md):支持 Ubuntu 22.04/24.04 LTS x86_64。 +- [本地开发环境](docs/deployment/local-development.md):使用开发机已有 Redis,应用全部从源码运行。 +- [Ubuntu 源码生产部署](docs/deployment/ubuntu-source-production.md):项目唯一生产部署方式,支持 Ubuntu 22.04/24.04 LTS x86_64。 -无秘密环境模板位于 `deploy/env/`。生产配置不进入 Git 或发布目录;Node.js 22 LTS、pnpm 10、Nginx 和基础设施由服务器管理员提供。 +无秘密环境模板位于 `deploy/env/`。生产配置不进入 Git 或发布目录;Node.js 22.x、pnpm 10.25.0、Nginx 和外部基础设施由服务器管理员提供。项目不安装、启动、停止或删除 Redis、MySQL 和 RocketMQ。 ## 开发入口 diff --git a/deploy/compose/development.yml b/deploy/compose/development.yml deleted file mode 100644 index 32fac6c..0000000 --- a/deploy/compose/development.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: a-share-analysis-development - -services: - redis: - image: redis:7.2.5-alpine - container_name: a-share-analysis-dev-redis - command: ['redis-server', '--appendonly', 'yes'] - ports: - - '127.0.0.1:6379:6379' - volumes: - - ../../.local/redis:/data - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - networks: - - a-share-analysis-dev - -networks: - a-share-analysis-dev: - name: a-share-analysis-dev diff --git a/deploy/compose/production.yml.example b/deploy/compose/production.yml.example deleted file mode 100644 index 9d6cc8a..0000000 --- a/deploy/compose/production.yml.example +++ /dev/null @@ -1,53 +0,0 @@ -# Web/Server 镜像与三进程入口均已纳入发布验证;外部基础设施继续由运维管理。 -name: a-share-analysis-production - -services: - web: - image: ${A_SHARE_WEB_IMAGE:?必须提供不可变 Web 镜像引用} - ports: - - '127.0.0.1:8080:80' - depends_on: - api: - condition: service_healthy - healthcheck: - test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/'] - interval: 30s - timeout: 3s - retries: 3 - start_period: 5s - restart: unless-stopped - - api: - image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用} - command: ['node', 'dist/apps/api/main.js'] - env_file: ${A_SHARE_API_ENV_FILE:-/etc/a-share-analysis/api.env} - environment: - # 容器网络需要对 Web 服务可达;源码部署模板仍只监听回环地址。 - API_HOST: 0.0.0.0 - ports: - - '127.0.0.1:3000:3000' - healthcheck: - test: - [ - 'CMD', - 'node', - '-e', - "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", - ] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - restart: unless-stopped - - scheduler: - image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用} - command: ['node', 'dist/apps/scheduler/main.js'] - env_file: ${A_SHARE_SCHEDULER_ENV_FILE:-/etc/a-share-analysis/scheduler.env} - restart: unless-stopped - - worker: - image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用} - command: ['node', 'dist/apps/worker/main.js'] - env_file: ${A_SHARE_WORKER_ENV_FILE:-/etc/a-share-analysis/worker.env} - restart: unless-stopped diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 69a731d..bba45c2 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -54,7 +54,7 @@ tools/ 可重复执行的治理和开发工具 - 应用运行环境只定义 `development` 与 `production`;生产环境禁止模拟数据、热更新、调试端口和开发默认值回退。 - API、Scheduler、Worker、Web 分别拥有开发和生产模板;同一进程跨环境使用相同变量名,通过启动 Schema 校验条件必填项。 -- 本地开发使用 Docker Compose 只启动 Redis,应用进程从源码运行;第一阶段 MySQL 与 RocketMQ 保持关闭。需要联调时可以连接本机独立运行的 RocketMQ 5.x。 -- 生产同时支持 Docker Compose 和 Ubuntu 22.04/24.04 LTS x86_64 源码部署,两者共用配置契约和进程边界。 -- 源码部署由管理员预装 Node.js 22 LTS、pnpm 10、Nginx 和基础设施;项目只验证,不安装或修改系统软件源。 +- 本地开发直接连接开发机已开机自启的 Redis `127.0.0.1:6379`,应用进程从源码运行;第一阶段 MySQL 与 RocketMQ 保持关闭,需要联调时连接开发者独立维护的外部服务。 +- 生产只支持 Ubuntu 22.04/24.04 LTS x86_64 源码部署,不提供容器运行、构建、测试或部署入口。 +- 源码部署由管理员预装 Node.js 22.x、pnpm 10.25.0、Nginx 和外部基础设施;项目只验证,不安装、启动、停止或删除系统服务。 - 源码部署使用不可变发布目录、`a-share` 用户和 systemd 独立进程配置;生产 env 存放在 `/etc/a-share-analysis/`,不随代码版本切换覆盖。 diff --git a/docs/deployment/docker-compose-production.md b/docs/deployment/docker-compose-production.md deleted file mode 100644 index e9caebe..0000000 --- a/docs/deployment/docker-compose-production.md +++ /dev/null @@ -1,33 +0,0 @@ -# Docker Compose 生产部署 - -生产 Compose 部署使用两个不可变镜像:Web 静态 Nginx 镜像,以及同时包含 API、Scheduler、Worker 三个入口的 Server 镜像。MySQL 5.7.42、Redis 与 RocketMQ 5.x 由运维独立管理,不进入应用 Compose 生命周期;其他 MySQL 5.7 补丁版本也不得替代发布验收。 - -## 构建与配置 - -构建期只能给 Web 注入公开变量,生产秘密不得使用 `ARG`、写入镜像或提交仓库: - -```bash -docker build -f web/Dockerfile -t registry.example/a-share-web:<提交SHA> . -docker build -f server/Dockerfile -t registry.example/a-share-server:<提交SHA> . -``` - -把四份生产模板复制到服务器的 `/etc/a-share-analysis/`,由运维填写并设置最小权限。Web 配置在镜像构建期固化,因此 `web.env` 只作为发布参数记录,不挂载到容器;API、Scheduler、Worker 分别读取自己的外部 env 文件。所有生产镜像引用必须使用内容摘要,禁止 `latest` 和任何普通 tag。 - -## 发布与验证 - -```bash -export A_SHARE_WEB_IMAGE='registry.example/a-share-web@sha256:<摘要>' -export A_SHARE_SERVER_IMAGE='registry.example/a-share-server@sha256:<摘要>' -pnpm check:images -docker compose -f deploy/compose/production.yml.example config -docker compose -f deploy/compose/production.yml.example up -d -docker compose -f deploy/compose/production.yml.example ps -curl --fail http://127.0.0.1:3000/api/v1/health -curl --fail http://127.0.0.1:8080/ -``` - -当前工程尚无业务表迁移;出现 TypeORM migration 后,发布必须在启动应用前用同一 Server 镜像执行迁移,且始终禁止 `synchronize: true`。RocketMQ Node 客户端在后续功能验证前保持关闭。 - -## 回退 - -保留上一组 Web/Server 内容摘要。失败时把两个镜像变量恢复到上一组,重新执行 `config`、`up -d` 和健康检查。外部 env、Redis、MySQL 数据与 RocketMQ CommitLog 不随应用镜像回退;禁止用删除数据目录的方式回退应用。 diff --git a/docs/deployment/local-development.md b/docs/deployment/local-development.md index 0dea1b5..a0e878e 100644 --- a/docs/deployment/local-development.md +++ b/docs/deployment/local-development.md @@ -1,20 +1,22 @@ # 本地开发环境 -本地开发由开发机提供 Node.js 22.x 与 pnpm 10.25.0,项目不安装或修改 Node。Docker Compose 只管理 Redis;Web、API、Scheduler 和 Worker分别从源码启动,MySQL 5.7.42 与 RocketMQ 5.x 使用外部开发实例且默认关闭。 +本地开发由开发机提供 Node.js 22.x、pnpm 10.25.0 和已开机自启的 Redis `127.0.0.1:6379`。Web、API、Scheduler 与 Worker 全部从源码启动;MySQL 5.7.42 和 RocketMQ 5.x 也是外部服务,项目脚本不负责安装、启动、停止或删除任何基础设施。 ## 首次准备 ```bash node --version pnpm --version +redis-cli -h 127.0.0.1 -p 6379 ping pnpm install --frozen-lockfile -docker compose -f deploy/compose/development.yml up -d pnpm check:runtime ``` +Redis 必须返回 `PONG`。如果不可达,应先修复开发机外部服务;测试和应用不会创建替代实例。 + 把 `deploy/env/development/*.env.example` 复制为同目录的 `*.env.local`。四份 local 文件被 Git 忽略:Web 只能含 `VITE_` 公开变量;API、Scheduler、Worker 按最小权限分别读取自己的配置,不共享一份全能 env。 -固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,服务端补丁版本固定为 MySQL 5.7.42,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。 +固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,服务端固定为 MySQL 5.7.42,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。 ## 启动顺序 @@ -34,8 +36,7 @@ API 健康端点是 `http://127.0.0.1:3000/api/v1/health`,Web 是 `http://127. ```bash pnpm check:contracts pnpm verify -pnpm verify:e2e -docker compose -f deploy/compose/development.yml down +pnpm verify:release ``` -Redis 只绑定 `127.0.0.1:6379`,数据写入仓库忽略的 `.local/redis/`。RocketMQ 由开发者独立维护 NameServer、Broker 与需要时的 Proxy;TCP 端口可达不等于生产消费链路通过,完整联调仍需验证 Topic 路由和实际生产/消费。 +应用进程通过各自终端或信号正常停止,外部 Redis/MySQL/RocketMQ 生命周期不由项目管理。RocketMQ 的 TCP 端口可达不等于生产消费链路通过,完整联调仍需验证 Topic 路由和实际生产/消费。 diff --git a/docs/deployment/ubuntu-source-production.md b/docs/deployment/ubuntu-source-production.md index 0cd2e65..de7377a 100644 --- a/docs/deployment/ubuntu-source-production.md +++ b/docs/deployment/ubuntu-source-production.md @@ -1,19 +1,22 @@ # Ubuntu 源码生产部署 -正式支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22 LTS、pnpm 10、Nginx,以及 MySQL 5.7.42、Redis、RocketMQ 5.x 或相应外部服务;项目不负责安装、升级或修改系统软件源,其他 MySQL 补丁版本不得代替生产验收。 +这是项目唯一生产部署方式,支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22.x、pnpm 10.25.0、Nginx,以及外部 MySQL 5.7.42、Redis、RocketMQ 5.x;项目不负责安装、升级、启动、停止、删除或修改这些系统软件与服务。 ## 构建发布目录 +在固定提交的干净工作区执行: + ```bash uname -m node --version pnpm --version pnpm install --frozen-lockfile -pnpm check:contracts -pnpm verify +pnpm verify:release ``` -架构必须为 `x86_64`,Node.js 必须为 22.x,pnpm 必须为 10.x。每次发布使用 `/opt/a-share-analysis/releases/<版本或提交>/` 新目录,验证后再原子切换 `/opt/a-share-analysis/current`,禁止在 current 中原地覆盖。 +架构必须为 `x86_64`,Node.js 必须为 22.x,pnpm 必须为 10.25.0。发布门禁会完成测试、Web/Server 构建、双视口浏览器检查,并核对 Web、API、Scheduler、Worker 产物与 systemd/Nginx 模板。 + +每次发布使用 `/opt/a-share-analysis/releases/<版本或提交>/` 新目录。完整传输并验证目录后,再把 `/opt/a-share-analysis/current` 原子切换到目标目录;禁止在 `current` 中原地覆盖文件,也禁止只发布部分进程产物。 ## 配置与 systemd @@ -31,10 +34,13 @@ sudo systemctl restart a-share-api.service.example sudo systemctl restart a-share-scheduler.service.example sudo systemctl restart a-share-worker.service.example curl --fail http://127.0.0.1:3000/api/v1/health +curl --fail http://127.0.0.1/ ``` -当前工程尚无业务表迁移;后续新增迁移后必须在切换 current 前执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。 +当前工程尚无业务表迁移;后续新增迁移后必须在切换 `current` 前单独执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。 ## 检查与回退 -使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 访问结果确认发布。失败时把 `current` 原子切回上一完整发布目录,重启三个服务并再次验证。生产配置和数据不随代码目录覆盖;RocketMQ 的 NameServer、Broker、Proxy 与 CommitLog 由运维独立维护。 +使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 同源访问结果确认发布。任一验证失败时,把 `/opt/a-share-analysis/current` 原子切回上一完整发布目录,重启三个 systemd 服务并重复健康检查。 + +`/etc/a-share-analysis/*.env` 和外部 Redis、MySQL、RocketMQ 数据不随应用目录切换而覆盖或删除。RocketMQ 的 NameServer、Broker、Proxy 与 CommitLog 由运维独立维护。 diff --git a/docs/features/GOV-006-environment-deployment-baseline/manifest.yaml b/docs/features/GOV-006-environment-deployment-baseline/manifest.yaml index 0021628..9c0b66d 100644 --- a/docs/features/GOV-006-environment-deployment-baseline/manifest.yaml +++ b/docs/features/GOV-006-environment-deployment-baseline/manifest.yaml @@ -15,3 +15,4 @@ code_review: review/code-review.md standards_review: review/standards-review.md implementation_commit: 9203fdd release: v0.0.5 +superseded_by: GOV-009/source-only-deployment-design.md diff --git a/docs/features/GOV-009-application-scaffold-baseline/architecture-review.md b/docs/features/GOV-009-application-scaffold-baseline/architecture-review.md index f8b027a..ee3f7fc 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/architecture-review.md +++ b/docs/features/GOV-009-application-scaffold-baseline/architecture-review.md @@ -22,7 +22,7 @@ repository │ ├── http-contracts/ Server DTO │ ├── message-contracts/ Server 内部 RocketMQ 信封 │ └── infrastructure/ Redis、MySQL、消息与生命周期适配 -├── deploy/ Compose、systemd、Nginx、env 模板 +├── deploy/ systemd、Nginx、env 源码部署模板 └── tools/ 治理、Schema、契约、边界和部署验证 ``` @@ -33,7 +33,7 @@ repository 3. **一个 Server 工程、三个进程入口。** API、Scheduler、Worker 使用相同基础设施与消息契约,但启动、配置和部署彼此独立。当前规模下没有必要拆成三个仓库或三个重复 Nest 工程;未来可独立扩容进程而不复制公共代码。 4. **配置是进程级最小权限。** API 不读取任务消费配置,Scheduler 不读取 MySQL/AI 密钥,Worker 不读取 API 端口。配置先校验和冻结,再通过动态模块注入,测试与生产使用同一装配路径。 5. **基础设施通过 Adapter 隔离。** 业务代码将依赖 MessageBus、Repository 和数据源端口;RocketMQ 客户端尚未验证时明确禁用,避免以临时内存队列形成错误语义。 -6. **部署方式共享同一产物。** Ubuntu 源码部署使用 Web dist 与三个 Server dist 入口;现有 Docker Compose 只作为可选交付适配层复用这些产物,不形成第二套应用逻辑。按用户 2026-08-13 的决定,日常开发不再构建 Docker,待核心功能完成后再决定保留或移除。 +6. **生产只有一条源码路径。** Ubuntu 源码部署直接使用 Web dist 与三个 Server dist 入口,由 systemd 和 Nginx 管理;源码发布门禁验证这些路径一致,避免维护第二套构建和运行语义。 ## 适合下一阶段的扩展方式 @@ -48,7 +48,7 @@ repository - RocketMQ 只有契约和禁用 Adapter,尚未选择并验证 Node 客户端。 - TypeORM 已固定安全选项,但尚无业务实体和迁移。 - 用户体系和订阅能力按用户决定后置,不在 GOV-009 范围。 -- Docker 交付方式尚未最终确认;现有文件和历史验证保留,但不能据此推定最终生产一定采用容器部署。 +- 仅源码部署调整已实现;历史 Docker 验证只作为决策追溯,不再代表当前可用能力。 ## 复核结论 diff --git a/docs/features/GOV-009-application-scaffold-baseline/design.md b/docs/features/GOV-009-application-scaffold-baseline/design.md index baaa175..6a9e491 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/design.md +++ b/docs/features/GOV-009-application-scaffold-baseline/design.md @@ -61,7 +61,7 @@ RocketMQ 消息契约属于 Server 的进程间协议,只由 Scheduler 与 Wor ## 配置加载 -本地启动固定读取 `deploy/env/development/.env.local`;生产运行依靠 Compose `env_file` 或 systemd `EnvironmentFile` 注入,不从源码目录寻找生产 env。配置解析采用白名单 Schema,未知键可以保留给其他进程,但当前进程只解析最小集合。 +本地启动固定读取 `deploy/env/development/.env.local`;生产运行只依靠 systemd `EnvironmentFile` 注入,不从源码目录寻找生产 env。配置解析采用白名单 Schema,未知键可以保留给其他进程,但当前进程只解析最小集合。 敏感值只用于建立连接,错误对象和日志使用键名而不输出实际值。Web 构建只加载 `web.env.local` 中的 `VITE_` 变量。 @@ -88,7 +88,8 @@ Server 数据访问使用 Nest 官方 `@nestjs/typeorm` 集成、TypeORM 和 `my - Web 使用 Vitest 验证启动外壳和生成客户端边界。 - Playwright 验证 Web 能启动、路由可访问且无阻断错误;GOV-009 不进行业务页面视觉设计。 - 使用真实 Redis 和固定开发 MySQL 执行只读冒烟;RocketMQ 只验证禁用态和既有管理链路,不宣称 Node 消息收发通过。 -- Web、三个 Server 入口和两个 Dockerfile均执行构建验证。 +- Web 与三个 Server 入口执行真实构建验证;源码发布门禁同时核对 systemd 和 Nginx 模板。 +- E2E 在启动 API 前只读检查现有本机 Redis;不可达时明确失败,不启动替代基础设施。 ## 回退原则 @@ -96,4 +97,4 @@ GOV-009 不迁移业务数据。回退时恢复上一 Git 标签即可移除脚 ## 实施后架构论证门禁 -本文档描述的是实施前目标架构。完成代码、测试、构建和 Review 后,必须根据 GitNexus 实际依赖图、Web/Server 目录、三个 Server 进程、OpenAPI 生成链路和部署镜像另写 `architecture-review.md`。复核需要说明实际实现与目标的偏差、当前选择优于替代方案的理由、何时应拆分或调整。用户再次确认前不发布 `v0.0.8`,也不把本功能视为彻底完成。 +本文档描述的是实施前目标架构。完成代码、测试、构建和 Review 后,必须根据 GitNexus 实际依赖图、Web/Server 目录、三个 Server 进程、OpenAPI 生成链路和源码部署产物另写 `architecture-review.md`。复核需要说明实际实现与目标的偏差、当前选择优于替代方案的理由、何时应拆分或调整。用户再次确认前不发布 `v0.0.8`,也不把本功能视为彻底完成。 diff --git a/docs/features/GOV-009-application-scaffold-baseline/manifest.yaml b/docs/features/GOV-009-application-scaffold-baseline/manifest.yaml index 0e2d463..40205a2 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/manifest.yaml +++ b/docs/features/GOV-009-application-scaffold-baseline/manifest.yaml @@ -15,3 +15,4 @@ release: unreleased completion_gate: 实施完成后必须基于实际前后端架构向用户论证并再次确认 architecture_review: architecture-review.md source_only_deployment_design: source-only-deployment-design.md +source_only_deployment_implementation_plan: source-only-deployment-implementation-plan.md diff --git a/docs/features/GOV-009-application-scaffold-baseline/prd.md b/docs/features/GOV-009-application-scaffold-baseline/prd.md index d14731a..a2ef3ba 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/prd.md +++ b/docs/features/GOV-009-application-scaffold-baseline/prd.md @@ -32,7 +32,7 @@ - Server 配置解析、条件必填和启动失败策略。 - API 健康检查、OpenAPI 产物与 Web 客户端生成链路。 - Redis 与 MySQL 开发连接健康检查;RocketMQ 保持配置可选和默认关闭。 -- 源码构建所需 Dockerfile 与现有生产 Compose 命令对齐。 +- Ubuntu 源码部署所需 Web/Server 产物与 systemd、Nginx 命令对齐,Docker 能力由后续确认的仅源码决策移除。 - Schema 开发配置路径、固定远程开发库白名单及空库基线处理。 ## 非目标 @@ -43,6 +43,7 @@ - 不把 HTTP DTO、数据库实体或消息事件做成前后端共享源码包。 - 不实现未经验证的 RocketMQ Node SDK;本功能只建立 Adapter 边界、配置校验和禁用态启动能力。 - 不创建、删除或修改生产基础设施与生产数据。 +- 不管理本地或生产 Redis、MySQL、RocketMQ 的生命周期,不提供容器兜底。 ## 验收标准 @@ -56,4 +57,5 @@ - Schema 工具只接受 `development`、`a_share_analysis_dev`、MySQL 5.7、`utf8mb4_general_ci` 和固定主机白名单;其他远程主机立即拒绝。 - 空开发库可以生成可验证的 captured Schema 基线,不用空 SQL 冒充结构,也不退回 `not_applicable`。 - 单元测试、契约测试、构建、开发启动冒烟和适用的浏览器验证全部通过。 +- 仓库不存在活动 Dockerfile、Compose 或 Docker 专用门禁;`pnpm verify:release` 只执行源码发布检查。 - 全部实施完成后必须基于真实代码和部署产物提交《实施架构复核》,向用户论证前后端职责、依赖方向、进程边界、替代方案和演进成本;获得用户再次确认前,GOV-009 不得标记完成或创建发布标签。 diff --git a/docs/features/GOV-009-application-scaffold-baseline/qa/test-results.md b/docs/features/GOV-009-application-scaffold-baseline/qa/test-results.md index 5594106..0908b9d 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/qa/test-results.md +++ b/docs/features/GOV-009-application-scaffold-baseline/qa/test-results.md @@ -46,3 +46,22 @@ - 本机 Redis `127.0.0.1:6379` 的 DB 0、1、2 分别执行只读 `PING`,三次均返回 `PONG`;未读取或修改业务键。 - 固定开发 MySQL 只读会话返回 `5.7.42-0ubuntu0.18.04.1`、库 `a_share_analysis_dev`、`utf8mb4`、`utf8mb4_general_ci`、会话时区 `+08:00`;Task 1 对象计数为 0。密码只通过临时进程环境传递,未输出或写入命令参数;未连接、探测或读取生产数据库。 - 本机 RocketMQ 只读 TCP 可达性复核:NameServer `127.0.0.1:9876`、Broker `127.0.0.1:10911`、Proxy `127.0.0.1:8081` 均可达。Node Adapter 仍保持禁用,没有创建 Topic、消费或发送消息。 + +## 仅源码部署调整(2026-08-13) + +### RED + +- `pnpm test:deployment` 因 `verify-source-deployment.mjs` 尚不存在而失败,证明新测试先于源码发布门禁实现。 +- `pnpm test:environment-baseline` 因 Docker 活动文件和本地 Compose 指令仍存在而失败,证明政策测试能捕获待移除能力。 +- `node --test server/tools/run-test-api.test.mjs` 因 `checkLocalRedis` 尚未导出而失败,证明外部 Redis 预检行为先有测试。 + +### GREEN + +- 源码部署夹具:5/5;覆盖完整发布、Docker 活动文件、Worker 产物缺失、systemd root 用户和 Nginx 错误配置。 +- 环境与部署契约:10/10;活动 Docker 入口不存在,本地外部 Redis和唯一 Ubuntu 源码部署规则通过。 +- Redis 预检:2/2;OS 分配的本机 TCP 端口分别验证可达和不可达,不安装或启动外部服务。 +- 本机 `redis-cli -h 127.0.0.1 -p 6379 ping` 返回 `PONG`。 +- Server 原有 30 项 Vitest 与新增 2 项 Node 测试全部通过。 +- Playwright 桌面与移动视口共 4/4,通过真实本机 Redis、API 健康接口和 Web 同源代理链路。 + +前述 `pnpm verify:docker` 及镜像验证内容是调整前的真实历史证据。2026-08-13 用户确认仅源码部署后,这些能力已被取代并从活动仓库删除,不再执行或作为发布依据。 diff --git a/docs/features/GOV-009-application-scaffold-baseline/release-notes.md b/docs/features/GOV-009-application-scaffold-baseline/release-notes.md index 36222a6..24bcad7 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/release-notes.md +++ b/docs/features/GOV-009-application-scaffold-baseline/release-notes.md @@ -6,10 +6,11 @@ GOV-009 建立了可运行的应用工程基线: - Server 提供 API、Scheduler、Worker 三个独立入口、最小权限配置和优雅关闭生命周期。 - HTTP 契约由 Server DTO/OpenAPI 单向生成 Web 客户端;RocketMQ 消息契约保持 Server 内部。 - Redis/MySQL 只读健康检查、TypeORM MySQL 5.7.42 基线和 RocketMQ 禁用 Adapter 已建立。 -- 开发/生产模板、Docker Compose、Ubuntu systemd/Nginx 与源码部署路径已对齐。 +- 开发/生产模板、Ubuntu systemd/Nginx 与唯一源码部署路径已对齐。 - Schema 支持固定远程开发库与真实空库 `captured_empty`,历史清单保持不可变。 -- 新增契约漂移、Vue SFC 边界、三进程生命周期、Playwright 真实 API 和 Docker 集成门禁。 +- 新增契约漂移、Vue SFC 边界、三进程生命周期、Playwright 真实 API 和源码发布门禁。 +- 删除 Dockerfile、Compose、镜像校验和容器测试;Redis/MySQL/RocketMQ 统一为外部服务。 当前状态为 `reviewing/unreleased`。实现提交已推送至 `origin/main`,但按用户要求,必须完成基于真实实现的架构论证并再次确认后才能创建和推送 `v0.0.8` 标签。 -Docker Compose 当前降级为待定的可选交付能力:保留既有文件和历史验证,不进入后续日常开发必跑流程;核心功能完成后由用户决定最终保留 Docker 还是仅采用源码部署。 +2026-08-13 后续确认的仅源码部署决策已取代此前可选 Docker 交付结论。历史验证记录继续保留用于追溯,但仓库不再提供可执行 Docker 能力。 diff --git a/docs/features/GOV-009-application-scaffold-baseline/requirements.md b/docs/features/GOV-009-application-scaffold-baseline/requirements.md index fe35d49..4c4bd08 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/requirements.md +++ b/docs/features/GOV-009-application-scaffold-baseline/requirements.md @@ -44,8 +44,10 @@ ## R7 部署与质量 -- Web 与 Server 分别提供可重复构建的 Dockerfile,生产 Compose 使用不可变镜像并与真实产物路径一致。 -- 源码部署的 systemd 命令必须与 Server 构建产物一致。 +- 项目只支持 Ubuntu 22.04/24.04 LTS x86_64 源码部署,不提供 Docker 运行、构建、测试或部署入口。 +- Web、API、Scheduler、Worker 构建产物必须完整,源码部署的 systemd 命令必须与三个 Server 构建入口一致。 +- 本地 Redis 和生产 Redis/MySQL/RocketMQ 均为外部系统服务,项目脚本不得安装、启动、停止或删除它们。 +- `verify:release` 必须校验真实构建产物、systemd 最小权限和 Nginx Web/API 链路;Redis 不可达时 E2E 必须明确失败且不得提供容器兜底。 - 根级检查必须覆盖格式、静态检查、类型、单元测试、契约测试和构建。 - 所有关键配置边界、契约生成和启动失败行为必须有自动化测试。 - Server 使用 `@nestjs/swagger` 输出稳定的 `openapi.json`;Web 使用 `openapi-typescript` 与 `openapi-fetch` 生成类型安全客户端。 diff --git a/docs/features/GOV-009-application-scaffold-baseline/rollback.md b/docs/features/GOV-009-application-scaffold-baseline/rollback.md index 0ccb5ea..367f5b4 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/rollback.md +++ b/docs/features/GOV-009-application-scaffold-baseline/rollback.md @@ -10,8 +10,9 @@ GOV-009 没有创建业务表、执行迁移、写入生产数据库、创建 Ro ## 部署回退 -- Docker 恢复上一组 Web/Server 不可变镜像摘要;源码部署把 `/opt/a-share-analysis/current` 切回上一完整发布目录并重启三个 systemd 服务。 +- 把 `/opt/a-share-analysis/current` 原子切回上一完整发布目录并重启 API、Scheduler、Worker 三个 systemd 服务。 - `/etc/a-share-analysis/*.env`、本地 `*.env.local`、Redis、MySQL 和 RocketMQ 数据不随应用回退覆盖或删除。 - 回退后重新执行健康检查、契约门禁和可用性验证。 +- 外部基础设施和 `/etc/a-share-analysis/*.env` 不属于应用回退范围,禁止删除或覆盖。 当前尚未创建 `v0.0.8` 标签,因此无需删除或移动标签。用户确认发布后,若发布回退也只能新建更高版本修复或明确的回退提交,不能移动已推送标签。 diff --git a/docs/features/GOV-009-application-scaffold-baseline/test-plan.md b/docs/features/GOV-009-application-scaffold-baseline/test-plan.md index ab79010..00d99ae 100644 --- a/docs/features/GOV-009-application-scaffold-baseline/test-plan.md +++ b/docs/features/GOV-009-application-scaffold-baseline/test-plan.md @@ -12,6 +12,8 @@ - Schema 固定远程白名单、未知远程拒绝和 `api.env.local` 优先级测试。 - Schema 空库 captured 清单、摘要和历史目录不可覆盖测试。 - Web、Server 类型检查、单元测试和生产构建。 +- 源码发布门禁检查 Docker 活动入口不存在、四类构建产物完整、systemd 最小权限和 Nginx Web/API 链路。 +- API E2E 的 Redis TCP 预检覆盖可达与不可达,证明失败时不启动替代基础设施。 ## 真实环境冒烟 @@ -19,10 +21,10 @@ - 固定开发 MySQL 验证认证、5.7 服务端、数据库名、字符集、排序规则和 `+08:00` 会话时区。 - RocketMQ 验证 NameServer、Broker 和 Proxy 可达;不创建 Topic,不发送消息。 - 浏览器访问 Web 与 API 健康检查,无控制台阻断错误。 +- `pnpm verify:release` 只执行源码发布链路,不调用 Docker。 ## 安全检查 - Git 跟踪文件不得包含本地密码、开发库凭据、AI Key 或生产地址。 - Web 构建产物不得包含 Server env 键或消息契约。 - 连接错误和测试日志不得输出密码。 - diff --git a/docs/governance/code-development-standards.md b/docs/governance/code-development-standards.md index 7116f7e..ba73bf1 100644 --- a/docs/governance/code-development-standards.md +++ b/docs/governance/code-development-standards.md @@ -92,7 +92,7 @@ count += 1; - 只有 `*.env.example` 可以提交;真实 `.env`、`*.env.local`、密码、Token 和 API Key 必须被 Git 忽略并由运行环境注入。 - Web 只能使用公开的 `VITE_*` 变量。数据库、Redis、RocketMQ、数据源和 AI 凭据禁止进入前端变量或构建产物。 - MySQL、RocketMQ、正式数据源与 AI 使用显式功能开关;关闭时连接变量可为空,开启后缺少任何必填值都必须拒绝启动。 -- Docker Compose 与源码部署必须复用同一变量契约,禁止为某种部署方式建立语义不同的别名。 +- 开发源码进程与 Ubuntu 生产源码部署复用同一变量契约;项目脚本不得安装、启动、停止或删除 Redis、MySQL、RocketMQ 等外部基础设施。 - 生产配置必须位于发布目录之外,切换应用版本不得覆盖配置;日志和诊断输出必须对配置值脱敏。 ## 9. 前端规范 @@ -116,7 +116,7 @@ count += 1; ## 11. MySQL 5.7 兼容规范 -- 开发、测试、Docker 和生产环境统一使用 MySQL 5.7.42,禁止使用其他 5.7 补丁、`latest`、MySQL 8.x 或其他数据库代替兼容性验证。 +- 开发、测试和生产环境统一使用 MySQL 5.7.42,禁止使用其他 5.7 补丁、`latest`、MySQL 8.x 或其他数据库代替兼容性验证。 - 创建数据库必须显式声明 `DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci`;建表与迁移脚本必须显式保持 `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`。 - ORM 和数据库驱动必须声明 `utf8mb4`,连接建立后验证会话、数据库和表的排序规则为 `utf8mb4_general_ci`,不得依赖服务器默认值。 - 文本列默认继承表级规则。列级改变字符集或排序规则必须由具体功能设计解释,并通过 ADR 审批。 diff --git a/docs/governance/development-workflow.md b/docs/governance/development-workflow.md index 1edc6bd..cd34811 100644 --- a/docs/governance/development-workflow.md +++ b/docs/governance/development-workflow.md @@ -84,7 +84,8 @@ npm run governance:finish -- FEAT-MO-001 market-overview - 发布说明和回退方法可执行。 - 中文提交已关联功能编号。 - 当前分支和本次新建标签已推送到 `origin`,功能档案保存远程核对结果。 -- 配置契约变化已同步开发/生产模板和适用的 Compose、systemd、Nginx及部署文档。 +- 配置契约变化已同步开发/生产模板、systemd、Nginx 和源码部署文档。 +- `pnpm verify:release` 源码发布门禁通过;外部基础设施不可达时按前置条件失败,不得由项目脚本启动替代服务。 - 下一版本开发前所需的上一发布版本 Schema 基线已验证、独立提交并推送 Gitea。 ## 7. 数据库 Schema 基线 @@ -132,6 +133,6 @@ git push origin <本次标签名> - 使用语义化版本和带注释 Git Tag。 - 发布产物必须对应确定提交,不从浮动分支临时构建。 -- 应用优先通过部署上一不可变镜像回退。 +- 应用通过把 `/opt/a-share-analysis/current` 原子切回上一完整不可变发布目录并重启 systemd 服务回退。 - 数据库采用可兼容迁移和前向修复,破坏性回滚需先备份和演练。 - 规则、提示词和消息契约发布后不得原地覆盖,只能新增版本。 diff --git a/docs/index.md b/docs/index.md index ba2d6ac..6bb1c37 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,8 +19,7 @@ ## 环境与部署 - [本地开发环境](deployment/local-development.md) -- [Docker Compose 生产部署](deployment/docker-compose-production.md) -- [Ubuntu 源码生产部署](deployment/ubuntu-source-production.md) +- [Ubuntu 源码生产部署(唯一生产方式)](deployment/ubuntu-source-production.md) ## 数据库版本基线 @@ -41,7 +40,7 @@ - [GOV-003:项目专用 Gitea 远程仓库](features/GOV-003-gitea-local-remote/manifest.yaml) - [GOV-004:提交后强制推送 Gitea](features/GOV-004-mandatory-gitea-push/manifest.yaml) - [GOV-005:MySQL 5.7 字符集与兼容性基线](features/GOV-005-mysql57-charset-baseline/manifest.yaml) -- [GOV-006:开发、生产环境与双部署方式](features/GOV-006-environment-deployment-baseline/manifest.yaml) +- [GOV-006:开发、生产环境与双部署方式(历史基线,部署方式已被 GOV-009 取代)](features/GOV-006-environment-deployment-baseline/manifest.yaml) - [GOV-007:开发数据库 Schema 基线与版本门禁](features/GOV-007-development-schema-baseline/manifest.yaml) - [GOV-008:RocketMQ 消息队列架构调整](features/GOV-008-rocketmq-adoption/manifest.yaml) - [GOV-009:应用工程脚手架基线](features/GOV-009-application-scaffold-baseline/manifest.yaml) diff --git a/package.json b/package.json index f99147b..7d5c138 100644 --- a/package.json +++ b/package.json @@ -22,17 +22,16 @@ "check:contracts": "pnpm --filter @a-share-analysis/server openapi:generate && pnpm --filter @a-share-analysis/web api:generate && pnpm exec prettier --write server/openapi/openapi.json web/src/api/generated/schema.ts && node tools/contracts/verify-generated-contracts.mjs && node tools/boundaries/verify-project-boundaries.mjs", "test:contracts": "node --test tools/contracts/verify-generated-contracts.test.mjs", "test:boundaries": "node --test tools/boundaries/verify-project-boundaries.test.mjs", - "test:deployment": "node --test tools/deployment/verify-image-references.test.mjs", + "test:deployment": "node --test tools/deployment/verify-source-deployment.test.mjs", "lint": "pnpm --filter @a-share-analysis/web --if-present lint && pnpm --filter @a-share-analysis/server --if-present lint", "format:check": "pnpm exec prettier --check .", "typecheck": "pnpm --filter @a-share-analysis/web --if-present typecheck && pnpm --filter @a-share-analysis/server --if-present typecheck", "test": "pnpm test:runtime && pnpm test:governance && pnpm test:mysql-baseline && pnpm test:environment-baseline && pnpm test:schema-baseline && pnpm test:contracts && pnpm test:boundaries && pnpm test:deployment && pnpm --filter @a-share-analysis/web --if-present test && pnpm --filter @a-share-analysis/server --if-present test", "build": "pnpm --filter @a-share-analysis/web --if-present build && pnpm --filter @a-share-analysis/server --if-present build", "verify:e2e": "pnpm --filter @a-share-analysis/web test:e2e", - "check:images": "node tools/deployment/verify-image-references.mjs", - "verify:docker": "node tools/deployment/verify-docker.mjs", + "verify:source": "node tools/deployment/verify-source-deployment.mjs", "verify": "pnpm check:runtime && pnpm check:contracts && pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build && pnpm verify:e2e", - "verify:release": "pnpm verify && pnpm check:images && pnpm verify:docker" + "verify:release": "pnpm verify && pnpm verify:source" }, "devDependencies": { "@eslint/js": "9.39.5", diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 16d93cb..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM node:22.22.0-bookworm-slim AS builder - -ENV PNPM_HOME=/pnpm -ENV PATH=$PNPM_HOME:$PATH -RUN corepack enable && corepack prepare pnpm@10.25.0 --activate - -WORKDIR /workspace -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY web/package.json web/package.json -COPY server/package.json server/package.json -RUN pnpm install --frozen-lockfile --filter @a-share-analysis/server... - -COPY server server -RUN pnpm --filter @a-share-analysis/server build -RUN pnpm --filter @a-share-analysis/server deploy --legacy --prod /runtime - -FROM node:22.22.0-bookworm-slim AS runtime -ENV NODE_ENV=production -ENV APP_ENV=production -WORKDIR /app -COPY --from=builder --chown=node:node /runtime/package.json ./package.json -COPY --from=builder --chown=node:node /runtime/node_modules ./node_modules -COPY --from=builder --chown=node:node /workspace/server/dist ./dist -USER node -CMD ["node", "dist/apps/api/main.js"] diff --git a/server/package.json b/server/package.json index 1d50c7c..29f328c 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,7 @@ "openapi:generate": "tsx tools/export-openapi.ts", "lint": "eslint .", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "vitest run", + "test": "vitest run && node --test tools/run-test-api.test.mjs", "test:watch": "vitest", "build": "node tools/clean-build-output.mjs && nest build api && nest build scheduler && nest build worker && node tools/verify-build-output.mjs" }, diff --git a/server/tools/run-test-api.mjs b/server/tools/run-test-api.mjs index 38f217d..4f0c993 100644 --- a/server/tools/run-test-api.mjs +++ b/server/tools/run-test-api.mjs @@ -1,103 +1,89 @@ -import { execFileSync, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { createConnection } from 'node:net'; import path from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; const serverRoot = path.resolve(import.meta.dirname, '..'); const launcher = path.join(serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); -const redisContainerName = `a-share-analysis-e2e-redis-${process.pid}`; -let ownsRedisContainer = false; -let redisPort = '6379'; -const localRedisAvailable = await new Promise((resolve) => { - const socket = createConnection({ host: '127.0.0.1', port: 6379 }); - const finish = (available) => { - socket.destroy(); - resolve(available); - }; - socket.setTimeout(500, () => finish(false)); - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); -}); +/** + * E2E 只检查开发者已维护的本机 Redis,不安装、启动或停止任何外部服务。 + * 失败信息固定指向项目要求的公开本地端点,避免泄漏可注入的测试地址。 + */ +export function checkLocalRedis({ host = '127.0.0.1', port = 6379, timeoutMs = 500 } = {}) { + return new Promise((resolve, reject) => { + const socket = createConnection({ host, port }); + let settled = false; -if (!localRedisAvailable) { - execFileSync( - 'docker', - [ - 'run', - '--rm', - '--detach', - '--name', - redisContainerName, - '--publish', - '127.0.0.1::6379', - 'redis:7.4.8-alpine3.21', - ], - { stdio: ['ignore', 'pipe', 'inherit'] }, - ); - ownsRedisContainer = true; - redisPort = execFileSync('docker', ['port', redisContainerName, '6379/tcp'], { - encoding: 'utf8', - }) - .trim() - .split(':') - .at(-1); + const finish = (error) => { + if (settled) return; + settled = true; + socket.destroy(); + if (error) reject(error); + else resolve(); + }; + + const unavailable = () => + finish(new Error('本机 Redis 127.0.0.1:6379 不可达,请确认外部 Redis 服务启动后重试')); + + socket.setTimeout(timeoutMs, unavailable); + socket.once('connect', () => finish()); + socket.once('error', unavailable); + }); } -// 浏览器集成测试只启用 API 与禁用态 MySQL;Redis 故障会形成可验证的安全 503 响应, -// 所有值均为公开测试夹具,不读取开发者的 *.env.local 或任何生产配置。 -const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], { - cwd: serverRoot, - env: { - PATH: process.env.PATH, - APP_ENV: 'production', - LOG_LEVEL: 'error', - API_HOST: '127.0.0.1', - API_PORT: '3000', - REDIS_HOST: '127.0.0.1', - REDIS_PORT: redisPort, - REDIS_USERNAME: '', - REDIS_PASSWORD: '', - REDIS_DATABASE: '0', - REDIS_TLS: 'false', - MYSQL_ENABLED: 'false', - MYSQL_HOST: '127.0.0.1', - MYSQL_PORT: '3306', - MYSQL_DATABASE: 'a_share_analysis_dev', - MYSQL_USERNAME: '', - MYSQL_PASSWORD: '', - MYSQL_CHARSET: 'utf8mb4', - MYSQL_COLLATION: 'utf8mb4_general_ci', - MYSQL_TIMEZONE: '+08:00', - AI_ENABLED: 'false', - AI_BASE_URL: '', - AI_API_KEY: '', - AI_MODEL: '', - AI_TIMEOUT_MS: '30000', - }, - stdio: 'inherit', -}); +export async function runTestApi() { + await checkLocalRedis(); -const forwardSignal = (signal) => { - if (ownsRedisContainer) { - try { - execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' }); - } catch { - // Playwright 可能已因启动失败清理容器,重复清理保持幂等。 - } - } - if (child.exitCode === null) child.kill(signal); -}; -process.once('SIGINT', () => forwardSignal('SIGINT')); -process.once('SIGTERM', () => forwardSignal('SIGTERM')); -child.once('exit', (code, signal) => { - if (ownsRedisContainer) { - try { - execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' }); - } catch { - // 容器已由信号处理清理时无需再次报告错误。 - } - } - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 1); -}); + // 浏览器集成测试只启用 API 与 Redis,其他依赖使用公开禁用态夹具;这里不会读取 + // 开发者的 *.env.local 或任何生产配置,也不会把敏感值传给子进程。 + const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], { + cwd: serverRoot, + env: { + PATH: process.env.PATH, + APP_ENV: 'production', + LOG_LEVEL: 'error', + API_HOST: '127.0.0.1', + API_PORT: '3000', + REDIS_HOST: '127.0.0.1', + REDIS_PORT: '6379', + REDIS_USERNAME: '', + REDIS_PASSWORD: '', + REDIS_DATABASE: '0', + REDIS_TLS: 'false', + MYSQL_ENABLED: 'false', + MYSQL_HOST: '127.0.0.1', + MYSQL_PORT: '3306', + MYSQL_DATABASE: 'a_share_analysis_dev', + MYSQL_USERNAME: '', + MYSQL_PASSWORD: '', + MYSQL_CHARSET: 'utf8mb4', + MYSQL_COLLATION: 'utf8mb4_general_ci', + MYSQL_TIMEZONE: '+08:00', + AI_ENABLED: 'false', + AI_BASE_URL: '', + AI_API_KEY: '', + AI_MODEL: '', + AI_TIMEOUT_MS: '30000', + }, + stdio: 'inherit', + }); + + const forwardSignal = (signal) => { + if (child.exitCode === null) child.kill(signal); + }; + process.once('SIGINT', () => forwardSignal('SIGINT')); + process.once('SIGTERM', () => forwardSignal('SIGTERM')); + child.once('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); + }); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) +) { + await runTestApi(); +} diff --git a/server/tools/run-test-api.test.mjs b/server/tools/run-test-api.test.mjs new file mode 100644 index 0000000..b98bbb1 --- /dev/null +++ b/server/tools/run-test-api.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import net from 'node:net'; +import test from 'node:test'; + +import { checkLocalRedis } from './run-test-api.mjs'; + +function listenOnLoopback() { + const server = net.createServer(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +test('Redis 不可达时给出外部服务提示并拒绝启动', async () => { + const server = await listenOnLoopback(); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + await closeServer(server); + + await assert.rejects( + checkLocalRedis({ host: '127.0.0.1', port: address.port, timeoutMs: 100 }), + /本机 Redis.*127\.0\.0\.1:6379.*启动后重试/, + ); +}); + +test('Redis TCP 可达时预检通过', async (context) => { + const server = await listenOnLoopback(); + context.after(() => closeServer(server)); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + + await assert.doesNotReject(() => + checkLocalRedis({ host: '127.0.0.1', port: address.port, timeoutMs: 100 }), + ); +}); diff --git a/tools/deployment/verify-docker.mjs b/tools/deployment/verify-docker.mjs deleted file mode 100644 index 11ff3ef..0000000 --- a/tools/deployment/verify-docker.mjs +++ /dev/null @@ -1,232 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import process from 'node:process'; - -const suffix = `${process.pid}`; -const webImage = `a-share-analysis-web:verify-${suffix}`; -const serverImage = `a-share-analysis-server:verify-${suffix}`; -const network = `a-share-analysis-verify-${suffix}`; -const redis = `a-share-analysis-verify-redis-${suffix}`; -const api = `a-share-analysis-verify-api-${suffix}`; -const scheduler = `a-share-analysis-verify-scheduler-${suffix}`; -const worker = `a-share-analysis-verify-worker-${suffix}`; -const web = `a-share-analysis-verify-web-${suffix}`; -const containers = [web, worker, scheduler, api, redis]; - -function docker(args, options = {}) { - return execFileSync('docker', args, { - cwd: process.cwd(), - encoding: 'utf8', - stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', - }); -} - -function waitForContainerCommand(container, command, description) { - let lastError; - for (let attempt = 0; attempt < 30; attempt += 1) { - try { - docker(['exec', container, ...command], { capture: true }); - return; - } catch (error) { - lastError = error; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); - } - } - throw new Error(`${description} 未在 6 秒内就绪:${String(lastError?.stderr ?? '')}`); -} - -function verifyLongRunningProcess(container, entry, environment) { - const environmentArguments = environment.flatMap(([key, value]) => ['--env', `${key}=${value}`]); - docker( - [ - 'run', - '--detach', - '--name', - container, - ...environmentArguments, - serverImage, - 'node', - `/app/dist/apps/${entry}/main.js`, - ], - { capture: true }, - ); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); - const running = docker(['inspect', container, '--format', '{{.State.Running}}'], { - capture: true, - }).trim(); - if (running !== 'true') throw new Error(`${entry} 容器未保持运行`); - - docker(['stop', '--time', '5', container], { capture: true }); - const exitCode = docker(['inspect', container, '--format', '{{.State.ExitCode}}'], { - capture: true, - }).trim(); - if (exitCode !== '0') throw new Error(`${entry} 容器未优雅退出,退出码 ${exitCode}`); -} - -try { - docker(['build', '--file', 'web/Dockerfile', '--tag', webImage, '.']); - docker(['build', '--file', 'server/Dockerfile', '--tag', serverImage, '.']); - - const serverUser = docker(['image', 'inspect', serverImage, '--format', '{{.Config.User}}'], { - capture: true, - }).trim(); - if (serverUser !== 'node') throw new Error(`Server 镜像必须使用 node 用户,实际 ${serverUser}`); - - for (const entry of ['api', 'scheduler', 'worker']) { - docker( - ['run', '--rm', '--entrypoint', 'test', serverImage, '-f', `/app/dist/apps/${entry}/main.js`], - { capture: true }, - ); - } - - const sharedProcessEnvironment = [ - ['APP_ENV', 'production'], - ['LOG_LEVEL', 'error'], - ['REDIS_HOST', '127.0.0.1'], - ['REDIS_PORT', '6379'], - ['REDIS_USERNAME', ''], - ['REDIS_PASSWORD', ''], - ['REDIS_DATABASE', '1'], - ['REDIS_TLS', 'false'], - ['ROCKETMQ_ENABLED', 'false'], - ['ROCKETMQ_NAMESRV_ADDR', '127.0.0.1:9876'], - ['ROCKETMQ_ENDPOINT', '127.0.0.1:8081'], - ['ROCKETMQ_TOPIC_PREFIX', 'a-share-analysis'], - ]; - verifyLongRunningProcess(scheduler, 'scheduler', [ - ...sharedProcessEnvironment, - ['SCHEDULER_TIMEZONE', 'Asia/Shanghai'], - ['SCHEDULER_ENABLED', 'true'], - ['ROCKETMQ_SEND_TIMEOUT_MS', '3000'], - ]); - verifyLongRunningProcess(worker, 'worker', [ - ...sharedProcessEnvironment, - ['MARKET_DATA_MODE', 'live'], - ['MARKET_DATA_PROVIDER', 'deterministic-fixture'], - ['MARKET_DATA_BASE_URL', ''], - ['MARKET_DATA_API_KEY', ''], - ['MYSQL_ENABLED', 'false'], - ['MYSQL_HOST', '127.0.0.1'], - ['MYSQL_PORT', '3306'], - ['MYSQL_DATABASE', 'a_share_analysis_dev'], - ['MYSQL_USERNAME', ''], - ['MYSQL_PASSWORD', ''], - ['MYSQL_CHARSET', 'utf8mb4'], - ['MYSQL_COLLATION', 'utf8mb4_general_ci'], - ['MYSQL_TIMEZONE', '+08:00'], - ['AI_ENABLED', 'false'], - ['AI_BASE_URL', ''], - ['AI_API_KEY', ''], - ['AI_MODEL', ''], - ['AI_TIMEOUT_MS', '30000'], - ['ROCKETMQ_CONSUMER_GROUP', 'a-share-analysis-worker'], - ['ROCKETMQ_CONSUMER_CONCURRENCY', '4'], - ]); - - docker(['network', 'create', network], { capture: true }); - docker(['run', '--detach', '--name', redis, '--network', network, 'redis:7.4.8-alpine3.21'], { - capture: true, - }); - docker( - [ - 'run', - '--detach', - '--name', - api, - '--network', - network, - '--network-alias', - 'api', - '--env', - 'APP_ENV=production', - '--env', - 'LOG_LEVEL=error', - '--env', - 'API_HOST=0.0.0.0', - '--env', - 'API_PORT=3000', - '--env', - `REDIS_HOST=${redis}`, - '--env', - 'REDIS_PORT=6379', - '--env', - 'REDIS_USERNAME=', - '--env', - 'REDIS_PASSWORD=', - '--env', - 'REDIS_DATABASE=0', - '--env', - 'REDIS_TLS=false', - '--env', - 'MYSQL_ENABLED=false', - '--env', - 'MYSQL_HOST=127.0.0.1', - '--env', - 'MYSQL_PORT=3306', - '--env', - 'MYSQL_DATABASE=a_share_analysis_dev', - '--env', - 'MYSQL_USERNAME=', - '--env', - 'MYSQL_PASSWORD=', - '--env', - 'MYSQL_CHARSET=utf8mb4', - '--env', - 'MYSQL_COLLATION=utf8mb4_general_ci', - '--env', - 'MYSQL_TIMEZONE=+08:00', - '--env', - 'AI_ENABLED=false', - '--env', - 'AI_BASE_URL=', - '--env', - 'AI_API_KEY=', - '--env', - 'AI_MODEL=', - '--env', - 'AI_TIMEOUT_MS=30000', - serverImage, - ], - { capture: true }, - ); - docker(['run', '--detach', '--name', web, '--network', network, webImage], { - capture: true, - }); - - waitForContainerCommand( - api, - [ - 'node', - '-e', - "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>{if(r.status!==200)process.exit(1)})", - ], - 'API 健康检查', - ); - waitForContainerCommand(web, ['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/'], 'Web 首页'); - waitForContainerCommand( - web, - ['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/api/v1/health'], - 'Web 到 API 的 Nginx 代理', - ); - - process.stdout.write('Docker 镜像、非 root 用户、三进程生命周期与 Web/API 链路验证通过\n'); -} finally { - for (const container of containers) { - try { - docker(['rm', '--force', container], { capture: true }); - } catch { - // 容器未创建时保持清理幂等。 - } - } - try { - docker(['network', 'rm', network], { capture: true }); - } catch { - // 网络未创建或已自动移除时无需报错。 - } - for (const image of [webImage, serverImage]) { - try { - docker(['image', 'rm', '--force', image], { capture: true }); - } catch { - // 构建中断、镜像尚未产生时保持清理幂等。 - } - } -} diff --git a/tools/deployment/verify-image-references.mjs b/tools/deployment/verify-image-references.mjs deleted file mode 100644 index 8504e7c..0000000 --- a/tools/deployment/verify-image-references.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import process from 'node:process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const IMMUTABLE_IMAGE_PATTERN = /^\S+@sha256:[a-f0-9]{64}$/; - -/** - * Compose 变量语法只能阻断空值,无法拒绝 latest 或普通 tag;发布前必须先通过 - * 该校验器,确保 Web/Server 都使用包含仓库名的内容摘要引用。 - */ -export function assertImmutableImageReferences(environment) { - for (const key of ['A_SHARE_WEB_IMAGE', 'A_SHARE_SERVER_IMAGE']) { - const value = environment[key] ?? ''; - if (!IMMUTABLE_IMAGE_PATTERN.test(value)) { - throw new Error(`${key} 必须使用仓库名@sha256摘要`); - } - } -} - -if ( - process.argv[1] && - path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) -) { - assertImmutableImageReferences(process.env); - process.stdout.write('生产镜像引用验证通过\n'); -} diff --git a/tools/deployment/verify-image-references.test.mjs b/tools/deployment/verify-image-references.test.mjs deleted file mode 100644 index 1858998..0000000 --- a/tools/deployment/verify-image-references.test.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { assertImmutableImageReferences } from './verify-image-references.mjs'; - -const digest = `sha256:${'a'.repeat(64)}`; - -test('接受包含仓库名和 sha256 摘要的不可变镜像', () => { - assert.doesNotThrow(() => - assertImmutableImageReferences({ - A_SHARE_WEB_IMAGE: `registry.invalid/web@${digest}`, - A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`, - }), - ); -}); - -for (const [name, image] of [ - ['latest', 'registry.invalid/web:latest'], - ['普通 tag', 'registry.invalid/web:3a8120a'], - ['裸摘要', digest], - ['空值', ''], -]) { - test(`拒绝 Web ${name} 镜像引用`, () => { - assert.throws( - () => - assertImmutableImageReferences({ - A_SHARE_WEB_IMAGE: image, - A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`, - }), - /A_SHARE_WEB_IMAGE 必须使用仓库名@sha256摘要/, - ); - }); -} diff --git a/tools/deployment/verify-source-deployment.mjs b/tools/deployment/verify-source-deployment.mjs new file mode 100644 index 0000000..c68a816 --- /dev/null +++ b/tools/deployment/verify-source-deployment.mjs @@ -0,0 +1,140 @@ +import { access, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const defaultRepositoryRoot = path.resolve(import.meta.dirname, '../..'); + +export const FORBIDDEN_DOCKER_PATHS = Object.freeze([ + '.dockerignore', + 'Dockerfile', + 'web/Dockerfile', + 'web/nginx.conf', + 'server/Dockerfile', + 'deploy/compose/development.yml', + 'deploy/compose/production.yml.example', + 'docs/deployment/docker-compose-production.md', + 'tools/deployment/verify-docker.mjs', + 'tools/deployment/verify-image-references.mjs', + 'tools/deployment/verify-image-references.test.mjs', +]); + +const buildOutputs = Object.freeze([ + ['Web', 'web/dist/index.html'], + ['API', 'server/dist/apps/api/main.js'], + ['Scheduler', 'server/dist/apps/scheduler/main.js'], + ['Worker', 'server/dist/apps/worker/main.js'], +]); + +const serverProcesses = Object.freeze([ + ['api', 'API'], + ['scheduler', 'Scheduler'], + ['worker', 'Worker'], +]); + +function resolveRoot(repositoryRoot) { + return repositoryRoot instanceof URL + ? fileURLToPath(repositoryRoot) + : path.resolve(repositoryRoot); +} + +async function pathExists(absolutePath) { + try { + await access(absolutePath); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} + +async function assertDockerArtifactsAbsent(repositoryRoot) { + for (const relativePath of FORBIDDEN_DOCKER_PATHS) { + if (await pathExists(path.join(repositoryRoot, relativePath))) { + throw new Error(`禁止存在 Docker 活动文件:${relativePath}`); + } + } +} + +async function assertBuildOutputsPresent(repositoryRoot) { + for (const [displayName, relativePath] of buildOutputs) { + if (!(await pathExists(path.join(repositoryRoot, relativePath)))) { + throw new Error(`${displayName} 构建入口不存在:${relativePath}`); + } + } +} + +function assertUnitValue(unit, displayName, key, expectedValue) { + const pattern = new RegExp( + `^${key}=${expectedValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, + 'm', + ); + if (!pattern.test(unit)) { + throw new Error(`${displayName} systemd 必须使用 ${key}=${expectedValue}`); + } +} + +async function assertSystemdUnits(repositoryRoot) { + for (const [processName, displayName] of serverProcesses) { + const relativePath = `deploy/systemd/a-share-${processName}.service.example`; + let unit; + try { + unit = await readFile(path.join(repositoryRoot, relativePath), 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') + throw new Error(`${displayName} systemd 配置不存在:${relativePath}`); + throw error; + } + + const environmentFile = `/etc/a-share-analysis/${processName}.env`; + assertUnitValue(unit, displayName, 'User', 'a-share'); + assertUnitValue(unit, displayName, 'Group', 'a-share'); + assertUnitValue(unit, displayName, 'WorkingDirectory', '/opt/a-share-analysis/current'); + assertUnitValue(unit, displayName, 'EnvironmentFile', environmentFile); + assertUnitValue(unit, displayName, 'ExecStartPre', `/usr/bin/test -r ${environmentFile}`); + assertUnitValue( + unit, + displayName, + 'ExecStart', + `/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/${processName}/main.js`, + ); + } +} + +async function assertNginxConfig(repositoryRoot) { + const relativePath = 'deploy/nginx/a-share-analysis.conf.example'; + let nginx; + try { + nginx = await readFile(path.join(repositoryRoot, relativePath), 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`Nginx 配置不存在:${relativePath}`); + throw error; + } + + for (const [description, pattern] of [ + ['Web dist 根目录', /\broot\s+\/opt\/a-share-analysis\/current\/web\/dist\s*;/], + ['API 代理 location', /location\s+\/api\/\s*\{/], + ['API 代理目标', /proxy_pass\s+http:\/\/127\.0\.0\.1:3000\s*;/], + ]) { + if (!pattern.test(nginx)) throw new Error(`Nginx 缺少${description}`); + } +} + +/** + * 源码发布门禁只验证应用产物和进程配置,不连接或改变任何外部基础设施。 + */ +export async function verifySourceDeployment(repositoryRoot = defaultRepositoryRoot) { + const resolvedRoot = resolveRoot(repositoryRoot); + await assertDockerArtifactsAbsent(resolvedRoot); + await assertBuildOutputsPresent(resolvedRoot); + await assertSystemdUnits(resolvedRoot); + await assertNginxConfig(resolvedRoot); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) +) { + await verifySourceDeployment(); + process.stdout.write('源码发布产物与 systemd/Nginx 配置验证通过\n'); +} diff --git a/tools/deployment/verify-source-deployment.test.mjs b/tools/deployment/verify-source-deployment.test.mjs new file mode 100644 index 0000000..e2a6b5c --- /dev/null +++ b/tools/deployment/verify-source-deployment.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { verifySourceDeployment } from './verify-source-deployment.mjs'; + +const processNames = ['api', 'scheduler', 'worker']; + +function systemdUnit(processName, { user = 'a-share' } = {}) { + const displayName = + processName === 'api' ? 'API' : `${processName[0].toUpperCase()}${processName.slice(1)}`; + + return `[Unit] +Description=A Share Analysis ${displayName} + +[Service] +Type=simple +User=${user} +Group=a-share +WorkingDirectory=/opt/a-share-analysis/current +EnvironmentFile=/etc/a-share-analysis/${processName}.env +ExecStartPre=/usr/bin/test -r /etc/a-share-analysis/${processName}.env +ExecStart=/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/${processName}/main.js + +[Install] +WantedBy=multi-user.target +`; +} + +async function createSourceReleaseFixture({ + omit, + apiUser = 'a-share', + invalidNginx = false, +} = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'a-share-source-release-')); + const files = new Map([ + ['web/dist/index.html', 'A Share Analysis\n'], + ['server/dist/apps/api/main.js', 'export {};\n'], + ['server/dist/apps/scheduler/main.js', 'export {};\n'], + ['server/dist/apps/worker/main.js', 'export {};\n'], + ...processNames.map((processName) => [ + `deploy/systemd/a-share-${processName}.service.example`, + systemdUnit(processName, { user: processName === 'api' ? apiUser : 'a-share' }), + ]), + [ + 'deploy/nginx/a-share-analysis.conf.example', + invalidNginx + ? 'server { root /srv/invalid; }\n' + : `server { + root /opt/a-share-analysis/current/web/dist; + location /api/ { + proxy_pass http://127.0.0.1:3000; + } +} +`, + ], + ]); + + for (const [relativePath, content] of files) { + if (relativePath === omit) continue; + const absolutePath = path.join(root, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, content); + } + + return root; +} + +test('完整源码发布夹具通过', async () => { + const root = await createSourceReleaseFixture(); + + await assert.doesNotReject(() => verifySourceDeployment(root)); +}); + +test('任一 Docker 活动文件会阻断源码发布', async () => { + const root = await createSourceReleaseFixture(); + await writeFile(path.join(root, 'Dockerfile'), 'FROM node:22\n'); + + await assert.rejects(() => verifySourceDeployment(root), /禁止存在 Docker 活动文件.*Dockerfile/); +}); + +test('Server 构建入口缺失会阻断源码发布', async () => { + const root = await createSourceReleaseFixture({ omit: 'server/dist/apps/worker/main.js' }); + + await assert.rejects(() => verifySourceDeployment(root), /Worker 构建入口不存在/); +}); + +test('systemd 配置不是最小权限时会阻断源码发布', async () => { + const root = await createSourceReleaseFixture({ apiUser: 'root' }); + + await assert.rejects(() => verifySourceDeployment(root), /API systemd 必须使用 User=a-share/); +}); + +test('Nginx 未代理 API 或未指向 Web dist 时会阻断源码发布', async () => { + const root = await createSourceReleaseFixture({ invalidNginx: true }); + + await assert.rejects(() => verifySourceDeployment(root), /Nginx/); +}); diff --git a/tools/governance/environment-baseline.test.mjs b/tools/governance/environment-baseline.test.mjs index 22af49b..9b91a30 100644 --- a/tools/governance/environment-baseline.test.mjs +++ b/tools/governance/environment-baseline.test.mjs @@ -1,15 +1,10 @@ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { access, mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; +import { access, readFile } from 'node:fs/promises'; import test from 'node:test'; -import { promisify } from 'node:util'; const repoRoot = new URL('../../', import.meta.url); const processNames = ['api', 'scheduler', 'worker', 'web']; const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/; -const execFileAsync = promisify(execFile); async function readRepoFile(relativePath) { return readFile(new URL(relativePath, repoRoot), 'utf8'); @@ -115,13 +110,21 @@ test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契 } }); -test('本地 Compose 仅启动 Redis 并提供健康检查', async () => { - const compose = await readRepoFile('deploy/compose/development.yml'); - - assert.match(compose, /services:\s*\n\s+redis:/); - assert.match(compose, /healthcheck:/); - assert.match(compose, /127\.0\.0\.1:/); - assert.doesNotMatch(compose, /^\s{2}(mysql|rocketmq|api|worker|scheduler|web):/m); +test('活动仓库不再包含 Docker 运行构建或部署入口', async () => { + for (const relativePath of [ + '.dockerignore', + 'web/Dockerfile', + 'web/nginx.conf', + 'server/Dockerfile', + 'deploy/compose/development.yml', + 'deploy/compose/production.yml.example', + 'docs/deployment/docker-compose-production.md', + 'tools/deployment/verify-docker.mjs', + 'tools/deployment/verify-image-references.mjs', + 'tools/deployment/verify-image-references.test.mjs', + ]) { + await assert.rejects(access(new URL(relativePath, repoRoot)), { code: 'ENOENT' }); + } }); test('活动架构与部署文档只采用 RocketMQ 5.x', async () => { @@ -130,7 +133,6 @@ test('活动架构与部署文档只采用 RocketMQ 5.x', async () => { 'docs/architecture/system-architecture.md', 'docs/governance/code-development-standards.md', 'docs/deployment/local-development.md', - 'docs/deployment/docker-compose-production.md', 'docs/deployment/ubuntu-source-production.md', ]) { const content = await readRepoFile(relativePath); @@ -153,73 +155,27 @@ test('生产源码部署使用独立 EnvironmentFile 并提供 Nginx 代理', as assert.match(nginx, /\/opt\/a-share-analysis\/current\/web\/dist/); }); -test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async () => { +test('部署文档要求本机外部 Redis 和唯一 Ubuntu 源码生产路径', async () => { const local = await readRepoFile('docs/deployment/local-development.md'); - const compose = await readRepoFile('docs/deployment/docker-compose-production.md'); const source = await readRepoFile('docs/deployment/ubuntu-source-production.md'); - assert.match(local, /Redis/); - assert.match(compose, /Docker Compose/); + assert.match(local, /redis-cli -h 127\.0\.0\.1 -p 6379 ping/); + assert.match(local, /外部服务|开发机/); + assert.doesNotMatch(local, /docker compose/i); assert.match(source, /Ubuntu 22\.04 LTS/); assert.match(source, /Ubuntu 24\.04 LTS/); - assert.match(source, /Node\.js 22 LTS/); - assert.match(source, /pnpm 10/); + assert.match(source, /Node\.js 22\.x/); + assert.match(source, /pnpm 10\.25\.0/); + assert.match(source, /pnpm verify:release/); + assert.match(source, /\/opt\/a-share-analysis\/releases\//); + assert.match(source, /\/opt\/a-share-analysis\/current/); assert.match(source, /不负责安装/); - for (const document of [local, compose, source]) { + for (const document of [local, source]) { assert.match(document, /MySQL 5\.7\.42/); } }); -test('生产 Compose 只接受显式镜像并解析为真实健康检查', async () => { - const composePath = new URL('deploy/compose/production.yml.example', repoRoot).pathname; - const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'a-share-compose-')); - const envFiles = {}; - for (const processName of processNames) { - const envPath = path.join(temporaryDirectory, `${processName}.env`); - await writeFile(envPath, 'APP_ENV=production\n'); - envFiles[processName] = envPath; - } - - await assert.rejects( - execFileAsync('docker', ['compose', '-f', composePath, 'config'], { - env: { ...process.env }, - }), - /必须提供不可变 (?:Web|Server) 镜像引用/, - ); - - const imageDigest = `sha256:${'a'.repeat(64)}`; - const { stdout } = await execFileAsync( - 'docker', - ['compose', '-f', composePath, 'config', '--format', 'json'], - { - env: { - ...process.env, - A_SHARE_WEB_IMAGE: `registry.invalid/web@${imageDigest}`, - A_SHARE_SERVER_IMAGE: `registry.invalid/server@${imageDigest}`, - A_SHARE_WEB_ENV_FILE: envFiles.web, - A_SHARE_API_ENV_FILE: envFiles.api, - A_SHARE_SCHEDULER_ENV_FILE: envFiles.scheduler, - A_SHARE_WORKER_ENV_FILE: envFiles.worker, - }, - }, - ); - const config = JSON.parse(stdout); - - assert.equal(config.services.web.image, `registry.invalid/web@${imageDigest}`); - assert.equal(config.services.api.image, `registry.invalid/server@${imageDigest}`); - assert.deepEqual(config.services.api.command, ['node', 'dist/apps/api/main.js']); - assert.ok(config.services.web.healthcheck?.test); - assert.ok(config.services.api.healthcheck?.test); -}); - -test('容器构建文件存在且 systemd 启动入口对应真实构建产物', async () => { - await access(new URL('web/Dockerfile', repoRoot)); - await access(new URL('web/nginx.conf', repoRoot)); - await access(new URL('server/Dockerfile', repoRoot)); - await execFileAsync('pnpm', ['--filter', '@a-share-analysis/server', 'build'], { - cwd: repoRoot.pathname, - }); - +test('systemd 启动入口对应源码构建产物', async () => { for (const processName of ['api', 'scheduler', 'worker']) { const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`); const execStart = unit.match( @@ -230,20 +186,11 @@ test('容器构建文件存在且 systemd 启动入口对应真实构建产物', } }); -test('Docker 构建上下文排除本地配置与派生产物', async () => { - const dockerIgnore = await readRepoFile('.dockerignore'); +test('根级发布命令只保留源码发布门禁', async () => { + const rootPackage = JSON.parse(await readRepoFile('package.json')); - for (const requiredPattern of [ - 'deploy/env/**/*.local', - '.git', - '.gitnexus', - 'node_modules', - 'web/dist', - 'server/dist', - ]) { - assert.ok( - dockerIgnore.split(/\r?\n/).includes(requiredPattern), - `.dockerignore 缺少 ${requiredPattern}`, - ); - } + assert.equal(rootPackage.scripts['verify:source'], 'node tools/deployment/verify-source-deployment.mjs'); + assert.equal(rootPackage.scripts['verify:release'], 'pnpm verify && pnpm verify:source'); + assert.equal(rootPackage.scripts['verify:docker'], undefined); + assert.equal(rootPackage.scripts['check:images'], undefined); }); diff --git a/web/Dockerfile b/web/Dockerfile deleted file mode 100644 index 221cb95..0000000 --- a/web/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM node:22.22.0-bookworm-slim AS builder - -ENV PNPM_HOME=/pnpm -ENV PATH=$PNPM_HOME:$PATH -RUN corepack enable && corepack prepare pnpm@10.25.0 --activate - -WORKDIR /workspace -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY web/package.json web/package.json -COPY server/package.json server/package.json -RUN pnpm install --frozen-lockfile --filter @a-share-analysis/web... - -COPY web web -ARG VITE_APP_ENV=production -ARG VITE_APP_TITLE=A-Share-Analysis -ARG VITE_API_BASE_URL= -ARG VITE_ENABLE_DEVTOOLS=false -ENV NODE_ENV=production -ENV VITE_APP_ENV=$VITE_APP_ENV -ENV VITE_APP_TITLE=$VITE_APP_TITLE -ENV VITE_API_BASE_URL=$VITE_API_BASE_URL -ENV VITE_ENABLE_DEVTOOLS=$VITE_ENABLE_DEVTOOLS -RUN pnpm --filter @a-share-analysis/web build - -FROM nginx:1.28.2-alpine3.23 -COPY web/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /workspace/web/dist /usr/share/nginx/html -EXPOSE 80 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 diff --git a/web/nginx.conf b/web/nginx.conf deleted file mode 100644 index d211bca..0000000 --- a/web/nginx.conf +++ /dev/null @@ -1,20 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location /api/ { - proxy_pass http://api:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location / { - try_files $uri $uri/ /index.html; - } -}