@wdyy/skills 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +53 -61
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +92 -88
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +213 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +106 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +168 -147
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +485 -526
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +10 -8
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +598 -416
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +46 -12
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +2 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +30 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +24 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +7 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +17 -18
- package/README.md +66 -10
- package/lib/wdyy-cli.js +35 -9
- package/package.json +1 -1
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"name": "wdyy-deployment-standard",
|
|
10
|
-
"description": "
|
|
11
|
-
"files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/deploy.sh.template", "templates/docker-compose.blue-green.yml", "templates/dockerignore.template", "templates/nginx-upstream.template.conf"]
|
|
10
|
+
"description": "基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务器生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚。Use when 规划、实现、审查或执行 `/xxzx/home/项目目录` 下的手工生产部署时。",
|
|
11
|
+
"files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/generate-deployment-files.mjs", "scripts/generate-deployment-files.test.mjs", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/frontend.Dockerfile.template", "templates/frontend-container.conf.template", "templates/env.example.template", "templates/deploy.sh.template", "templates/docker-compose.blue-green.yml", "templates/dockerignore.template", "templates/nginx-upstream.template.conf"]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"name": "wdyy-api-standard",
|
|
@@ -1,81 +1,73 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wdyy-deployment-standard
|
|
3
|
-
description:
|
|
3
|
+
description: 基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务器生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚。Use when 规划、实现、审查或执行 `/xxzx/home/项目目录` 下的手工生产部署时。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 企业部署规范
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev`;本地用 `./deploy.sh build` 生成前后端双镜像和完整 `deploy/`;工程师人工上传;服务器按根 `.env` 的项目端口执行成对蓝绿切流。
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## 使用边界
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
1. 先读取目标项目真实的 pnpm scripts、前后端构建输出、健康/版本接口、Dockerfile、`.env` 和 Nginx include 方式。
|
|
15
|
+
2. 完整读取 [Linux 部署规则](reference/linux-deployment-rules.md),运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 确定性生成受版本控制文件,并向 `.env` 与 `.env.example` 的唯一“部署”段追加同名空配置;既有文件不一致时先审查差异,只有工程师确认后才使用 `--force`。
|
|
16
|
+
3. 应用 URL 与端口只使用 `FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT`;部署端点使用各自唯一变量。健康和版本 URL 在脚本中由主机、端口及固定路径构造,不在 `.env` 中重复配置。
|
|
17
|
+
4. 工程师人工上传 `deploy/` 全部内容;脚本不得上传、拉取代码、安装依赖、修改共享 Nginx 主配置或替代 GO/NO-GO。
|
|
18
|
+
5. 生成后先运行生成器的 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和全部行为测试。
|
|
15
19
|
|
|
16
|
-
##
|
|
20
|
+
## 固定契约
|
|
17
21
|
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
22
|
+
- 项目名只允许小写字母、数字和下划线,并与服务器目录 `/xxzx/home/<项目名称>` 对应。
|
|
23
|
+
- 两个镜像必须精确命名为 `<项目名称>_frontend:<YYYYMMDD-NNN>` 和 `<项目名称>_backend:<YYYYMMDD-NNN>`。
|
|
24
|
+
- Compose 必须包含 frontend-blue、backend-blue、frontend-green、backend-green;同一颜色的前后端作为一个切流单元。
|
|
25
|
+
- 宿主机 Nginx 只监听根 `.env` 的 `PROJECT_HTTP_PORT`,`/api/` 代理活动后端,其他请求代理活动前端。
|
|
26
|
+
- 所有容器只把项目根 `./logs` 挂载到 `/app/logs`;日志文件直接位于 `logs/`,不得创建服务、颜色或日期子目录。
|
|
27
|
+
- 根 `.env` 原样复制到 `deploy/.env` 并人工上传,但必须被 `.dockerignore` 排除,且不得进入版本目录或任一镜像。
|
|
28
|
+
- 环境文件固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段和已有值,只扩充部署段。
|
|
29
|
+
- `DATABASE_MIGRATION_MODE=manual` 时只打包迁移与执行脚本;`none` 时不要求数据库文件。任何部署命令都不得自动访问数据库。
|
|
30
|
+
- 相同输入生成的 deploy.sh、Compose、Nginx 配置必须字节一致;版本指针、镜像归档和 manifest 可随发布变化。
|
|
21
31
|
|
|
22
|
-
##
|
|
32
|
+
## 工作流
|
|
23
33
|
|
|
24
|
-
1.
|
|
25
|
-
2.
|
|
26
|
-
3.
|
|
27
|
-
4.
|
|
28
|
-
5.
|
|
29
|
-
6.
|
|
30
|
-
7.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
1. 使用 `pnpm dev` 完成开发测试。
|
|
35
|
+
2. 执行不带额外参数的 `./deploy.sh build`;从 `AGENTS.md` 唯一“发布记录”表生成 `YYYYMMDD-NNN`。
|
|
36
|
+
3. 依次通过测试、lint、类型检查、构建、双镜像语义检查、包校验和配置确定性检查;成功后才替换 `deploy/` 并写发布记录。
|
|
37
|
+
4. 人工把 `deploy/` 全部内容上传到 `.env` 声明的 `<SERVER_PROJECTS_ROOT>/<PROJECT_NAME>`;生产值不得通过命令行传入。
|
|
38
|
+
5. 数据库模式为 manual 时,由工程师单独执行版本包中的迁移脚本并确认结果。
|
|
39
|
+
6. 首次部署执行 `./deploy.sh start`;后续新版本执行 `./deploy.sh replace`。
|
|
40
|
+
7. 通过 `restart`、`stop`、`status`、`rollback <version>` 管理当前项目;`remove` 只删除本项目容器、网络及两个精确镜像仓库。
|
|
41
|
+
|
|
42
|
+
## Nginx 多项目隔离
|
|
43
|
+
|
|
44
|
+
- 运维只在服务器初次接入时让共享 Nginx include `/xxzx/home/*/nginx/site.conf`;部署脚本不得编辑该全局配置。
|
|
45
|
+
- 每个项目只写自身 `nginx/site.conf` 和 `nginx/active-upstreams.conf`。
|
|
46
|
+
- upstream 名称由严格项目名确定,不接受自由文本配置。
|
|
47
|
+
- 在共享文件锁内检查其他项目配置和运行中容器的实际发布端口、安装候选配置、运行进程级 `nginx -t` 和 reload;失败时恢复本项目旧文件。
|
|
48
|
+
- 其他项目配置错误可以阻止本次 reload,但不得强制跳过检查或改写其他项目文件。
|
|
34
49
|
|
|
35
50
|
## 禁止事项
|
|
36
51
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
- 不得在无健康检查、版本标识或可演练回滚的情况下发布。
|
|
46
|
-
- 不得在 `deploy.sh` 中执行 SCP、拉取代码、访问制品仓库或静默安装依赖。
|
|
47
|
-
- 不得把“保留旧 release 目录”等同于旧资源可访问;资源 URL 必须包含版本。
|
|
48
|
-
- 不得将日志挂载到项目根目录 `./logs` 之外,或为 blue、green 配置不同日志目录。
|
|
49
|
-
|
|
50
|
-
## Red Flags
|
|
51
|
-
|
|
52
|
-
- `build` 成功前修改 `AGENTS.md`,或失败后仍留下新的可上传目录。
|
|
53
|
-
- 发布包依赖目标项目工作树中未上传的迁移执行器、配置或脚本。
|
|
54
|
-
- `start` 依赖固定 `/srv/<project>`、调用者当前目录或人工版本参数。
|
|
55
|
-
- 发布脚本在新版本通过健康检查前修改 upstream。
|
|
56
|
-
- 前端目录被直接覆盖,或发布计划包含破坏性迁移。
|
|
57
|
-
- Compose 包含 `LOG_DIR`、`HOST_LOG_DIR`、实例日志子目录或非 `../../logs:/app/logs` 的日志挂载。
|
|
52
|
+
- 不得接受人工 build/start/replace 版本参数,不得扫描目录替代发布账本或 `release.env`。
|
|
53
|
+
- 不得使用 Bash `source`/`eval` 解析 `.env` 或状态文件。
|
|
54
|
+
- 不得使用镜像 fallback、固定 `/srv`、固定 URL/IP/端口、PM2、systemd 或自动安装。
|
|
55
|
+
- 不得重新引入 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST` 或任何可推导的 `*_HEALTH_URL`、`*_VERSION_URL`。
|
|
56
|
+
- 不得先停止活动颜色再启动新颜色,不得分别切换前后端,不得在验证前 reload。
|
|
57
|
+
- 不得自动执行 psql、迁移脚本或数据库回滚。
|
|
58
|
+
- 不得让 `remove` 删除其他项目资源、日志、发布文件、状态或 Nginx 配置。
|
|
59
|
+
- 不得以字符串关键字存在代替命令行为、失败恢复和资源边界测试。
|
|
58
60
|
|
|
59
61
|
## Verification
|
|
60
62
|
|
|
61
|
-
- [ ]
|
|
62
|
-
- [ ]
|
|
63
|
-
- [ ]
|
|
64
|
-
- [ ]
|
|
65
|
-
- [ ]
|
|
66
|
-
- [ ]
|
|
67
|
-
- [ ]
|
|
68
|
-
- [ ] `deploy
|
|
69
|
-
- [ ]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
## Common Rationalizations
|
|
74
|
-
|
|
75
|
-
| 合理化说法 | 事实 |
|
|
76
|
-
|---|---|
|
|
77
|
-
| “版本号手工输入更快” | 手工参数可能与上传包不一致,版本必须来自发布账本和 `release.env`。 |
|
|
78
|
-
| “迁移脚本服务器上已经有” | 发布包必须自包含,不能依赖未上传的项目工作树。 |
|
|
79
|
-
| “停几秒没关系” | 发布规范要求后端无先停后启的中断窗口。 |
|
|
80
|
-
| “覆盖 dist 最简单” | 旧页面会引用旧 hash 文件,覆盖会造成资源 404。 |
|
|
81
|
-
| “小字段改动无需兼容” | 滚动与回滚期间新旧版本会并存。 |
|
|
63
|
+
- [ ] 两个镜像名称、标签和版本 label 精确匹配发布版本。
|
|
64
|
+
- [ ] 四个服务均使用根 `.env`,且只挂载 `../../logs:/app/logs`。
|
|
65
|
+
- [ ] `.env` 与 `.env.example` 的五段、变量顺序一致,部署段键相同且无重复 URL/端口配置。
|
|
66
|
+
- [ ] 外部项目端口唯一;Nginx 一次同时切换前后端,失败恢复旧流量和状态。
|
|
67
|
+
- [ ] start、replace、restart、stop、remove、status、rollback 行为与资源范围均通过测试。
|
|
68
|
+
- [ ] manual/none 两种数据库模式均通过测试,部署脚本不存在数据库执行路径。
|
|
69
|
+
- [ ] 连续两次构建的配置文件一致,失败不写发布记录、不暴露半成品。
|
|
70
|
+
- [ ] `deploy/` 含根 `.env`,但版本目录、Docker 上下文和镜像不含秘密文件。
|
|
71
|
+
- [ ] 替换或删除容器后,项目根 `logs/` 内容仍保留。
|
|
72
|
+
|
|
73
|
+
任一步失败必须明确报告具体阶段并保持或恢复本项目原流量;不得以兜底、静默跳过、强制切流或删除失败证据规避问题。
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
|
-
display_name: "
|
|
3
|
-
short_description: "Build
|
|
4
|
-
default_prompt: "Use $wdyy-deployment-standard to
|
|
2
|
+
display_name: "wdyy-deployment-standard"
|
|
3
|
+
short_description: "Build isolated dual-image manual deployment packages"
|
|
4
|
+
default_prompt: "Use $wdyy-deployment-standard to generate and validate an isolated dual-image manual deployment workflow."
|
|
@@ -1,131 +1,135 @@
|
|
|
1
1
|
# Linux 部署规则
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 目录和受控源文件
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
目标项目必须维护:
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
deploy.sh
|
|
9
9
|
.dockerignore
|
|
10
|
+
src/frontend/Dockerfile
|
|
10
11
|
src/backend/Dockerfile
|
|
11
|
-
scripts/apply-migrations.sh
|
|
12
12
|
scripts/deployment/docker-compose.blue-green.yml
|
|
13
|
+
scripts/deployment/frontend-container.conf.template
|
|
13
14
|
scripts/deployment/nginx-site.conf
|
|
15
|
+
.env.example
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
- `deploy.sh
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
这些文件必须由 `scripts/generate-deployment-files.mjs --target <project-root> --write` 按固定映射原子生成,templates 是唯一源。既有差异默认失败,不得静默覆盖;工程师审查并确认后才允许 `--force`。生成后执行 `--check`。build 必须原样复制 deploy.sh、Compose 和 Nginx 模板,不得根据运行顺序产生随机内容;相同输入连续构建时这些配置的 SHA-256 必须一致。
|
|
19
|
+
|
|
20
|
+
## 根 .env
|
|
21
|
+
|
|
22
|
+
根 `.env` 是本地 build、上传包和服务器 Compose 的唯一配置文件。`.env` 与 `.env.example` 固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段与已有值,只向两个文件的部署段追加同名空键。`.env.example` 必须直接采用 `templates/env.example.template` 的键、顺序与非敏感默认值。脚本必须按数据解析,不得 `source` 或 `eval`。部署前根 `.env` 至少要求:
|
|
23
|
+
|
|
24
|
+
```dotenv
|
|
25
|
+
FRONTEND_URL=0.0.0.0
|
|
26
|
+
FRONTEND_PORT=5173
|
|
27
|
+
BACKEND_URL=0.0.0.0
|
|
28
|
+
BACKEND_PORT=3000
|
|
29
|
+
DB_URL=
|
|
30
|
+
DB_PORT=5432
|
|
31
|
+
DB_USER=
|
|
32
|
+
DB_PASSWORD=
|
|
33
|
+
DB_NAME=
|
|
34
|
+
DB_SCHEMA=
|
|
35
|
+
PROJECT_NAME=example_project
|
|
36
|
+
SERVER_PROJECTS_ROOT=/xxzx/home
|
|
37
|
+
PROJECT_HTTP_PORT=9099
|
|
38
|
+
DOCKER_BIND_IP=127.0.0.1
|
|
39
|
+
FRONTEND_BLUE_PORT=19091
|
|
40
|
+
FRONTEND_GREEN_PORT=19092
|
|
41
|
+
BACKEND_BLUE_PORT=19093
|
|
42
|
+
BACKEND_GREEN_PORT=19094
|
|
43
|
+
FRONTEND_BASE_IMAGE=nginx:1.29-alpine@sha256:<confirmed-digest>
|
|
44
|
+
BACKEND_BASE_IMAGE=node:24-bookworm-slim@sha256:<confirmed-digest>
|
|
45
|
+
DATABASE_MIGRATION_MODE=none
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT` 是应用监听的唯一来源;容器、蓝绿和项目入口的健康/版本 URL 必须由对应主机、端口与固定路径确定性构造。不得增加 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST`、`*_HEALTH_URL` 或 `*_VERSION_URL`。通配监听地址 `0.0.0.0` 仅在主动本机探测时转换为 `127.0.0.1`。
|
|
49
|
+
|
|
50
|
+
部署示例值只说明字段,生成目标项目后必须由工程师确认 `.env` 实际值,不得保留占位符或示例部署端口。`PROJECT_NAME` 只允许 `^[a-z][a-z0-9_]*$`。所有宿主机端口必须互不相同且在 1–65535;基础镜像必须使用 digest 固定。数据库连接键允许为空,但项目使用数据库时必须填写真实值。
|
|
51
|
+
|
|
52
|
+
`.env` 权限必须为 `0600` 或只读的 `0400`。部署脚本只把明确的部署键载入自身环境;数据库密码、JWT 等其他应用键保留在文件中供 Compose 注入容器,不得改变脚本的 `PATH`、Shell 选项或命令行为。
|
|
53
|
+
|
|
54
|
+
可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`MIGRATIONS_SOURCE`、`MIGRATION_RUNNER_SOURCE`、`DEPLOY_CONFIG_DIR`;未设置时只能使用与目标项目真实结构一致且已验证的标准路径。
|
|
23
55
|
|
|
24
|
-
##
|
|
56
|
+
## Docker 与日志
|
|
25
57
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
- 前端镜像:`${PROJECT_NAME}_frontend:${RELEASE_VERSION}`。
|
|
59
|
+
- 后端镜像:`${PROJECT_NAME}_backend:${RELEASE_VERSION}`。
|
|
60
|
+
- 四个服务由同一个 `docker compose --project-name "$PROJECT_NAME"` 管理,不设置 `container_name`。
|
|
61
|
+
- 四个服务都使用 `../../.env`,复用 `FRONTEND_PORT`、`BACKEND_PORT`、`FRONTEND_URL`、`BACKEND_URL`,并且只有 `../../logs:/app/logs` 一个日志挂载。
|
|
62
|
+
- 前端 Nginx 容器的 access/error 文件直接写入 `/app/logs/frontend-<color>-*.log`;不得创建子目录。
|
|
63
|
+
- 后端必须把自身日志直接写入 `/app/logs`,不得依赖容器可写层保存持久日志。
|
|
64
|
+
- `.dockerignore` 必须排除 `.env`、`.env.*`、密钥、凭据、deploy、logs、Git、依赖缓存和虚拟环境。
|
|
31
65
|
|
|
32
|
-
##
|
|
66
|
+
## 发布账本与本地包
|
|
33
67
|
|
|
34
|
-
`AGENTS.md`
|
|
68
|
+
`AGENTS.md` 只允许一个固定两列表:
|
|
35
69
|
|
|
36
70
|
```markdown
|
|
37
71
|
## 发布记录
|
|
38
72
|
|
|
39
73
|
| 版本号 | 构建时间 |
|
|
40
74
|
|---|---|
|
|
41
|
-
|
|
|
75
|
+
| 20260808-001 | 2026-08-08 09:30:00 +08:00 |
|
|
42
76
|
```
|
|
43
77
|
|
|
44
|
-
build
|
|
45
|
-
|
|
46
|
-
1. 要求项目根目录存在可读写的 `AGENTS.md`。
|
|
47
|
-
2. 要求最多存在一个标题完全等于 `## 发布记录` 的章节。
|
|
48
|
-
3. 严格验证表头、分隔行以及所有 `YYYYMMDD-NNN` 和构建时间记录;版本不得重复,序号必须为 `001` 至 `999`。
|
|
49
|
-
4. 读取本地当前日期,取当天最大序号加一;当天无记录时使用 `001`。
|
|
50
|
-
5. 在全部发布门禁和包校验成功后才原子写入版本号与构建时间。
|
|
51
|
-
6. 章节不存在时,在首次成功 build 后追加固定章节和表格。
|
|
52
|
-
7. 账本损坏、章节重复、版本重复或当天已到 `999` 时明确失败。
|
|
53
|
-
|
|
54
|
-
不得接受人工 build 版本参数,不得扫描 `deploy/` 推导序号。构建人员必须先同步版本控制中的发布记录;本规范不提供跨未同步工作区的中央编号服务。
|
|
55
|
-
|
|
56
|
-
## 本地 deploy 目录
|
|
57
|
-
|
|
58
|
-
每次 build 先在项目根目录外显路径之外生成临时包,全部校验通过后重新生成只含本次版本的 `deploy/`:
|
|
78
|
+
build 使用项目级原子锁,严格验证账本后生成当天下一序号。全部门禁、双镜像、包结构和确定性校验通过后才写账本。失败必须保留原 deploy 和原账本。
|
|
59
79
|
|
|
60
80
|
```text
|
|
61
81
|
deploy/
|
|
82
|
+
├── .env
|
|
62
83
|
├── deploy.sh
|
|
63
84
|
├── release.env
|
|
64
85
|
└── YYYYMMDD-NNN/
|
|
65
|
-
├── frontend.tar
|
|
86
|
+
├── frontend-image.tar
|
|
66
87
|
├── backend-image.tar
|
|
67
88
|
├── manifest.sha256
|
|
68
|
-
├── database/migrations/
|
|
69
|
-
├── scripts/apply-migrations.sh
|
|
70
89
|
├── docker/docker-compose.blue-green.yml
|
|
71
|
-
|
|
90
|
+
├── nginx/site.conf
|
|
91
|
+
└── database/ # 仅 manual 模式
|
|
92
|
+
├── migrations/
|
|
93
|
+
└── apply-migrations.sh
|
|
72
94
|
```
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
- `manifest.sha256` 覆盖版本目录中除清单本身外的全部文件。
|
|
76
|
-
- `deploy/` 只包含本次版本,不承担历史账本职责。
|
|
77
|
-
- `.env`、`.env.production`、访问令牌、私钥、证书、密码和其他生产秘密不得进入 `deploy/`。
|
|
78
|
-
- build 必须在 Docker 构建前验证项目根目录 `.dockerignore` 的固定秘密与生成目录排除项,防止秘密进入 `backend-image.tar`。
|
|
79
|
-
- 任一步失败必须清理本次临时输出,不得修改发布记录或留下半成品。
|
|
96
|
+
`release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件,禁止符号链接、硬链接秘密、绝对路径和 `..`。根 `.env` 可上传,但版本目录与镜像不得包含它。
|
|
80
97
|
|
|
81
|
-
##
|
|
98
|
+
## 服务器部署和 Nginx
|
|
82
99
|
|
|
83
|
-
|
|
100
|
+
上传目标必须是 `${SERVER_PROJECTS_ROOT}/${PROJECT_NAME}`,生产值为 `/xxzx/home/<项目名称>`。脚本通过自身物理路径验证该关系,不依赖调用者当前目录。
|
|
84
101
|
|
|
85
|
-
|
|
102
|
+
共享 Nginx 主配置由运维预先 include `/xxzx/home/*/nginx/site.conf`。项目脚本不得写 `/etc/nginx` 或其他项目,只维护:
|
|
86
103
|
|
|
87
|
-
|
|
104
|
+
```text
|
|
105
|
+
<project-root>/nginx/site.conf
|
|
106
|
+
<project-root>/nginx/active-upstreams.conf
|
|
107
|
+
```
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
site 监听 `PROJECT_HTTP_PORT`,并带项目名、外部端口及四个 Docker 宿主机端口标记。首次部署和端口变更必须在共享锁内检查展开的 Nginx 配置、其他标准项目 site 以及运行中容器的实际发布端口;任一外部端口或 blue/green 宿主机端口重复、其他项目缺少端口标记或不能证明唯一时失败。候选 site 与前后端 upstream 一起安装,`nginx -t` 或 reload 失败时恢复旧文件。
|
|
90
110
|
|
|
91
|
-
|
|
111
|
+
## 命令
|
|
92
112
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
- `./deploy.sh start`:只允许无活动状态的首次 blue 部署。
|
|
114
|
+
- `./deploy.sh replace`:只允许已有活动状态且 release.env 指向不同新版本。
|
|
115
|
+
- `./deploy.sh restart`:重新创建并验证当前颜色的两个容器,不改变流量颜色和版本。
|
|
116
|
+
- `./deploy.sh stop`:停止当前颜色的两个容器,保留所有文件和状态。
|
|
117
|
+
- `./deploy.sh remove`:删除 Compose project 下四个容器及网络,并删除两个精确项目镜像仓库的全部标签;保留日志、包、状态和 Nginx 文件。
|
|
118
|
+
- `./deploy.sh status`:报告 active/previous 和四服务状态。
|
|
119
|
+
- `./deploy.sh rollback <version>`:只允许 previous 中已保留且健康的成对容器。
|
|
120
|
+
|
|
121
|
+
除 rollback 的版本外,命令不得接受额外参数。
|
|
122
|
+
|
|
123
|
+
## 数据库
|
|
124
|
+
|
|
125
|
+
- `DATABASE_MIGRATION_MODE=none`:版本包不要求数据库目录。
|
|
126
|
+
- `DATABASE_MIGRATION_MODE=manual`:必须打包完整迁移目录和可执行脚本。
|
|
127
|
+
- deploy.sh 的任何服务器命令都不得调用迁移脚本、psql 或数据库客户端。
|
|
128
|
+
- build 完成和服务器 start/replace 必须明确提示 manual 操作尚需工程师单独执行,不得声称已迁移。
|
|
129
|
+
|
|
130
|
+
## 失败边界
|
|
107
131
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
5. 安全解压前端到不可变 `releases/<version>`;确认 `index.html` 与版本化资源路径后才允许切换 `current`。
|
|
113
|
-
6. 加载精确标签的后端镜像,使用版本包内的迁移执行器执行尚未执行的向前兼容迁移。
|
|
114
|
-
7. 在非活动颜色启动新容器;健康检查和版本接口返回目标版本后才切换 Nginx upstream,再原子切换前端 `current`。
|
|
115
|
-
8. 保留旧颜色和旧前端 release 到观测窗口结束,默认保留最近五个版本。
|
|
116
|
-
9. `rollback <version>` 只允许切回已保留、健康且数据库兼容的上一版本。
|
|
117
|
-
|
|
118
|
-
## 路径与日志
|
|
119
|
-
|
|
120
|
-
- 版本 Compose 位于 `<deploy-root>/<version>/docker`。
|
|
121
|
-
- blue 与 green 共同使用 `../../logs:/app/logs`,使宿主机部署根目录 `./logs` 对应容器 `/app/logs`。
|
|
122
|
-
- 不得设置 `LOG_DIR`、`HOST_LOG_DIR`,不得创建服务、实例、日期、颜色或其他日志子目录。
|
|
123
|
-
- Nginx 包内模板使用明确占位符;start 按脚本物理路径渲染到 `<deploy-root>/nginx/site.conf`。
|
|
124
|
-
- Nginx 主配置必须由工程师预先包含或链接该 site 文件,并授予脚本执行 `nginx -t` 与 reload 的权限。
|
|
125
|
-
|
|
126
|
-
## 失败与回滚
|
|
127
|
-
|
|
128
|
-
- 包校验、生产配置、解压、镜像、迁移、容器启动、健康检查、版本检查、Nginx 校验或 reload 任一步失败时,保持当前 upstream 和前端 `current`。
|
|
129
|
-
- Nginx 切换失败时恢复原 upstream 内容,再验证并 reload;必须明确报告失败。
|
|
130
|
-
- 不得强制切流、先停止旧容器、删除旧版本、静默安装工具或吞掉错误。
|
|
131
|
-
- `stop`、`restart`、`status` 和 `rollback` 必须从持久 active/previous 状态解析对应版本目录,不得依赖上传者重复输入当前版本。
|
|
132
|
+
- 新颜色前后端全部健康且后端版本正确前,不得修改 Nginx 或活动状态。
|
|
133
|
+
- Nginx candidate、全局校验、reload 或状态提交失败时,必须恢复本项目旧配置和旧状态。
|
|
134
|
+
- 不得停止旧颜色掩盖新颜色失败;不得修改、停止或删除其他项目任何资源。
|
|
135
|
+
- `remove` 不得删除 logs、上传版本目录、release.env、状态文件或 Nginx 配置。
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const templatesRoot = join(skillRoot, 'templates');
|
|
8
|
+
const mappings = [
|
|
9
|
+
['deploy.sh.template', 'deploy.sh', 0o755],
|
|
10
|
+
['dockerignore.template', '.dockerignore', 0o644],
|
|
11
|
+
['frontend.Dockerfile.template', 'src/frontend/Dockerfile', 0o644],
|
|
12
|
+
['Dockerfile.template', 'src/backend/Dockerfile', 0o644],
|
|
13
|
+
['docker-compose.blue-green.yml', 'scripts/deployment/docker-compose.blue-green.yml', 0o644],
|
|
14
|
+
['frontend-container.conf.template', 'scripts/deployment/frontend-container.conf.template', 0o644],
|
|
15
|
+
['nginx-upstream.template.conf', 'scripts/deployment/nginx-site.conf', 0o644],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const sectionNames = ['前端', '后端', '数据库', 'API', '部署'];
|
|
19
|
+
const baseKeys = new Map([
|
|
20
|
+
['前端', ['FRONTEND_URL', 'FRONTEND_PORT']],
|
|
21
|
+
['后端', ['BACKEND_URL', 'BACKEND_PORT']],
|
|
22
|
+
['数据库', ['DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA']],
|
|
23
|
+
]);
|
|
24
|
+
const deploymentKeys = [
|
|
25
|
+
'PROJECT_NAME', 'SERVER_PROJECTS_ROOT', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
|
|
26
|
+
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
27
|
+
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE', 'DATABASE_MIGRATION_MODE',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults, exampleValues) {
|
|
31
|
+
const normalized = text.replaceAll('\r\n', '\n');
|
|
32
|
+
const sections = new Map(sectionNames.map((name) => [name, []]));
|
|
33
|
+
const encounteredSections = [];
|
|
34
|
+
const seenKeys = new Set();
|
|
35
|
+
let currentSection;
|
|
36
|
+
|
|
37
|
+
for (const [index, line] of normalized.split('\n').entries()) {
|
|
38
|
+
if (!line) continue;
|
|
39
|
+
const heading = line.match(/^# (前端|后端|数据库|API|部署)$/);
|
|
40
|
+
if (heading) {
|
|
41
|
+
currentSection = heading[1];
|
|
42
|
+
encounteredSections.push(currentSection);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (line.startsWith('#')) continue;
|
|
46
|
+
if (!currentSection) throw new Error(`${relativeTarget}:${index + 1} must be inside a standard section`);
|
|
47
|
+
const entry = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
48
|
+
if (!entry) throw new Error(`${relativeTarget}:${index + 1} must use KEY=VALUE syntax`);
|
|
49
|
+
if (seenKeys.has(entry[1])) throw new Error(`${relativeTarget} contains duplicate key: ${entry[1]}`);
|
|
50
|
+
if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(entry[1]) || /_(?:HEALTH|VERSION)_URL$/.test(entry[1])) {
|
|
51
|
+
throw new Error(`${relativeTarget} contains legacy duplicate configuration: ${entry[1]}`);
|
|
52
|
+
}
|
|
53
|
+
seenKeys.add(entry[1]);
|
|
54
|
+
sections.get(currentSection).push({ key: entry[1], value: entry[2] });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (encounteredSections.join('|') !== sectionNames.join('|')) {
|
|
58
|
+
throw new Error(`${relativeTarget} section order must be: ${sectionNames.map((name) => `# ${name}`).join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
for (const [section, expectedKeys] of baseKeys) {
|
|
61
|
+
const entries = sections.get(section);
|
|
62
|
+
const actualKeys = entries.map(({ key }) => key);
|
|
63
|
+
if (actualKeys.join('|') !== expectedKeys.join('|')) {
|
|
64
|
+
throw new Error(`${relativeTarget} # ${section} keys must be: ${expectedKeys.join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
if (requireExampleDefaults) {
|
|
67
|
+
for (const { key, value } of entries) {
|
|
68
|
+
if (value !== exampleValues.get(key)) throw new Error(`${relativeTarget} must keep the standard default for ${key}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const actualDeploymentKeys = sections.get('部署').map(({ key }) => key);
|
|
73
|
+
if (actualDeploymentKeys.length !== 0 && actualDeploymentKeys.join('|') !== deploymentKeys.join('|')) {
|
|
74
|
+
throw new Error(`${relativeTarget} # 部署 keys must be empty or exactly: ${deploymentKeys.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
if (requireExampleDefaults && sections.get('部署').some(({ value }) => value !== '')) {
|
|
77
|
+
throw new Error(`${relativeTarget} deployment example values must be empty`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
extended: actualDeploymentKeys.length === deploymentKeys.length,
|
|
81
|
+
nonDeploymentKeys: [...seenKeys].filter((key) => !deploymentKeys.includes(key)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extendDeploymentSection(text) {
|
|
86
|
+
return `${text.trimEnd()}\n${deploymentKeys.map((key) => `${key}=`).join('\n')}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const args = process.argv.slice(2);
|
|
90
|
+
let mode;
|
|
91
|
+
let force = false;
|
|
92
|
+
let targetArgument;
|
|
93
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
94
|
+
const argument = args[index];
|
|
95
|
+
if (argument === '--target') {
|
|
96
|
+
if (targetArgument !== undefined || !args[index + 1] || args[index + 1].startsWith('--')) {
|
|
97
|
+
throw new Error('Duplicate or missing --target argument');
|
|
98
|
+
}
|
|
99
|
+
targetArgument = args[index + 1];
|
|
100
|
+
index += 1;
|
|
101
|
+
} else if (argument === '--write' || argument === '--check') {
|
|
102
|
+
if (mode !== undefined) throw new Error('Exactly one of --write or --check is required');
|
|
103
|
+
mode = argument.slice(2);
|
|
104
|
+
} else if (argument === '--force') {
|
|
105
|
+
if (force) throw new Error('Duplicate --force argument');
|
|
106
|
+
force = true;
|
|
107
|
+
} else {
|
|
108
|
+
throw new Error(`Unknown argument: ${argument}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!mode || targetArgument === undefined || (force && mode !== 'write')) {
|
|
112
|
+
throw new Error('Usage: generate-deployment-files.mjs --target <project-root> (--write [--force] | --check)');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const targetRoot = resolve(targetArgument);
|
|
116
|
+
const targetStat = await stat(targetRoot);
|
|
117
|
+
if (!targetStat.isDirectory()) throw new Error(`Target project root is not a directory: ${targetRoot}`);
|
|
118
|
+
|
|
119
|
+
const environmentTemplate = await readFile(join(templatesRoot, 'env.example.template'), 'utf8');
|
|
120
|
+
const exampleValues = new Map(
|
|
121
|
+
[...environmentTemplate.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/gm)].map((match) => [match[1], match[2]]),
|
|
122
|
+
);
|
|
123
|
+
const environmentPlans = [];
|
|
124
|
+
for (const [relativeTarget, expectedMode, requireExampleDefaults] of [['.env', 0o600, false], ['.env.example', 0o644, true]]) {
|
|
125
|
+
const target = join(targetRoot, relativeTarget);
|
|
126
|
+
const targetInfo = await lstat(target);
|
|
127
|
+
if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error(`Environment target must be a regular file: ${relativeTarget}`);
|
|
128
|
+
const existing = await readFile(target, 'utf8');
|
|
129
|
+
const structure = parseEnvironmentStructure(existing, relativeTarget, requireExampleDefaults, exampleValues);
|
|
130
|
+
environmentPlans.push({
|
|
131
|
+
target,
|
|
132
|
+
relativeTarget,
|
|
133
|
+
expectedMode,
|
|
134
|
+
existing,
|
|
135
|
+
nonDeploymentKeys: structure.nonDeploymentKeys,
|
|
136
|
+
expected: structure.extended ? existing : extendDeploymentSection(existing),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (environmentPlans[0].nonDeploymentKeys.join('|') !== environmentPlans[1].nonDeploymentKeys.join('|')) {
|
|
140
|
+
throw new Error('.env and .env.example variable names must remain consistent');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const differences = [];
|
|
144
|
+
for (const [templateName, relativeTarget, expectedMode] of mappings) {
|
|
145
|
+
const source = join(templatesRoot, templateName);
|
|
146
|
+
const target = join(targetRoot, relativeTarget);
|
|
147
|
+
const expected = await readFile(source);
|
|
148
|
+
let existing;
|
|
149
|
+
try {
|
|
150
|
+
const targetInfo = await lstat(target);
|
|
151
|
+
if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error(`Generated target must be a regular file: ${relativeTarget}`);
|
|
152
|
+
existing = await readFile(target);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error.code !== 'ENOENT') throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const differs = !existing || !existing.equals(expected);
|
|
158
|
+
if (mode === 'check') {
|
|
159
|
+
if (differs) differences.push(relativeTarget);
|
|
160
|
+
if (!differs && relativeTarget === 'deploy.sh') {
|
|
161
|
+
const info = await stat(target);
|
|
162
|
+
if ((info.mode & 0o111) === 0) differences.push(`${relativeTarget} (not executable)`);
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (differs && existing && !force) {
|
|
167
|
+
differences.push(relativeTarget);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (differs) {
|
|
171
|
+
await mkdir(dirname(target), { recursive: true });
|
|
172
|
+
const temporary = `${target}.wdyy-${process.pid}.tmp`;
|
|
173
|
+
try {
|
|
174
|
+
await writeFile(temporary, expected, { flag: 'wx', mode: expectedMode });
|
|
175
|
+
await chmod(temporary, expectedMode);
|
|
176
|
+
await rename(temporary, target);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
await unlink(temporary).catch(() => {});
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
await chmod(target, expectedMode);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (mode === 'write' && differences.length) {
|
|
187
|
+
throw new Error(`Refusing to overwrite differing deployment files without --force: ${differences.join(', ')}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const plan of environmentPlans) {
|
|
191
|
+
if (plan.existing !== plan.expected) {
|
|
192
|
+
if (mode === 'check') differences.push(plan.relativeTarget);
|
|
193
|
+
else {
|
|
194
|
+
const temporary = `${plan.target}.wdyy-${process.pid}.tmp`;
|
|
195
|
+
try {
|
|
196
|
+
await writeFile(temporary, plan.expected, { flag: 'wx', mode: plan.expectedMode });
|
|
197
|
+
await chmod(temporary, plan.expectedMode);
|
|
198
|
+
await rename(temporary, plan.target);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await unlink(temporary).catch(() => {});
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else if (mode === 'write') {
|
|
205
|
+
await chmod(plan.target, plan.expectedMode);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (differences.length) {
|
|
210
|
+
const action = mode === 'check' ? 'Generated deployment files differ' : 'Refusing to overwrite differing deployment files without --force';
|
|
211
|
+
throw new Error(`${action}: ${differences.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write(`${mode === 'write' ? 'generated' : 'verified'} deterministic deployment files in ${targetRoot}\n`);
|