@wdyy/skills 0.1.13 → 0.1.14
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 +1 -1
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +9 -3
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +15 -8
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +27 -4
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +39 -1
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +19 -3
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +139 -2
- package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +4 -5
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +31 -6
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +8 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +3 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +9 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +3 -0
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"name": "wdyy-deployment-standard",
|
|
10
|
-
"description": "为无 CI/CD
|
|
10
|
+
"description": "为无 CI/CD 的前后端项目生成并验证目标平台确定的统一 Docker 交付:在任意构建机上使用本地已有的目标架构基础镜像,构建 linux/amd64 或 linux/arm64 前后端镜像,分别导出后封装为一个压缩包,并在平台匹配的 Linux Docker Server 上无参数部署整个项目栈。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。",
|
|
11
11
|
"files": ["SKILL.md", "agents/openai.yaml", "reference/docker-delivery-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/backend.Dockerfile.template", "templates/frontend.Dockerfile.template", "templates/frontend-container.conf.template", "templates/env.example.template", "templates/deploy.sh.template", "templates/docker-compose.yml", "templates/dockerignore.template"]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wdyy-deployment-standard
|
|
3
|
-
description: 为无 CI/CD
|
|
3
|
+
description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的统一 Docker 交付:在任意构建机上使用本地已有的目标架构基础镜像,构建 linux/amd64 或 linux/arm64 前后端镜像,分别导出后封装为一个压缩包,并在平台匹配的 Linux Docker Server 上无参数部署整个项目栈。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 统一 Docker 交付规范
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
把一个项目作为一个整体交付:在任意支持目标架构的 Docker 构建机上执行 `./deploy.sh build`,按 `DOCKER_PLATFORM` 生成前端、后端两个单架构镜像并分别导出,最终只产出一个压缩包;工程师人工上传并解压;平台匹配的 Linux Docker Server 无参数执行 `./deploy.sh`,一次性加载并部署整个前后端项目栈。
|
|
11
11
|
|
|
12
12
|
## Workflow
|
|
13
13
|
|
|
@@ -21,11 +21,14 @@ description: 为无 CI/CD 的前后端项目生成并验证统一 Docker 交付
|
|
|
21
21
|
## Fixed Contract
|
|
22
22
|
|
|
23
23
|
- 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest` 两个独立镜像。
|
|
24
|
+
- 每个交付包只对应 `DOCKER_PLATFORM` 指定的 `linux/amd64` 或 `linux/arm64`;构建机自身平台可以不同,不得回退到构建机原生架构。
|
|
24
25
|
- 分别导出 `frontend-image.tar`、`backend-image.tar`,但只交付一个 `<PROJECT_NAME>-docker.tar.gz`。
|
|
25
26
|
- 构建前检查 `FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE` 已存在于本地;缺失即失败,不执行 `docker pull`。
|
|
27
|
+
- 服务器加载镜像前读取 Docker Server OS/架构;无法读取、不是 Linux 或不等于 `DOCKER_PLATFORM` 时立即失败。
|
|
26
28
|
- Compose 只定义 frontend、backend;数据库通过 `.env` 直连远程实例,不生成数据库容器、数据卷或迁移路径。
|
|
27
29
|
- 服务器无参数命令整体部署;只额外允许 `stop` 和 `status`,不得独立操作单一服务。
|
|
28
30
|
- 前后端直接暴露 `.env` 指定的宿主机端口;两个容器共同挂载项目根 `./logs` 到 `/app/logs`。
|
|
31
|
+
- frontend Nginx 使用 `.env` 的 `VITE_API_BASE_URL` 与 `BACKEND_PORT`,把同源 API 请求代理到 Compose `backend` 服务;`VITE_API_BASE_URL` 必须等于 `/${API_PREFIX}`。
|
|
29
32
|
- `.env` 可以进入人工上传的压缩包,但必须被 `.dockerignore` 排除,不得进入镜像。
|
|
30
33
|
|
|
31
34
|
## Prohibited
|
|
@@ -40,8 +43,11 @@ description: 为无 CI/CD 的前后端项目生成并验证统一 Docker 交付
|
|
|
40
43
|
- [ ] deploy 目录只有一个 `<PROJECT_NAME>-docker.tar.gz`。
|
|
41
44
|
- [ ] 压缩包解压后只含 `.env`、deploy.sh、docker-compose.yml、两个镜像 tar 和 manifest.sha256。
|
|
42
45
|
- [ ] 两个镜像归档各自只含预期的 `latest` 标签。
|
|
43
|
-
- [ ]
|
|
46
|
+
- [ ] 单一压缩包不含 macOS AppleDouble、资源叉或扩展属性元数据。
|
|
47
|
+
- [ ] 构建与服务器 Compose 均禁止 pull,服务器禁止 build;基础镜像、构建参数、产出镜像和 Compose 平台均与 `DOCKER_PLATFORM` 一致。
|
|
48
|
+
- [ ] 服务器 Docker Engine 平台在加载镜像前通过校验,并严格等于 `DOCKER_PLATFORM`。
|
|
44
49
|
- [ ] Compose 只含 frontend、backend,并使用远程数据库环境变量和根 logs。
|
|
50
|
+
- [ ] frontend 镜像不包含 `.env`,同源 API 代理能够把真实页面查询转发到 backend。
|
|
45
51
|
- [ ] 无参数部署、stop、status 的实际行为及失败传播通过测试。
|
|
46
52
|
|
|
47
53
|
任一步失败必须指出具体阶段并返回非零;不得静默跳过、伪造成功或删除上一份成功交付包。
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "wdyy-deployment-standard"
|
|
3
|
-
short_description: "
|
|
4
|
-
default_prompt: "Use $wdyy-deployment-standard to build
|
|
3
|
+
short_description: "跨构建机生成指定 Linux 架构的双镜像并一键整栈部署"
|
|
4
|
+
default_prompt: "Use $wdyy-deployment-standard to build a single-target linux/amd64 or linux/arm64 dual-image package and verify the Docker Server platform before deploying the full stack."
|
|
@@ -31,8 +31,11 @@ DB_USER=app_user
|
|
|
31
31
|
DB_PASSWORD=change_me
|
|
32
32
|
DB_NAME=app
|
|
33
33
|
DB_SCHEMA=public
|
|
34
|
+
API_PREFIX=api
|
|
35
|
+
VITE_API_BASE_URL=/api
|
|
34
36
|
PROJECT_NAME=example_project
|
|
35
37
|
DOCKER_BIND_IP=0.0.0.0
|
|
38
|
+
DOCKER_PLATFORM=linux/amd64
|
|
36
39
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
37
40
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
38
41
|
```
|
|
@@ -42,21 +45,23 @@ BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
|
42
45
|
- `.env` 权限必须为 `0600` 或只读 `0400`。
|
|
43
46
|
- 按数据解析 `.env`;不得 `source`、`eval` 或执行其中内容。
|
|
44
47
|
- `DB_URL` 指向远程数据库主机或地址;部署资产不得把它解释为 Docker 服务名并创建数据库容器。
|
|
48
|
+
- `DOCKER_PLATFORM` 必须为 `linux/amd64` 或 `linux/arm64`,表示当前单一交付包的目标平台,并与目标 Docker Server 平台一致;不得根据构建机平台自动改写。
|
|
49
|
+
- `API_PREFIX` 只允许一个安全路径段,`VITE_API_BASE_URL` 必须严格等于 `/${API_PREFIX}`;生成器在两个键都缺失时加入标准值,存在缺失或冲突时明确失败。
|
|
45
50
|
- `.env.example` 可保留空数据库凭据,但真实 `.env` 构建时必须完整。
|
|
46
51
|
|
|
47
52
|
## 3. 本地镜像与构建
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
基础镜像引用允许使用标签,但目标架构版本必须在构建机本地预先存在。构建机可以是 macOS、Windows 或 Linux,也可以与目标架构不同;相同标签被人工替换后可能对应不同内容,构建过程不得访问镜像仓库或回退到构建机原生架构。
|
|
50
55
|
|
|
51
56
|
`./deploy.sh build` 必须按顺序:
|
|
52
57
|
|
|
53
58
|
1. 验证根 `.env`、Dockerfile、Compose 和 `.dockerignore`。
|
|
54
|
-
2.
|
|
55
|
-
3.
|
|
59
|
+
2. 使用平台感知的 `docker image inspect --platform "$DOCKER_PLATFORM"` 确认两个基础镜像的目标架构版本存在。
|
|
60
|
+
3. 校验本地基础镜像平台与 `DOCKER_PLATFORM` 一致,使用 `docker build --pull=false --platform "$DOCKER_PLATFORM"` 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest`,并校验产出镜像平台。
|
|
56
61
|
4. 分别执行 Docker save,生成 `frontend-image.tar`、`backend-image.tar`。
|
|
57
62
|
5. 复制根 `.env`、deploy.sh、docker-compose.yml,生成覆盖全部包内文件的 `manifest.sha256`。
|
|
58
63
|
6. 验证镜像归档各自只含预期标签,验证完整包结构。
|
|
59
|
-
7.
|
|
64
|
+
7. 使用 `COPYFILE_DISABLE=1` 和 `tar --no-xattrs` 排除 macOS AppleDouble、资源叉和扩展属性,生成 `deploy/<PROJECT_NAME>-docker.tar.gz`,成功后原子替换同名旧包。
|
|
60
65
|
|
|
61
66
|
构建失败必须保留上一份成功压缩包,并清理本次临时目录。不得维护发布账本、版本号、版本目录、`release.env` 或半成品目录。
|
|
62
67
|
|
|
@@ -73,13 +78,14 @@ backend-image.tar
|
|
|
73
78
|
manifest.sha256
|
|
74
79
|
```
|
|
75
80
|
|
|
76
|
-
不得包含源代码、Git
|
|
81
|
+
不得包含源代码、Git、依赖缓存、数据库目录、迁移脚本、秘密副本、符号链接、macOS AppleDouble/扩展属性或额外嵌套目录。`.env` 必须保持 owner-only 权限并纳入清单;`.dockerignore` 必须确保 `.env`、秘密、deploy、logs、Git、依赖缓存和虚拟环境不进入镜像构建上下文。
|
|
77
82
|
|
|
78
83
|
## 5. 服务器一键操作
|
|
79
84
|
|
|
80
|
-
工程师人工上传并解压单一压缩包。服务器部署根由解压后的 deploy.sh
|
|
85
|
+
工程师人工上传并解压单一压缩包。服务器部署根由解压后的 deploy.sh 物理路径确定。上传用的 `.tar.gz` 必须放在部署根外,或在解压后移出部署根;执行 `./deploy.sh` 时,部署根只允许保留压缩包契约中的 6 个文件和运行后生成的 `logs/` 目录。
|
|
81
86
|
|
|
82
|
-
- `./deploy.sh`:校验 `.env` 权限和内容、清单、两个镜像 tar
|
|
87
|
+
- `./deploy.sh`:校验 `.env` 权限和内容、清单、两个镜像 tar 及预期标签;通过 `docker version` 读取 Docker Server OS/架构并要求严格等于 `DOCKER_PLATFORM`;校验通过后才加载两个镜像,再使用一次 `docker compose up -d --force-recreate --no-build --pull never --wait frontend backend` 整体部署并等待健康。
|
|
88
|
+
- 加载后必须再次校验两个镜像与 `DOCKER_PLATFORM` 一致;Compose 的两个服务也必须显式声明同一平台。
|
|
83
89
|
- `./deploy.sh stop`:同时停止 frontend、backend,保留容器、镜像、交付文件和日志。
|
|
84
90
|
- `./deploy.sh status`:输出当前 Compose 项目的服务状态,不改变 Docker 状态。
|
|
85
91
|
|
|
@@ -90,6 +96,7 @@ manifest.sha256
|
|
|
90
96
|
- Compose 只允许 frontend、backend 两个服务,不设置 `container_name`。
|
|
91
97
|
- 镜像固定为 `${PROJECT_NAME}_frontend:latest`、`${PROJECT_NAME}_backend:latest`,不得提供 fallback。
|
|
92
98
|
- frontend 映射 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 同理。
|
|
99
|
+
- frontend Nginx 把 `${VITE_API_BASE_URL}/...` 原样代理到 `http://backend:${BACKEND_PORT}`;不得硬编码服务器地址或后端端口。
|
|
93
100
|
- 两个服务均使用根 `.env`,并挂载 `./logs:/app/logs`。
|
|
94
101
|
- backend 直接读取 DB_URL、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME、DB_SCHEMA。
|
|
95
102
|
- Compose 不得定义数据库服务、数据库镜像、数据库 volume、数据库端口映射或迁移任务。
|
|
@@ -97,7 +104,7 @@ manifest.sha256
|
|
|
97
104
|
|
|
98
105
|
## 7. 失败边界
|
|
99
106
|
|
|
100
|
-
-
|
|
107
|
+
- 目标架构基础镜像缺失、Docker Server 平台无法读取或不匹配、清单错误、镜像标签错误、远程数据库配置不完整或任一服务不健康时明确失败。
|
|
101
108
|
- 禁止 `docker pull`、服务器 `docker build`、镜像 fallback、自动回滚、自动安装和静默跳过。
|
|
102
109
|
- 脚本只操作当前 `PROJECT_NAME` 的 Compose 项目和 frontend、backend 服务,不清理其他项目资源。
|
|
103
110
|
- 旧部署资产由工程师根据实际状态单独迁移;新脚本不得猜测并删除。
|
|
@@ -24,7 +24,8 @@ const exampleDefaults = new Map([
|
|
|
24
24
|
['BACKEND_URL', '0.0.0.0'], ['BACKEND_PORT', '3000'],
|
|
25
25
|
['DB_URL', ''], ['DB_PORT', '5432'], ['DB_USER', ''], ['DB_PASSWORD', ''], ['DB_NAME', ''], ['DB_SCHEMA', ''],
|
|
26
26
|
]);
|
|
27
|
-
const
|
|
27
|
+
const apiKeys = ['API_PREFIX', 'VITE_API_BASE_URL'];
|
|
28
|
+
const deploymentKeys = ['PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'];
|
|
28
29
|
const removedKeys = new Set([
|
|
29
30
|
'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
30
31
|
'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT',
|
|
@@ -94,6 +95,18 @@ function parseEnvironment(text, relativePath, requireExampleDefaults, deployment
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
const apiEntries = sections.get('API');
|
|
99
|
+
const apiValues = new Map(apiEntries.map(({ key, value }) => [key, value]));
|
|
100
|
+
const presentApiKeys = apiKeys.filter((key) => apiValues.has(key));
|
|
101
|
+
if (presentApiKeys.length !== 0 && presentApiKeys.length !== apiKeys.length) {
|
|
102
|
+
throw new Error(`${relativePath} # API must contain both: ${apiKeys.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
if (presentApiKeys.length === apiKeys.length) {
|
|
105
|
+
const prefix = apiValues.get('API_PREFIX');
|
|
106
|
+
const baseUrl = apiValues.get('VITE_API_BASE_URL');
|
|
107
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(prefix)) throw new Error(`${relativePath} API_PREFIX must be one safe path segment`);
|
|
108
|
+
if (baseUrl !== `/${prefix}`) throw new Error(`${relativePath} VITE_API_BASE_URL must equal /${prefix}`);
|
|
109
|
+
}
|
|
97
110
|
const deploymentEntries = sections.get('部署');
|
|
98
111
|
const actualDeploymentKeys = deploymentEntries.map(({ key }) => key);
|
|
99
112
|
if (actualDeploymentKeys.length !== 0 && actualDeploymentKeys.join('|') !== deploymentKeys.join('|')) {
|
|
@@ -105,11 +118,17 @@ function parseEnvironment(text, relativePath, requireExampleDefaults, deployment
|
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
return {
|
|
108
|
-
|
|
121
|
+
apiExtended: presentApiKeys.length === apiKeys.length,
|
|
122
|
+
deploymentExtended: deploymentEntries.length === deploymentKeys.length,
|
|
109
123
|
nonDeploymentKeys: [...seen].filter((key) => !deploymentKeys.includes(key)),
|
|
110
124
|
};
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
function extendApiSection(text, apiDefaults) {
|
|
128
|
+
const additions = apiKeys.map((key) => `${key}=${apiDefaults.get(key)}`).join('\n');
|
|
129
|
+
return `${text.replaceAll('\r\n', '\n').trimEnd().replace(/^# 部署$/m, `${additions}\n\n# 部署`)}\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
function extendDeploymentSection(text, deploymentDefaults) {
|
|
114
133
|
const additions = deploymentKeys.map((key) => `${key}=${deploymentDefaults.get(key)}`).join('\n');
|
|
115
134
|
return `${text.trimEnd()}\n${additions}\n`;
|
|
@@ -145,7 +164,8 @@ if (!targetInfo.isDirectory()) throw new Error(`Target project root is not a dir
|
|
|
145
164
|
|
|
146
165
|
const deploymentTemplate = await readFile(join(templatesRoot, 'env.example.template'), 'utf8');
|
|
147
166
|
const deploymentDefaults = new Map([...deploymentTemplate.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/gm)].map((match) => [match[1], match[2]]));
|
|
148
|
-
if (deploymentKeys.some((key) => !deploymentDefaults.has(key))) throw new Error('Deployment environment template is incomplete');
|
|
167
|
+
if ([...apiKeys, ...deploymentKeys].some((key) => !deploymentDefaults.has(key))) throw new Error('Deployment environment template is incomplete');
|
|
168
|
+
const apiDefaults = new Map(apiKeys.map((key) => [key, deploymentDefaults.get(key)]));
|
|
149
169
|
|
|
150
170
|
const environmentPlans = [];
|
|
151
171
|
for (const [relativePath, expectedMode, requireDefaults] of [['.env', 0o600, false], ['.env.example', 0o644, true]]) {
|
|
@@ -153,10 +173,13 @@ for (const [relativePath, expectedMode, requireDefaults] of [['.env', 0o600, fal
|
|
|
153
173
|
const existingBuffer = await readRegularFile(path, relativePath);
|
|
154
174
|
const existing = existingBuffer.toString('utf8');
|
|
155
175
|
const parsed = parseEnvironment(existing, relativePath, requireDefaults, deploymentDefaults);
|
|
176
|
+
let expected = existing;
|
|
177
|
+
if (!parsed.apiExtended) expected = extendApiSection(expected, apiDefaults);
|
|
178
|
+
if (!parsed.deploymentExtended) expected = extendDeploymentSection(expected, deploymentDefaults);
|
|
156
179
|
environmentPlans.push({
|
|
157
180
|
path, relativePath, expectedMode,
|
|
158
181
|
existing: existingBuffer,
|
|
159
|
-
expected: Buffer.from(
|
|
182
|
+
expected: Buffer.from(expected),
|
|
160
183
|
nonDeploymentKeys: parsed.nonDeploymentKeys,
|
|
161
184
|
});
|
|
162
185
|
}
|
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -71,10 +71,25 @@ test('生成统一 Docker 交付文件并通过确定性检查', async () => {
|
|
|
71
71
|
}
|
|
72
72
|
assert.equal((await stat(join(root, 'deploy.sh'))).mode & 0o777, 0o755);
|
|
73
73
|
assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
|
|
74
|
-
|
|
74
|
+
const environment = await readFile(join(root, '.env'), 'utf8');
|
|
75
|
+
assert.match(environment, /API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=\/api\n\n# 部署/);
|
|
76
|
+
assert.match(environment, /FRONTEND_BASE_IMAGE=nginx:stable/);
|
|
77
|
+
assert.match(environment, /DOCKER_PLATFORM=linux\/amd64/);
|
|
75
78
|
assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-alpine3\.24/);
|
|
79
|
+
const nginx = await readFile(join(root, 'scripts/deployment/frontend-container.conf.template'), 'utf8');
|
|
80
|
+
assert.match(nginx, /location \$\{VITE_API_BASE_URL\}\/ \{/);
|
|
81
|
+
assert.match(nginx, /proxy_pass http:\/\/backend:\$\{BACKEND_PORT\};/);
|
|
82
|
+
const frontendDockerfile = await readFile(join(root, 'src/frontend/Dockerfile'), 'utf8');
|
|
83
|
+
assert.match(frontendDockerfile, /ARG VITE_API_BASE_URL/);
|
|
84
|
+
assert.doesNotMatch(frontendDockerfile, /COPY\s+\.env/);
|
|
85
|
+
const backendDockerfile = await readFile(join(root, 'src/backend/Dockerfile'), 'utf8');
|
|
86
|
+
assert.match(backendDockerfile, /pnpm --filter \.\/src\/backend deploy --prod --legacy \/runtime/);
|
|
87
|
+
assert.match(backendDockerfile, /WORKDIR \/app\n/);
|
|
88
|
+
assert.match(backendDockerfile, /COPY --from=build \/runtime \.\/src\/backend\//);
|
|
89
|
+
assert.match(backendDockerfile, /CMD \["node", "src\/backend\/dist\/main\.js"\]/);
|
|
76
90
|
const check = await invoke(root, '--check');
|
|
77
91
|
assert.match(check.stdout, /verified unified Docker delivery files/);
|
|
92
|
+
assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/);
|
|
78
93
|
});
|
|
79
94
|
|
|
80
95
|
test('扩充部署段时完整保留真实环境值', async () => {
|
|
@@ -86,6 +101,29 @@ test('扩充部署段时完整保留真实环境值', async () => {
|
|
|
86
101
|
assert.match(actual, /PROJECT_NAME=\nDOCKER_BIND_IP=\n/);
|
|
87
102
|
});
|
|
88
103
|
|
|
104
|
+
test('保留有效的自定义 API 路径', async () => {
|
|
105
|
+
const configuredReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=service\nVITE_API_BASE_URL=/service\n');
|
|
106
|
+
const configuredExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=service\nVITE_API_BASE_URL=/service\n');
|
|
107
|
+
const root = await fixture(configuredReal, configuredExample);
|
|
108
|
+
await invoke(root, '--write');
|
|
109
|
+
assert.match(await readFile(join(root, '.env'), 'utf8'), /API_PREFIX=service\nVITE_API_BASE_URL=\/service/);
|
|
110
|
+
await invoke(root, '--check');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('缺失、非法或冲突的 API 路径被明确拒绝', async () => {
|
|
114
|
+
const partialReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\n');
|
|
115
|
+
const partialExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\n');
|
|
116
|
+
await assert.rejects(invoke(await fixture(partialReal, partialExample), '--write'), /must contain both/);
|
|
117
|
+
|
|
118
|
+
const invalidReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api/path\nVITE_API_BASE_URL=/api\/path\n');
|
|
119
|
+
const invalidExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api/path\nVITE_API_BASE_URL=/api\/path\n');
|
|
120
|
+
await assert.rejects(invoke(await fixture(invalidReal, invalidExample), '--write'), /one safe path segment/);
|
|
121
|
+
|
|
122
|
+
const conflictReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=/service\n');
|
|
123
|
+
const conflictExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=/service\n');
|
|
124
|
+
await assert.rejects(invoke(await fixture(conflictReal, conflictExample), '--write'), /VITE_API_BASE_URL must equal \/api/);
|
|
125
|
+
});
|
|
126
|
+
|
|
89
127
|
test('非标准环境文件被拒绝且不修改', async () => {
|
|
90
128
|
const invalid = realEnv.replace('# 数据库\n', '');
|
|
91
129
|
const root = await fixture(invalid);
|
|
@@ -55,7 +55,7 @@ const envText = await readFile(join(packageRoot, '.env'), 'utf8');
|
|
|
55
55
|
const env = parseDotenv(envText);
|
|
56
56
|
const sections = [...envText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
57
57
|
assert(sections.join('|') === '前端|后端|数据库|API|部署', 'Root .env must use the five standard sections in order');
|
|
58
|
-
for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
58
|
+
for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'API_PREFIX', 'VITE_API_BASE_URL', 'DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
59
59
|
assert(env.get(key), `Root .env is missing ${key}`);
|
|
60
60
|
}
|
|
61
61
|
assert(/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME')), 'Invalid PROJECT_NAME');
|
|
@@ -64,6 +64,9 @@ for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT']) {
|
|
|
64
64
|
assert(Number.isInteger(port) && port >= 1 && port <= 65535, `${key} must be a valid port`);
|
|
65
65
|
}
|
|
66
66
|
assert(env.get('FRONTEND_PORT') !== env.get('BACKEND_PORT'), 'Frontend and backend ports must differ');
|
|
67
|
+
assert(/^[A-Za-z][A-Za-z0-9_-]*$/.test(env.get('API_PREFIX')), 'API_PREFIX must be one safe path segment');
|
|
68
|
+
assert(env.get('VITE_API_BASE_URL') === `/${env.get('API_PREFIX')}`, 'VITE_API_BASE_URL must equal /API_PREFIX');
|
|
69
|
+
assert(/^(?:linux\/amd64|linux\/arm64)$/.test(env.get('DOCKER_PLATFORM')), 'Invalid DOCKER_PLATFORM');
|
|
67
70
|
for (const key of env.keys()) {
|
|
68
71
|
assert(!['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE', 'RELEASE_VERSION'].includes(key) && !/_(?:HEALTH|VERSION)_URL$/.test(key), `Root .env contains removed configuration: ${key}`);
|
|
69
72
|
}
|
|
@@ -77,14 +80,20 @@ for (const [pattern, message] of [
|
|
|
77
80
|
[/"\$1" == status/, 'Missing status command'],
|
|
78
81
|
[/docker image inspect "\$FRONTEND_BASE_IMAGE"/, 'Frontend local base image must be checked'],
|
|
79
82
|
[/docker image inspect "\$BACKEND_BASE_IMAGE"/, 'Backend local base image must be checked'],
|
|
80
|
-
[/docker build --pull=false/g, 'Docker builds must disable pull'],
|
|
83
|
+
[/docker build --pull=false --platform "\$DOCKER_PLATFORM"/g, 'Docker builds must use the configured target platform and disable pull'],
|
|
81
84
|
[/docker save -o "\$package_root\/frontend-image\.tar"/, 'Frontend image must be exported separately'],
|
|
82
85
|
[/docker save -o "\$package_root\/backend-image\.tar"/, 'Backend image must be exported separately'],
|
|
86
|
+
[/env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/, 'Delivery archive must exclude macOS metadata and extended attributes'],
|
|
87
|
+
[/docker version --format '\{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}'/, 'Deploy must read the Docker Server platform'],
|
|
83
88
|
[/compose up -d --force-recreate --no-build --pull never --wait frontend backend/, 'Deploy must recreate and wait for both services without build or pull'],
|
|
84
89
|
[/compose stop frontend backend/, 'Stop must target both services'],
|
|
85
90
|
[/compose ps frontend backend/, 'Status must inspect both services'],
|
|
86
91
|
]) assert(pattern.test(deploy), message);
|
|
87
|
-
assert((deploy.match(/docker build --pull=false/g) ?? []).length === 2, 'Exactly two Docker builds must disable pull');
|
|
92
|
+
assert((deploy.match(/docker build --pull=false --platform "\$DOCKER_PLATFORM"/g) ?? []).length === 2, 'Exactly two Docker builds must use the configured target platform and disable pull');
|
|
93
|
+
const deployStackIndex = deploy.indexOf('deploy_stack() {');
|
|
94
|
+
const serverPlatformCheckIndex = deploy.indexOf(' assert_server_platform\n', deployStackIndex);
|
|
95
|
+
const firstImageLoadIndex = deploy.indexOf(' docker load -i ', deployStackIndex);
|
|
96
|
+
assert(deployStackIndex >= 0 && serverPlatformCheckIndex > deployStackIndex && firstImageLoadIndex > serverPlatformCheckIndex, 'Docker Server platform must be checked before loading images');
|
|
88
97
|
assert(!/\bdocker\s+pull\b/.test(deploy), 'docker pull is forbidden');
|
|
89
98
|
assert(!/\bsource\s+[^\n]*\.env|\beval\b/.test(deploy), 'Environment files must be parsed as data');
|
|
90
99
|
assert(!/server_menu|运行或替换|read_release_pointer|release_ledger|AGENTS\.md/.test(deploy), 'Legacy menu or release ledger logic is forbidden');
|
|
@@ -95,10 +104,14 @@ const serviceNames = [...compose.matchAll(/^ ([a-z][a-z0-9_-]*):$/gm)].map((mat
|
|
|
95
104
|
assert(JSON.stringify(serviceNames) === JSON.stringify(['frontend', 'backend']), `Compose must define only frontend and backend; got ${serviceNames.join(', ')}`);
|
|
96
105
|
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"'), 'Compose frontend image is not fixed');
|
|
97
106
|
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"'), 'Compose backend image is not fixed');
|
|
107
|
+
assert((compose.match(/platform: "\$\{DOCKER_PLATFORM:\?DOCKER_PLATFORM is required\}"/g) ?? []).length === 2, 'Both services must use DOCKER_PLATFORM');
|
|
98
108
|
assert((compose.match(/env_file: \.\/\.env/g) ?? []).length === 2, 'Both services must use root .env');
|
|
99
109
|
assert((compose.match(/^\s*- \.\/logs:\/app\/logs\s*$/gm) ?? []).length === 2, 'Both services must mount root logs');
|
|
100
110
|
assert(compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"'), 'Frontend port mapping is invalid');
|
|
101
111
|
assert(compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"'), 'Backend port mapping is invalid');
|
|
112
|
+
assert(compose.includes('VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"'), 'Frontend API path is not passed to Nginx');
|
|
113
|
+
assert(compose.includes('BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"'), 'Backend port is not passed to Nginx');
|
|
114
|
+
assert(compose.includes('NGINX_ENVSUBST_FILTER: "^(FRONTEND_URL|FRONTEND_PORT|VITE_API_BASE_URL|BACKEND_PORT)$"'), 'Nginx envsubst filter does not cover the runtime API proxy');
|
|
102
115
|
assert(!/container_name:|\b(?:postgres|database|db):\s*$|postgres:\d|DATABASE_MIGRATION|MIGRATIONS_SOURCE|RELEASE_VERSION|blue|green/i.test(compose), 'Compose contains a database, migration, release or blue-green configuration');
|
|
103
116
|
|
|
104
117
|
const manifestText = await readFile(join(packageRoot, 'manifest.sha256'), 'utf8');
|
|
@@ -122,6 +135,9 @@ async function assertImageArchive(name, expectedTag) {
|
|
|
122
135
|
const parsed = JSON.parse(stdout);
|
|
123
136
|
assert(Array.isArray(parsed) && parsed.length === 1, `${name} must contain exactly one image manifest`);
|
|
124
137
|
assert(JSON.stringify(parsed[0].RepoTags) === JSON.stringify([expectedTag]), `${name} must contain only ${expectedTag}`);
|
|
138
|
+
const { stdout: configText } = await run('tar', ['-xOf', archive, parsed[0].Config], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 });
|
|
139
|
+
const config = JSON.parse(configText);
|
|
140
|
+
assert(`${config.os}/${config.architecture}` === env.get('DOCKER_PLATFORM'), `${name} platform must equal DOCKER_PLATFORM`);
|
|
125
141
|
}
|
|
126
142
|
await assertImageArchive('frontend-image.tar', `${env.get('PROJECT_NAME')}_frontend:latest`);
|
|
127
143
|
await assertImageArchive('backend-image.tar', `${env.get('PROJECT_NAME')}_backend:latest`);
|
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -7,8 +7,10 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
+
import { gunzip } from 'node:zlib';
|
|
10
11
|
|
|
11
12
|
const run = promisify(execFile);
|
|
13
|
+
const unzip = promisify(gunzip);
|
|
12
14
|
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
13
15
|
const generator = join(scriptsRoot, 'generate-deployment-files.mjs');
|
|
14
16
|
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
@@ -29,9 +31,12 @@ DB_NAME=app
|
|
|
29
31
|
DB_SCHEMA=public
|
|
30
32
|
# API
|
|
31
33
|
API_URL=
|
|
34
|
+
API_PREFIX=api
|
|
35
|
+
VITE_API_BASE_URL=/api
|
|
32
36
|
# 部署
|
|
33
37
|
PROJECT_NAME=example_project
|
|
34
38
|
DOCKER_BIND_IP=0.0.0.0
|
|
39
|
+
DOCKER_PLATFORM=linux/amd64
|
|
35
40
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
36
41
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
37
42
|
`;
|
|
@@ -50,9 +55,12 @@ DB_NAME=
|
|
|
50
55
|
DB_SCHEMA=
|
|
51
56
|
# API
|
|
52
57
|
API_URL=
|
|
58
|
+
API_PREFIX=api
|
|
59
|
+
VITE_API_BASE_URL=/api
|
|
53
60
|
# 部署
|
|
54
61
|
PROJECT_NAME=
|
|
55
62
|
DOCKER_BIND_IP=
|
|
63
|
+
DOCKER_PLATFORM=linux/amd64
|
|
56
64
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
57
65
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
58
66
|
`;
|
|
@@ -64,9 +72,15 @@ const path = require('path');
|
|
|
64
72
|
const cp = require('child_process');
|
|
65
73
|
const args = process.argv.slice(2);
|
|
66
74
|
if (process.env.DOCKER_LOG) fs.appendFileSync(process.env.DOCKER_LOG, args.join(' ') + '\\n');
|
|
75
|
+
if (args[0] === 'version') {
|
|
76
|
+
if (process.env.FAIL_SERVER_PLATFORM_READ === '1') process.exit(1);
|
|
77
|
+
process.stdout.write((process.env.SERVER_PLATFORM || 'linux/amd64') + '\\n');
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
67
80
|
if (args[0] === 'image' && args[1] === 'inspect') {
|
|
68
81
|
const image = args[args.length - 1];
|
|
69
82
|
if (process.env.MISSING_IMAGE === image) process.exit(1);
|
|
83
|
+
if (args.includes('--format')) process.stdout.write((process.env.IMAGE_PLATFORM || 'linux/amd64') + '\\n');
|
|
70
84
|
process.exit(0);
|
|
71
85
|
}
|
|
72
86
|
if (args[0] === 'build') {
|
|
@@ -78,9 +92,10 @@ if (args[0] === 'build') {
|
|
|
78
92
|
if (args[0] === 'save') {
|
|
79
93
|
const output = args[args.indexOf('-o') + 1];
|
|
80
94
|
const tag = args[args.length - 1];
|
|
95
|
+
const [imageOs, imageArchitecture] = (process.env.IMAGE_PLATFORM || 'linux/amd64').split('/');
|
|
81
96
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-image-'));
|
|
82
97
|
fs.writeFileSync(path.join(temporary, 'manifest.json'), JSON.stringify([{Config:'config.json',RepoTags:[tag],Layers:[]}]) + '\\n');
|
|
83
|
-
fs.writeFileSync(path.join(temporary, 'config.json'),
|
|
98
|
+
fs.writeFileSync(path.join(temporary, 'config.json'), JSON.stringify({architecture:imageArchitecture,os:imageOs}) + '\\n');
|
|
84
99
|
const result = cp.spawnSync('tar', ['-cf', output, '-C', temporary, 'manifest.json', 'config.json']);
|
|
85
100
|
fs.rmSync(temporary, {recursive:true, force:true});
|
|
86
101
|
process.exit(result.status || 0);
|
|
@@ -131,6 +146,34 @@ async function rewriteManifest(root) {
|
|
|
131
146
|
await writeFile(join(root, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
132
147
|
}
|
|
133
148
|
|
|
149
|
+
async function setTargetPlatform(root, platform) {
|
|
150
|
+
const environmentPath = join(root, '.env');
|
|
151
|
+
const environment = await readFile(environmentPath, 'utf8');
|
|
152
|
+
await writeFile(environmentPath, environment.replace(/^DOCKER_PLATFORM=.*$/m, `DOCKER_PLATFORM=${platform}`), { mode: 0o600 });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readImageArchivePlatform(archive) {
|
|
156
|
+
const { stdout: manifestText } = await run('tar', ['-xOf', archive, 'manifest.json']);
|
|
157
|
+
const [{ Config: configName }] = JSON.parse(manifestText);
|
|
158
|
+
const { stdout: configText } = await run('tar', ['-xOf', archive, configName]);
|
|
159
|
+
const config = JSON.parse(configText);
|
|
160
|
+
return `${config.os}/${config.architecture}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function rewriteImageArchivePlatform(archive, platform) {
|
|
164
|
+
const temporary = await mkdtemp(join(tmpdir(), 'wdyy-image-platform-'));
|
|
165
|
+
created.push(temporary);
|
|
166
|
+
await run('tar', ['-xf', archive, '-C', temporary]);
|
|
167
|
+
const manifest = JSON.parse(await readFile(join(temporary, 'manifest.json'), 'utf8'));
|
|
168
|
+
const configName = manifest[0].Config;
|
|
169
|
+
const config = JSON.parse(await readFile(join(temporary, configName), 'utf8'));
|
|
170
|
+
const [os, architecture] = platform.split('/');
|
|
171
|
+
config.os = os;
|
|
172
|
+
config.architecture = architecture;
|
|
173
|
+
await writeFile(join(temporary, configName), `${JSON.stringify(config)}\n`);
|
|
174
|
+
await run('tar', ['-cf', archive, '-C', temporary, 'manifest.json', configName]);
|
|
175
|
+
}
|
|
176
|
+
|
|
134
177
|
test.after(async () => {
|
|
135
178
|
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
136
179
|
});
|
|
@@ -140,6 +183,11 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
|
|
|
140
183
|
const result = await buildProject(item);
|
|
141
184
|
assert.match(result.stdout, /delivery archive created/);
|
|
142
185
|
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
186
|
+
const uncompressed = await unzip(await readFile(item.archive));
|
|
187
|
+
assert.equal(uncompressed.includes(Buffer.from('./._.env')), false);
|
|
188
|
+
const listing = await run('tar', ['-tzf', item.archive]);
|
|
189
|
+
assert.doesNotMatch(listing.stdout, /(^|\/)\._[^/]*(?:\n|$)/m);
|
|
190
|
+
assert.doesNotMatch(listing.stderr, /LIBARCHIVE\.xattr|unknown extended header/i);
|
|
143
191
|
const extracted = await extractArchive(item);
|
|
144
192
|
assert.deepEqual((await readdir(extracted)).sort(), ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'].sort());
|
|
145
193
|
const validation = await run(process.execPath, [validator, extracted]);
|
|
@@ -147,12 +195,39 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
|
|
|
147
195
|
const log = await readFile(item.log, 'utf8');
|
|
148
196
|
assert.match(log, /image inspect nginx:stable/);
|
|
149
197
|
assert.match(log, /image inspect node:24-alpine3\.24/);
|
|
150
|
-
assert.equal((log.match(/build --pull=false/g) ?? []).length, 2);
|
|
198
|
+
assert.equal((log.match(/build --pull=false --platform linux\/amd64/g) ?? []).length, 2);
|
|
199
|
+
assert.match(log, /build --pull=false --platform linux\/amd64 .*--build-arg VITE_API_BASE_URL=\/api .*example_project_frontend:latest/);
|
|
151
200
|
assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
|
|
152
201
|
assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
|
|
153
202
|
assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
|
|
154
203
|
});
|
|
155
204
|
|
|
205
|
+
test('基础镜像平台不匹配时在 build 前失败', async () => {
|
|
206
|
+
const item = await fixture();
|
|
207
|
+
await assert.rejects(buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' }), /image platform mismatch for nginx:stable: expected linux\/amd64, got linux\/arm64/);
|
|
208
|
+
const log = await readFile(item.log, 'utf8');
|
|
209
|
+
assert.doesNotMatch(log, /^build /m);
|
|
210
|
+
assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('linux/arm64 目标在不同构建机平台上生成并部署单架构交付包', async () => {
|
|
214
|
+
const item = await fixture();
|
|
215
|
+
await setTargetPlatform(item.root, 'linux/arm64');
|
|
216
|
+
await buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' });
|
|
217
|
+
const buildLog = await readFile(item.log, 'utf8');
|
|
218
|
+
assert.equal((buildLog.match(/build --pull=false --platform linux\/arm64/g) ?? []).length, 2);
|
|
219
|
+
const extracted = await extractArchive(item);
|
|
220
|
+
assert.equal(await readImageArchivePlatform(join(extracted, 'frontend-image.tar')), 'linux/arm64');
|
|
221
|
+
assert.equal(await readImageArchivePlatform(join(extracted, 'backend-image.tar')), 'linux/arm64');
|
|
222
|
+
const validation = await run(process.execPath, [validator, extracted]);
|
|
223
|
+
assert.match(validation.stdout, /valid unified Docker delivery package/);
|
|
224
|
+
await writeFile(item.log, '');
|
|
225
|
+
await run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, IMAGE_PLATFORM: 'linux/arm64', SERVER_PLATFORM: 'linux/arm64' } });
|
|
226
|
+
const deployLog = await readFile(item.log, 'utf8');
|
|
227
|
+
assert.match(deployLog, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
|
|
228
|
+
assert.equal((deployLog.match(/^load -i /gm) ?? []).length, 2);
|
|
229
|
+
});
|
|
230
|
+
|
|
156
231
|
test('缺少本地基础镜像时在 build 前失败', async () => {
|
|
157
232
|
const item = await fixture();
|
|
158
233
|
await assert.rejects(buildProject(item, { MISSING_IMAGE: 'nginx:stable' }), /required local frontend base image is missing/);
|
|
@@ -179,11 +254,48 @@ test('服务器无参数执行时从脚本目录整体加载并部署前后端',
|
|
|
179
254
|
await writeFile(item.log, '');
|
|
180
255
|
await run(join(extracted, 'deploy.sh'), [], { cwd: tmpdir(), env: item.environment });
|
|
181
256
|
const log = await readFile(item.log, 'utf8');
|
|
257
|
+
const versionIndex = log.indexOf('version --format {{.Server.Os}}/{{.Server.Arch}}');
|
|
258
|
+
const firstLoadIndex = log.indexOf('load -i ');
|
|
259
|
+
assert(versionIndex >= 0 && firstLoadIndex > versionIndex);
|
|
182
260
|
assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
|
|
183
261
|
assert.match(log, /compose --env-file .* --project-name example_project -f .* up -d --force-recreate --no-build --pull never --wait frontend backend/);
|
|
184
262
|
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=success/);
|
|
185
263
|
});
|
|
186
264
|
|
|
265
|
+
test('服务器平台无法读取、不受支持或不匹配时在加载镜像前失败', async () => {
|
|
266
|
+
const item = await fixture();
|
|
267
|
+
await buildProject(item);
|
|
268
|
+
const cases = [
|
|
269
|
+
[{ FAIL_SERVER_PLATFORM_READ: '1' }, /Docker Server platform is unavailable/],
|
|
270
|
+
[{ SERVER_PLATFORM: 'windows/amd64' }, /unsupported Docker Server platform: windows\/amd64/],
|
|
271
|
+
[{ SERVER_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/amd64, got linux\/arm64/],
|
|
272
|
+
];
|
|
273
|
+
for (const [extraEnvironment, expectedError] of cases) {
|
|
274
|
+
const extracted = await extractArchive(item);
|
|
275
|
+
await writeFile(item.log, '');
|
|
276
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, ...extraEnvironment } }), expectedError);
|
|
277
|
+
const log = await readFile(item.log, 'utf8');
|
|
278
|
+
assert.match(log, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
|
|
279
|
+
assert.doesNotMatch(log, /^load -i /m);
|
|
280
|
+
assert.doesNotMatch(log, /^compose .* (?:up|stop) /m);
|
|
281
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('服务器在 Docker 操作前拒绝 AppleDouble 和上传压缩包残留', async () => {
|
|
286
|
+
const item = await fixture();
|
|
287
|
+
await buildProject(item);
|
|
288
|
+
const extracted = await extractArchive(item);
|
|
289
|
+
await writeFile(item.log, '');
|
|
290
|
+
await writeFile(join(extracted, '._deploy.sh'), 'AppleDouble\n');
|
|
291
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: \._deploy\.sh/);
|
|
292
|
+
assert.equal(await readFile(item.log, 'utf8'), '');
|
|
293
|
+
await rm(join(extracted, '._deploy.sh'));
|
|
294
|
+
await writeFile(join(extracted, 'example_project-docker.tar.gz'), 'uploaded archive\n');
|
|
295
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: example_project-docker\.tar\.gz/);
|
|
296
|
+
assert.equal(await readFile(item.log, 'utf8'), '');
|
|
297
|
+
});
|
|
298
|
+
|
|
187
299
|
test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Docker', async () => {
|
|
188
300
|
const item = await fixture();
|
|
189
301
|
await buildProject(item);
|
|
@@ -221,6 +333,22 @@ test('validator 拒绝数据库容器和不完整远程数据库配置', async (
|
|
|
221
333
|
await assert.rejects(run(process.execPath, [validator, envPackage]), /Root \.env is missing DB_URL/);
|
|
222
334
|
});
|
|
223
335
|
|
|
336
|
+
test('validator 拒绝冲突的 API 路径和缺失的代理注入', async () => {
|
|
337
|
+
const item = await fixture();
|
|
338
|
+
await buildProject(item);
|
|
339
|
+
|
|
340
|
+
const envPackage = await extractArchive(item);
|
|
341
|
+
await writeFile(join(envPackage, '.env'), envText.replace('VITE_API_BASE_URL=/api', 'VITE_API_BASE_URL=/service'), { mode: 0o600 });
|
|
342
|
+
await rewriteManifest(envPackage);
|
|
343
|
+
await assert.rejects(run(process.execPath, [validator, envPackage]), /VITE_API_BASE_URL must equal \/API_PREFIX/);
|
|
344
|
+
|
|
345
|
+
const composePackage = await extractArchive(item);
|
|
346
|
+
const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
|
|
347
|
+
await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"\n', ''));
|
|
348
|
+
await rewriteManifest(composePackage);
|
|
349
|
+
await assert.rejects(run(process.execPath, [validator, composePackage]), /Frontend API path is not passed to Nginx/);
|
|
350
|
+
});
|
|
351
|
+
|
|
224
352
|
test('validator 拒绝清单篡改和额外包内容', async () => {
|
|
225
353
|
const item = await fixture();
|
|
226
354
|
await buildProject(item);
|
|
@@ -232,6 +360,15 @@ test('validator 拒绝清单篡改和额外包内容', async () => {
|
|
|
232
360
|
await assert.rejects(run(process.execPath, [validator, extracted]), /Compose must define only frontend and backend|Checksum mismatch/);
|
|
233
361
|
});
|
|
234
362
|
|
|
363
|
+
test('validator 拒绝镜像归档平台与 DOCKER_PLATFORM 不一致', async () => {
|
|
364
|
+
const item = await fixture();
|
|
365
|
+
await buildProject(item);
|
|
366
|
+
const extracted = await extractArchive(item);
|
|
367
|
+
await rewriteImageArchivePlatform(join(extracted, 'backend-image.tar'), 'linux/arm64');
|
|
368
|
+
await rewriteManifest(extracted);
|
|
369
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /backend-image\.tar platform must equal DOCKER_PLATFORM/);
|
|
370
|
+
});
|
|
371
|
+
|
|
235
372
|
test('build 在任何 Docker 操作前拒绝环境文件命令语法', async () => {
|
|
236
373
|
const item = await fixture();
|
|
237
374
|
await writeFile(join(item.root, '.env'), envText.replace('DB_PASSWORD=secret', 'DB_PASSWORD=$(id)'), { mode: 0o600 });
|
|
@@ -3,7 +3,8 @@ FROM ${BACKEND_BASE_IMAGE} AS build
|
|
|
3
3
|
WORKDIR /app
|
|
4
4
|
COPY . .
|
|
5
5
|
RUN corepack enable && pnpm install --frozen-lockfile
|
|
6
|
-
RUN pnpm --
|
|
6
|
+
RUN pnpm --filter ./src/backend build
|
|
7
|
+
RUN pnpm --filter ./src/backend deploy --prod --legacy /runtime
|
|
7
8
|
|
|
8
9
|
FROM ${BACKEND_BASE_IMAGE} AS runtime
|
|
9
10
|
WORKDIR /app
|
|
@@ -11,8 +12,6 @@ ARG BACKEND_PORT
|
|
|
11
12
|
ENV NODE_ENV=production
|
|
12
13
|
ENV PORT=${BACKEND_PORT}
|
|
13
14
|
LABEL org.opencontainers.image.title="wdyy-backend"
|
|
14
|
-
COPY --from=build /
|
|
15
|
-
COPY --from=build /app/node_modules ./node_modules
|
|
16
|
-
COPY --from=build /app/src/backend/dist ./dist
|
|
15
|
+
COPY --from=build /runtime ./src/backend/
|
|
17
16
|
EXPOSE ${BACKEND_PORT}
|
|
18
|
-
CMD ["node", "dist/main.js"]
|
|
17
|
+
CMD ["node", "src/backend/dist/main.js"]
|
|
@@ -55,13 +55,14 @@ validate_env_permissions() {
|
|
|
55
55
|
|
|
56
56
|
reset_configuration() {
|
|
57
57
|
unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
|
|
58
|
+
unset API_PREFIX VITE_API_BASE_URL
|
|
58
59
|
unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
|
|
59
|
-
unset PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
60
|
+
unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
is_configuration_key() {
|
|
63
64
|
case "$1" in
|
|
64
|
-
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
|
|
65
|
+
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|API_PREFIX|VITE_API_BASE_URL|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|DOCKER_PLATFORM|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
|
|
65
66
|
return 0 ;;
|
|
66
67
|
*) return 1 ;;
|
|
67
68
|
esac
|
|
@@ -105,14 +106,17 @@ load_dotenv() {
|
|
|
105
106
|
|
|
106
107
|
validate_configuration() {
|
|
107
108
|
local key value
|
|
108
|
-
for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
|
|
109
|
+
for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT API_PREFIX VITE_API_BASE_URL DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
|
|
109
110
|
value="${!key:-}"
|
|
110
111
|
[[ -n "$value" ]] || fail "required configuration is missing: $key"
|
|
111
112
|
done
|
|
112
113
|
[[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
|
|
113
114
|
[[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
|
|
114
115
|
[[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
|
|
116
|
+
[[ "$API_PREFIX" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]] || fail "API_PREFIX must be one safe path segment"
|
|
117
|
+
[[ "$VITE_API_BASE_URL" == "/$API_PREFIX" ]] || fail "VITE_API_BASE_URL must equal /$API_PREFIX"
|
|
115
118
|
[[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
|
|
119
|
+
[[ "$DOCKER_PLATFORM" =~ ^linux/(amd64|arm64)$ ]] || fail "DOCKER_PLATFORM must be linux/amd64 or linux/arm64"
|
|
116
120
|
[[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
|
|
117
121
|
[[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
|
|
118
122
|
[[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
|
|
@@ -122,6 +126,20 @@ validate_configuration() {
|
|
|
122
126
|
[[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
assert_image_platform() {
|
|
130
|
+
local image="$1" actual
|
|
131
|
+
actual="$(docker image inspect --platform "$DOCKER_PLATFORM" --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
|
|
132
|
+
[[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM, got $actual"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
assert_server_platform() {
|
|
136
|
+
local actual
|
|
137
|
+
actual="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)" || fail "Docker Server platform is unavailable"
|
|
138
|
+
[[ -n "$actual" ]] || fail "Docker Server platform is empty"
|
|
139
|
+
[[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
|
|
140
|
+
[[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
|
|
141
|
+
}
|
|
142
|
+
|
|
125
143
|
validate_dockerignore() {
|
|
126
144
|
local file="$1" pattern line
|
|
127
145
|
[[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
|
|
@@ -220,14 +238,18 @@ build_release() {
|
|
|
220
238
|
validate_dockerignore "$dockerignore_file"
|
|
221
239
|
docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
|
|
222
240
|
docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
|
|
241
|
+
assert_image_platform "$FRONTEND_BASE_IMAGE"
|
|
242
|
+
assert_image_platform "$BACKEND_BASE_IMAGE"
|
|
223
243
|
frontend_image="${PROJECT_NAME}_frontend:latest"
|
|
224
244
|
backend_image="${PROJECT_NAME}_backend:latest"
|
|
225
245
|
acquire_build_lock
|
|
226
246
|
trap cleanup_build EXIT
|
|
227
|
-
docker build --pull=false --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
|
|
228
|
-
docker build --pull=false --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
|
|
247
|
+
docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "VITE_API_BASE_URL=$VITE_API_BASE_URL" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
|
|
248
|
+
docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
|
|
229
249
|
docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
|
|
230
250
|
docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
|
|
251
|
+
assert_image_platform "$frontend_image"
|
|
252
|
+
assert_image_platform "$backend_image"
|
|
231
253
|
STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
|
|
232
254
|
package_root="$STAGING_ROOT/package"
|
|
233
255
|
mkdir -p "$package_root"
|
|
@@ -245,7 +267,7 @@ build_release() {
|
|
|
245
267
|
archive_name="${PROJECT_NAME}-docker.tar.gz"
|
|
246
268
|
ensure_deploy_output_scope "$deploy_dir" "$archive_name"
|
|
247
269
|
candidate="$STAGING_ROOT/$archive_name"
|
|
248
|
-
tar -czf "$candidate" -C "$package_root" .
|
|
270
|
+
env COPYFILE_DISABLE=1 tar --no-xattrs -czf "$candidate" -C "$package_root" .
|
|
249
271
|
tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
|
|
250
272
|
mv -f "$candidate" "$deploy_dir/$archive_name"
|
|
251
273
|
rm -rf -- "$STAGING_ROOT"
|
|
@@ -283,8 +305,11 @@ deploy_stack() {
|
|
|
283
305
|
verify_manifest "$DEPLOY_ROOT"
|
|
284
306
|
archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
|
|
285
307
|
archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
|
|
308
|
+
assert_server_platform
|
|
286
309
|
docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
|
|
287
310
|
docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
|
|
311
|
+
assert_image_platform "${PROJECT_NAME}_frontend:latest"
|
|
312
|
+
assert_image_platform "${PROJECT_NAME}_backend:latest"
|
|
288
313
|
docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
|
|
289
314
|
docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
|
|
290
315
|
compose up -d --force-recreate --no-build --pull never --wait frontend backend
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
services:
|
|
2
2
|
frontend:
|
|
3
3
|
image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"
|
|
4
|
+
platform: "${DOCKER_PLATFORM:?DOCKER_PLATFORM is required}"
|
|
4
5
|
env_file: ./.env
|
|
5
6
|
environment:
|
|
6
|
-
NGINX_ENVSUBST_FILTER: "^(FRONTEND_URL|FRONTEND_PORT)$"
|
|
7
|
+
NGINX_ENVSUBST_FILTER: "^(FRONTEND_URL|FRONTEND_PORT|VITE_API_BASE_URL|BACKEND_PORT)$"
|
|
8
|
+
VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"
|
|
9
|
+
BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"
|
|
7
10
|
ports:
|
|
8
11
|
- "${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"
|
|
9
12
|
volumes:
|
|
10
13
|
- ./logs:/app/logs
|
|
11
14
|
restart: unless-stopped
|
|
15
|
+
depends_on:
|
|
16
|
+
backend:
|
|
17
|
+
condition: service_healthy
|
|
12
18
|
healthcheck:
|
|
13
19
|
test: ["CMD", "nginx", "-t"]
|
|
14
20
|
interval: 10s
|
|
@@ -17,6 +23,7 @@ services:
|
|
|
17
23
|
|
|
18
24
|
backend:
|
|
19
25
|
image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"
|
|
26
|
+
platform: "${DOCKER_PLATFORM:?DOCKER_PLATFORM is required}"
|
|
20
27
|
env_file: ./.env
|
|
21
28
|
environment:
|
|
22
29
|
HOST: "${BACKEND_URL}"
|
package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template
CHANGED
|
@@ -12,6 +12,15 @@ server {
|
|
|
12
12
|
return 200 'ok';
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
location ${VITE_API_BASE_URL}/ {
|
|
16
|
+
proxy_pass http://backend:${BACKEND_PORT};
|
|
17
|
+
proxy_http_version 1.1;
|
|
18
|
+
proxy_set_header Host $host;
|
|
19
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
20
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
21
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
location / {
|
|
16
25
|
try_files $uri $uri/ /index.html;
|
|
17
26
|
}
|
|
@@ -2,8 +2,11 @@ ARG BACKEND_BASE_IMAGE
|
|
|
2
2
|
ARG FRONTEND_BASE_IMAGE
|
|
3
3
|
FROM ${BACKEND_BASE_IMAGE} AS build
|
|
4
4
|
WORKDIR /app
|
|
5
|
+
ARG VITE_API_BASE_URL
|
|
6
|
+
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|
5
7
|
COPY . .
|
|
6
8
|
RUN corepack enable && pnpm install --frozen-lockfile
|
|
9
|
+
RUN test -n "$VITE_API_BASE_URL"
|
|
7
10
|
RUN pnpm --dir src/frontend build
|
|
8
11
|
|
|
9
12
|
FROM ${FRONTEND_BASE_IMAGE}
|