@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.
Files changed (19) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +53 -61
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
  4. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +92 -88
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +213 -0
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +106 -0
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +168 -147
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +485 -526
  9. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +10 -8
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +598 -416
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +46 -12
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +2 -0
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +30 -0
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +24 -0
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +7 -0
  16. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +17 -18
  17. package/README.md +66 -10
  18. package/lib/wdyy-cli.js +35 -9
  19. package/package.json +1 -1
@@ -1,22 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createHash } from 'node:crypto';
3
3
  import {
4
- chmod,
5
- cp,
6
- copyFile,
7
- mkdir,
8
- mkdtemp,
9
- readFile,
10
- readlink,
11
- readdir,
12
- rm,
13
- stat,
14
- symlink,
15
- unlink,
16
- writeFile,
4
+ access, chmod, cp, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, unlink, writeFile,
17
5
  } from 'node:fs/promises';
18
6
  import { tmpdir } from 'node:os';
19
- import { dirname, join, relative, resolve, sep } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
20
8
  import { fileURLToPath } from 'node:url';
21
9
  import { afterEach, test } from 'node:test';
22
10
  import { spawnSync } from 'node:child_process';
@@ -29,582 +17,553 @@ const deployTemplate = join(templatesRoot, 'deploy.sh.template');
29
17
  const composeTemplate = join(templatesRoot, 'docker-compose.blue-green.yml');
30
18
  const nginxTemplate = join(templatesRoot, 'nginx-upstream.template.conf');
31
19
  const dockerignoreTemplate = join(templatesRoot, 'dockerignore.template');
20
+ const frontendDockerfileTemplate = join(templatesRoot, 'frontend.Dockerfile.template');
21
+ const backendDockerfileTemplate = join(templatesRoot, 'Dockerfile.template');
22
+ const frontendNginxTemplate = join(templatesRoot, 'frontend-container.conf.template');
32
23
  const temporaryDirectories = [];
33
24
 
34
25
  afterEach(async () => {
35
- await Promise.all(
36
- temporaryDirectories.splice(0).map((directory) =>
37
- rm(directory, { force: true, recursive: true }),
38
- ),
39
- );
26
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
40
27
  });
41
28
 
42
- async function createTemporaryDirectory(prefix) {
43
- const directory = await mkdtemp(join(tmpdir(), prefix));
44
- temporaryDirectories.push(directory);
45
- return directory;
46
- }
47
-
48
- async function listFiles(root, directory = root) {
49
- const result = [];
50
- for (const entry of await readdir(directory, { withFileTypes: true })) {
51
- const path = join(directory, entry.name);
52
- if (entry.isDirectory()) result.push(...await listFiles(root, path));
53
- else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
54
- }
55
- return result;
29
+ async function temporary(prefix) {
30
+ const path = await mkdtemp(join(tmpdir(), prefix));
31
+ temporaryDirectories.push(path);
32
+ return path;
56
33
  }
57
34
 
58
- async function writeManifest(versionRoot) {
59
- const paths = (await listFiles(versionRoot))
60
- .filter((path) => path !== 'manifest.sha256')
61
- .sort();
62
- const lines = [];
63
- for (const path of paths) {
64
- const checksum = createHash('sha256')
65
- .update(await readFile(join(versionRoot, path)))
66
- .digest('hex');
67
- lines.push(`${checksum} ${path}`);
68
- }
69
- await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
35
+ function envText(serverProjectsRoot, mode = 'none', extra = '') {
36
+ return `# 前端
37
+ FRONTEND_URL=0.0.0.0
38
+ FRONTEND_PORT=5173
39
+
40
+ # 后端
41
+ BACKEND_URL=0.0.0.0
42
+ BACKEND_PORT=3000
43
+
44
+ # 数据库
45
+ DB_URL=
46
+ DB_PORT=5432
47
+ DB_USER=
48
+ DB_PASSWORD=
49
+ DB_NAME=
50
+ DB_SCHEMA=
51
+
52
+ # API
53
+
54
+ # 部署
55
+ PROJECT_NAME=fixture
56
+ SERVER_PROJECTS_ROOT=${serverProjectsRoot}
57
+ PROJECT_HTTP_PORT=9099
58
+ DOCKER_BIND_IP=127.0.0.1
59
+ FRONTEND_BLUE_PORT=19091
60
+ FRONTEND_GREEN_PORT=19092
61
+ BACKEND_BLUE_PORT=19093
62
+ BACKEND_GREEN_PORT=19094
63
+ FRONTEND_BASE_IMAGE=nginx@sha256:${'0'.repeat(64)}
64
+ BACKEND_BASE_IMAGE=node@sha256:${'1'.repeat(64)}
65
+ DATABASE_MIGRATION_MODE=${mode}
66
+ ${extra}`;
70
67
  }
71
68
 
72
- async function createPackage() {
73
- const directory = await createTemporaryDirectory('deployment-package-');
74
- const packageRoot = join(directory, 'deploy');
75
- const version = '20260730-001';
76
- const versionRoot = join(packageRoot, version);
69
+ async function createFixture({ mode = 'none', agents = '# Target project\n', extraEnv = '' } = {}) {
70
+ const root = await temporary('wdyy-deploy-');
71
+ const fakeBin = join(root, 'fake-bin');
72
+ const serverProjectsRoot = join(root, 'servers');
73
+ const commandLog = join(root, 'commands.log');
77
74
  await Promise.all([
78
- mkdir(join(versionRoot, 'database', 'migrations'), { recursive: true }),
79
- mkdir(join(versionRoot, 'scripts'), { recursive: true }),
80
- mkdir(join(versionRoot, 'docker'), { recursive: true }),
81
- mkdir(join(versionRoot, 'nginx'), { recursive: true }),
75
+ mkdir(fakeBin, { recursive: true }),
76
+ mkdir(join(root, 'src/frontend'), { recursive: true }),
77
+ mkdir(join(root, 'src/backend'), { recursive: true }),
78
+ mkdir(join(root, 'scripts/deployment'), { recursive: true }),
79
+ mkdir(serverProjectsRoot, { recursive: true }),
82
80
  ]);
83
- await copyFile(deployTemplate, join(packageRoot, 'deploy.sh'));
84
- await chmod(join(packageRoot, 'deploy.sh'), 0o755);
85
- await writeFile(join(packageRoot, 'release.env'), `RELEASE_VERSION=${version}\n`);
86
- await writeFile(join(versionRoot, 'frontend.tar.gz'), 'frontend archive');
87
- await writeFile(join(versionRoot, 'backend-image.tar'), 'backend image');
88
- await writeFile(
89
- join(versionRoot, 'database', 'migrations', 'V001__baseline.sql'),
90
- 'SELECT 1;\n',
91
- );
92
- await writeFile(
93
- join(versionRoot, 'scripts', 'apply-migrations.sh'),
94
- '#!/usr/bin/env bash\nset -euo pipefail\n',
95
- );
96
- await chmod(join(versionRoot, 'scripts', 'apply-migrations.sh'), 0o755);
97
- await copyFile(composeTemplate, join(versionRoot, 'docker', 'docker-compose.blue-green.yml'));
98
- await copyFile(nginxTemplate, join(versionRoot, 'nginx', 'site.conf'));
99
- await writeManifest(versionRoot);
100
- return {
101
- directory,
102
- packageRoot,
103
- version,
104
- versionRoot,
105
- deploy: join(packageRoot, 'deploy.sh'),
106
- compose: join(versionRoot, 'docker', 'docker-compose.blue-green.yml'),
107
- nginx: join(versionRoot, 'nginx', 'site.conf'),
108
- };
109
- }
110
-
111
- function runValidator(files) {
112
- return spawnSync(
113
- process.execPath,
114
- [validator, files.deploy, files.nginx, files.compose, files.packageRoot],
115
- { encoding: 'utf8' },
116
- );
117
- }
118
-
119
- async function replace(path, from, to) {
120
- const content = await readFile(path, 'utf8');
121
- assert.ok(content.includes(from), `fixture replacement source not found: ${from}`);
122
- await writeFile(path, content.replace(from, to));
123
- }
124
-
125
- test('接受自动 build、完整单版本目录和无参数 start', async () => {
126
- const files = await createPackage();
127
- const result = runValidator(files);
128
-
129
- assert.equal(result.status, 0, result.stderr);
130
- assert.match(result.stdout, /valid deployment package 20260730-001/);
131
- });
132
-
133
- test('缺少 rollback 命令时明确失败', async () => {
134
- const files = await createPackage();
135
- await replace(files.deploy, '\n rollback)\n', '\n legacy_command)\n');
136
- const result = runValidator(files);
137
-
138
- assert.notEqual(result.status, 0);
139
- assert.match(result.stderr, /rollback/);
140
- });
141
-
142
- test('拒绝旧 start version 语法', async () => {
143
- const files = await createPackage();
144
- await replace(
145
- files.deploy,
146
- 'start)\n [[ "$#" -eq 1 ]] || usage',
147
- 'start)\n version="$2"',
148
- );
149
- const result = runValidator(files);
150
-
151
- assert.notEqual(result.status, 0);
152
- assert.match(result.stderr, /start/);
153
- });
154
-
155
- test('拒绝固定 srv 部署根目录', async () => {
156
- const files = await createPackage();
157
- await replace(files.nginx, '__DEPLOY_ROOT__', '/srv/fixed-project');
158
- const result = runValidator(files);
159
-
160
- assert.notEqual(result.status, 0);
161
- assert.match(result.stderr, /fixed \/srv path/);
162
- });
163
-
164
- test('拒绝 Nginx 模板写死代理 URL', async () => {
165
- const files = await createPackage();
166
- await replace(files.nginx, '__BACKEND_PROXY_URL__', 'http://backend_active');
167
- const result = runValidator(files);
168
-
169
- assert.notEqual(result.status, 0);
170
- assert.match(result.stderr, /BACKEND_PROXY_URL/);
171
- });
172
-
173
- test('拒绝缺失迁移执行器', async () => {
174
- const files = await createPackage();
175
- await unlink(join(files.versionRoot, 'scripts', 'apply-migrations.sh'));
176
- const result = runValidator(files);
177
-
178
- assert.notEqual(result.status, 0);
179
- assert.match(result.stderr, /apply-migrations/);
180
- });
181
-
182
- test('拒绝缺失校验清单', async () => {
183
- const files = await createPackage();
184
- await unlink(join(files.versionRoot, 'manifest.sha256'));
185
- const result = runValidator(files);
186
-
187
- assert.notEqual(result.status, 0);
188
- assert.match(result.stderr, /manifest/);
189
- });
190
-
191
- test('拒绝生产环境文件进入版本目录', async () => {
192
- const files = await createPackage();
193
- await writeFile(join(files.versionRoot, '.env.production'), 'DATABASE_URL=secret\n');
194
- await writeManifest(files.versionRoot);
195
- const result = runValidator(files);
196
-
197
- assert.notEqual(result.status, 0);
198
- assert.match(result.stderr, /secret file/);
199
- });
200
-
201
- test('拒绝版本目录中的符号链接', async () => {
202
- const files = await createPackage();
203
- await symlink('../outside-secret', join(files.versionRoot, 'linked-secret'));
204
- const result = runValidator(files);
205
-
206
- assert.notEqual(result.status, 0);
207
- assert.match(result.stderr, /symbolic links/);
208
- });
209
-
210
- test('拒绝文件与校验清单不一致', async () => {
211
- const files = await createPackage();
212
- await writeFile(join(files.versionRoot, 'backend-image.tar'), 'changed image');
213
- const result = runValidator(files);
214
-
215
- assert.notEqual(result.status, 0);
216
- assert.match(result.stderr, /Checksum mismatch/);
217
- });
218
-
219
- test('拒绝 deploy.sh 自动上传', async () => {
220
- const files = await createPackage();
221
- await writeFile(files.deploy, `${await readFile(files.deploy, 'utf8')}\nscp deploy server:/tmp\n`);
222
- const result = runValidator(files);
223
-
224
- assert.notEqual(result.status, 0);
225
- assert.match(result.stderr, /must not upload/);
226
- });
227
-
228
- test('拒绝 LOG_DIR 和 HOST_LOG_DIR 可配置日志目录', async () => {
229
- const files = await createPackage();
230
- await writeFile(
231
- files.compose,
232
- `services:
233
- backend-blue:
234
- environment:
235
- INSTANCE_ID: blue
236
- LOG_DIR: /var/log/application
237
- volumes:
238
- - \${HOST_LOG_DIR}/backend/blue:/app/logs
239
- backend-green:
240
- environment:
241
- INSTANCE_ID: green
242
- LOG_DIR: /var/log/application
243
- volumes:
244
- - \${HOST_LOG_DIR}/backend/green:/app/logs
245
- `,
246
- );
247
- const result = runValidator(files);
248
-
249
- assert.notEqual(result.status, 0);
250
- assert.match(result.stderr, /project-root/);
251
- });
252
-
253
- test('拒绝 blue 和 green 使用不同日志目录', async () => {
254
- const files = await createPackage();
255
- await writeFile(
256
- files.compose,
257
- `services:
258
- backend-blue:
259
- environment:
260
- INSTANCE_ID: blue
261
- volumes:
262
- - ../../logs/backend/blue:/app/logs
263
- backend-green:
264
- environment:
265
- INSTANCE_ID: green
266
- volumes:
267
- - ../../logs/backend/green:/app/logs
268
- `,
269
- );
270
- const result = runValidator(files);
271
-
272
- assert.notEqual(result.status, 0);
273
- assert.match(result.stderr, /project-root/);
274
- });
275
-
276
- async function createBuildFixture({ agentsContent = '# Target Project\n', secretMigration = false } = {}) {
277
- const directory = await createTemporaryDirectory('deployment-build-');
278
- const fakeBin = join(directory, 'fake-bin');
279
81
  await Promise.all([
280
- mkdir(fakeBin, { recursive: true }),
281
- mkdir(join(directory, 'src', 'backend'), { recursive: true }),
282
- mkdir(join(directory, 'database', 'migrations'), { recursive: true }),
283
- mkdir(join(directory, 'scripts', 'deployment'), { recursive: true }),
82
+ copyFile(deployTemplate, join(root, 'deploy.sh')),
83
+ copyFile(frontendDockerfileTemplate, join(root, 'src/frontend/Dockerfile')),
84
+ copyFile(backendDockerfileTemplate, join(root, 'src/backend/Dockerfile')),
85
+ copyFile(composeTemplate, join(root, 'scripts/deployment/docker-compose.blue-green.yml')),
86
+ copyFile(nginxTemplate, join(root, 'scripts/deployment/nginx-site.conf')),
87
+ copyFile(frontendNginxTemplate, join(root, 'scripts/deployment/frontend-container.conf.template')),
88
+ copyFile(dockerignoreTemplate, join(root, '.dockerignore')),
89
+ writeFile(join(root, 'AGENTS.md'), agents),
90
+ writeFile(join(root, '.env'), envText(serverProjectsRoot, mode, extraEnv)),
91
+ writeFile(commandLog, ''),
284
92
  ]);
285
- await copyFile(deployTemplate, join(directory, 'deploy.sh'));
286
- await chmod(join(directory, 'deploy.sh'), 0o755);
287
- await writeFile(join(directory, 'AGENTS.md'), agentsContent);
288
- await writeFile(
289
- join(directory, '.env'),
290
- 'PROJECT_NAME=fixture\nBACKEND_CONTAINER_PORT=3000\n',
291
- );
292
- await copyFile(dockerignoreTemplate, join(directory, '.dockerignore'));
293
- await writeFile(join(directory, 'src', 'backend', 'Dockerfile'), 'FROM scratch\n');
294
- await writeFile(
295
- join(directory, 'database', 'migrations', 'V001__baseline.sql'),
296
- 'SELECT 1;\n',
297
- );
298
- if (secretMigration) {
299
- await writeFile(
300
- join(directory, 'database', 'migrations', '.env.production'),
301
- 'DATABASE_URL=secret\n',
302
- );
93
+ await chmod(join(root, 'deploy.sh'), 0o755);
94
+ await chmod(join(root, '.env'), 0o600);
95
+
96
+ if (mode === 'manual') {
97
+ await mkdir(join(root, 'database/migrations'), { recursive: true });
98
+ await writeFile(join(root, 'database/migrations/V001__baseline.sql'), 'SELECT 1;\n');
99
+ await writeFile(join(root, 'scripts/apply-migrations.sh'), '#!/usr/bin/env bash\nset -euo pipefail\n');
100
+ await chmod(join(root, 'scripts/apply-migrations.sh'), 0o755);
303
101
  }
304
- await writeFile(
305
- join(directory, 'scripts', 'apply-migrations.sh'),
306
- `#!/usr/bin/env bash
307
- set -euo pipefail
308
- if [[ -n "\${COMMAND_LOG:-}" ]]; then
309
- printf 'migration %s\\n' "\${MIGRATIONS_DIR:-}" >> "$COMMAND_LOG"
310
- fi
311
- `,
312
- );
313
- await chmod(join(directory, 'scripts', 'apply-migrations.sh'), 0o755);
314
- await copyFile(
315
- composeTemplate,
316
- join(directory, 'scripts', 'deployment', 'docker-compose.blue-green.yml'),
317
- );
318
- await copyFile(
319
- nginxTemplate,
320
- join(directory, 'scripts', 'deployment', 'nginx-site.conf'),
321
- );
322
102
 
323
- const fakePnpm = join(fakeBin, 'pnpm');
324
- await writeFile(
325
- fakePnpm,
326
- `#!/usr/bin/env bash
103
+ await writeExecutable(join(fakeBin, 'pnpm'), `#!/usr/bin/env bash
327
104
  set -euo pipefail
328
- if [[ "\${FAIL_GATE:-}" == "\${1:-}" ]]; then
329
- echo "forced pnpm failure: $1" >&2
330
- exit 9
331
- fi
105
+ printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
106
+ [[ "\${FAIL_GATE:-}" != "\${1:-}" ]] || exit 9
332
107
  if [[ "\${1:-}" == build ]]; then
333
108
  mkdir -p src/frontend/dist
334
- printf '<!doctype html><script src="%sapp.js"></script>\\n' "$VITE_RELEASE_BASE" > src/frontend/dist/index.html
335
- printf 'console.log("ok")\\n' > src/frontend/dist/app.js
109
+ printf '<!doctype html><div id="app"></div>\n' > src/frontend/dist/index.html
336
110
  fi
337
- `,
338
- );
339
- await chmod(fakePnpm, 0o755);
340
-
341
- const fakeDocker = join(fakeBin, 'docker');
342
- await writeFile(
343
- fakeDocker,
344
- `#!/usr/bin/env bash
111
+ `);
112
+ await writeExecutable(join(fakeBin, 'docker'), `#!/usr/bin/env bash
345
113
  set -euo pipefail
346
- if [[ -n "\${COMMAND_LOG:-}" ]]; then
347
- printf 'docker %s\\n' "$*" >> "$COMMAND_LOG"
348
- fi
114
+ printf 'docker %s\n' "$*" >> "$COMMAND_LOG"
349
115
  case "\${1:-}" in
350
- build)
351
- exit 0
352
- ;;
116
+ build) exit 0 ;;
353
117
  image)
354
- [[ "\${2:-}" == inspect ]]
118
+ if [[ "\${2:-}" == inspect ]]; then tag="\${!#}"; printf '%s\n' "\${tag##*:}";
119
+ elif [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_IMAGE_LIST:-fixture_frontend:20260808-001
120
+ fixture_backend:20260808-001
121
+ other_project_frontend:shared}"; fi
355
122
  ;;
356
123
  save)
357
- shift
358
- output=
124
+ shift; output=; tag=
359
125
  while [[ "$#" -gt 0 ]]; do
360
- if [[ "$1" == -o ]]; then
361
- output="$2"
362
- shift 2
363
- else
364
- shift
365
- fi
126
+ if [[ "$1" == -o ]]; then output="$2"; shift 2; else tag="$1"; shift; fi
366
127
  done
367
- [[ -n "$output" ]]
368
- printf 'fixture image\\n' > "$output"
128
+ work="$(mktemp -d)"
129
+ printf '[{"Config":"config.json","RepoTags":["%s"],"Layers":[]}]\n' "$tag" > "$work/manifest.json"
130
+ printf '{}\n' > "$work/config.json"
131
+ /usr/bin/tar -cf "$output" -C "$work" manifest.json config.json
132
+ /bin/rm -rf "$work"
369
133
  ;;
370
- load|compose)
371
- exit 0
134
+ load) exit 0 ;;
135
+ compose) exit 0 ;;
136
+ ps)
137
+ if [[ "\${2:-}" == -aq ]]; then
138
+ printf '%s\n' "\${DOCKER_CONTAINER_IDS:-c111
139
+ c222}"
140
+ elif [[ "\${2:-}" == --format && -n "\${DOCKER_RUNNING_PORTS:-}" ]]; then
141
+ printf '%s\n' "$DOCKER_RUNNING_PORTS"
142
+ fi
372
143
  ;;
373
- *)
374
- echo "unexpected docker command: $*" >&2
375
- exit 10
144
+ network)
145
+ if [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_NETWORK_IDS:-n111}"; fi
376
146
  ;;
147
+ rm) exit 0 ;;
148
+ *) echo "unexpected docker command: $*" >&2; exit 10 ;;
377
149
  esac
378
- `,
379
- );
380
- await chmod(fakeDocker, 0o755);
381
-
382
- const fakeCurl = join(fakeBin, 'curl');
383
- await writeFile(
384
- fakeCurl,
385
- `#!/usr/bin/env bash
150
+ `);
151
+ await writeExecutable(join(fakeBin, 'curl'), `#!/usr/bin/env bash
386
152
  set -euo pipefail
387
153
  url="\${!#}"
388
- if [[ "$url" == */version ]]; then
389
- printf '%s' "\${EXPECTED_VERSION:?}"
390
- else
391
- printf 'ok'
392
- fi
393
- `,
394
- );
395
- await chmod(fakeCurl, 0o755);
396
-
397
- const fakeNginx = join(fakeBin, 'nginx');
398
- await writeFile(
399
- fakeNginx,
400
- `#!/usr/bin/env bash
154
+ if [[ "\${FAIL_PUBLIC_ENDPOINT:-0}" == 1 && "$url" == *:9099/* ]]; then exit 26; fi
155
+ if [[ "$url" == */version ]]; then printf '%s' "\${EXPECTED_VERSION:?}"; else printf 'ok'; fi
156
+ `);
157
+ await writeExecutable(join(fakeBin, 'nginx'), `#!/usr/bin/env bash
401
158
  set -euo pipefail
402
- if [[ -n "\${COMMAND_LOG:-}" ]]; then
403
- printf 'nginx %s\\n' "$*" >> "$COMMAND_LOG"
159
+ printf 'nginx %s\n' "$*" >> "$COMMAND_LOG"
160
+ if [[ "\${1:-}" == -t ]]; then [[ "\${FAIL_NGINX_TEST:-0}" != 1 ]] || exit 23; exit 0; fi
161
+ if [[ "\${1:-}" == -T ]]; then
162
+ [[ ! -f "$SERVER_ROOT/nginx/site.conf" ]] || cat "$SERVER_ROOT/nginx/site.conf"
163
+ [[ -z "\${EXTRA_NGINX_CONFIG:-}" ]] || printf '%s\n' "$EXTRA_NGINX_CONFIG"
164
+ exit 0
404
165
  fi
405
- `,
406
- );
407
- await chmod(fakeNginx, 0o755);
166
+ if [[ "\${1:-}" == -s && "\${2:-}" == reload && "\${FAIL_NGINX_RELOAD_ONCE:-0}" == 1 && ! -f "$NGINX_FAIL_MARKER" ]]; then
167
+ : > "$NGINX_FAIL_MARKER"; exit 24
168
+ fi
169
+ `);
170
+ await writeExecutable(join(fakeBin, 'flock'), '#!/usr/bin/env bash\nexit 0\n');
171
+ await writeExecutable(join(fakeBin, 'install'), `#!/usr/bin/env bash
172
+ set -euo pipefail
173
+ target="\${!#}"
174
+ if [[ "\${FAIL_STATE_INSTALL:-0}" == 1 && "$target" == */state/active.env ]]; then exit 25; fi
175
+ exec /usr/bin/install "$@"
176
+ `);
408
177
 
409
- const fakePsql = join(fakeBin, 'psql');
410
- await writeFile(fakePsql, '#!/usr/bin/env bash\nset -euo pipefail\n');
411
- await chmod(fakePsql, 0o755);
178
+ return { root, fakeBin, serverProjectsRoot, commandLog, mode };
179
+ }
412
180
 
413
- const fakeMv = join(fakeBin, 'mv');
414
- await writeFile(
415
- fakeMv,
416
- `#!/usr/bin/env bash
417
- set -euo pipefail
418
- if [[ "\${1:-}" == -Tf ]]; then
419
- shift
420
- exec /bin/mv -f "$@"
421
- fi
422
- exec /bin/mv "$@"
423
- `,
424
- );
425
- await chmod(fakeMv, 0o755);
426
- return { directory, fakeBin, commandLog: join(directory, 'commands.log') };
181
+ async function writeExecutable(path, content) {
182
+ await writeFile(path, content);
183
+ await chmod(path, 0o755);
427
184
  }
428
185
 
429
- function runBuild(fixture, extraEnv = {}, ...extraArguments) {
430
- return spawnSync(
431
- 'bash',
432
- ['deploy.sh', 'build', ...extraArguments],
433
- {
434
- cwd: fixture.directory,
435
- encoding: 'utf8',
436
- env: {
437
- ...process.env,
438
- PATH: `${fixture.fakeBin}:${process.env.PATH}`,
439
- ...extraEnv,
440
- },
186
+ function run(root, args, fixture, extraEnv = {}) {
187
+ return spawnSync('bash', args, {
188
+ cwd: root,
189
+ encoding: 'utf8',
190
+ env: {
191
+ ...process.env,
192
+ PATH: `${fixture.fakeBin}:${process.env.PATH}`,
193
+ COMMAND_LOG: fixture.commandLog,
194
+ ...extraEnv,
441
195
  },
442
- );
196
+ });
443
197
  }
444
198
 
445
- function currentDateText() {
446
- const value = new Date();
447
- return [
448
- String(value.getFullYear()).padStart(4, '0'),
449
- String(value.getMonth() + 1).padStart(2, '0'),
450
- String(value.getDate()).padStart(2, '0'),
451
- ].join('');
199
+ function runBuild(fixture, extraEnv = {}, ...extraArgs) {
200
+ return run(fixture.root, ['deploy.sh', 'build', ...extraArgs], fixture, extraEnv);
452
201
  }
453
202
 
454
- test('build 首次生成 001、同日递增并只保留当前完整版本目录', async () => {
455
- const fixture = await createBuildFixture();
456
- const date = currentDateText();
203
+ async function releaseVersion(root) {
204
+ return (await readFile(join(root, 'deploy/release.env'), 'utf8')).trim().slice('RELEASE_VERSION='.length);
205
+ }
457
206
 
458
- const first = runBuild(fixture);
459
- assert.equal(first.status, 0, first.stderr);
460
- assert.equal(
461
- await readFile(join(fixture.directory, 'deploy', 'release.env'), 'utf8'),
462
- `RELEASE_VERSION=${date}-001\n`,
463
- );
464
- assert.deepEqual(
465
- (await readdir(join(fixture.directory, 'deploy'))).sort(),
466
- [`${date}-001`, 'deploy.sh', 'release.env'].sort(),
467
- );
468
- assert.match(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), new RegExp(`${date}-001`));
469
- assert.ok((await stat(join(
470
- fixture.directory,
471
- 'deploy',
472
- `${date}-001`,
473
- 'scripts',
474
- 'apply-migrations.sh',
475
- ))).mode & 0o111);
476
-
477
- const second = runBuild(fixture);
478
- assert.equal(second.status, 0, second.stderr);
479
- assert.equal(
480
- await readFile(join(fixture.directory, 'deploy', 'release.env'), 'utf8'),
481
- `RELEASE_VERSION=${date}-002\n`,
482
- );
483
- assert.deepEqual(
484
- (await readdir(join(fixture.directory, 'deploy'))).sort(),
485
- [`${date}-002`, 'deploy.sh', 'release.env'].sort(),
486
- );
487
- const agents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
488
- assert.match(agents, new RegExp(`${date}-001`));
489
- assert.match(agents, new RegExp(`${date}-002`));
207
+ function runValidator(root, version) {
208
+ return spawnSync(process.execPath, [
209
+ validator,
210
+ join(root, 'deploy/deploy.sh'),
211
+ join(root, `deploy/${version}/nginx/site.conf`),
212
+ join(root, `deploy/${version}/docker/docker-compose.blue-green.yml`),
213
+ join(root, 'deploy'),
214
+ ], { encoding: 'utf8' });
215
+ }
216
+
217
+ async function installOnServer(fixture) {
218
+ const serverRoot = join(fixture.serverProjectsRoot, 'fixture');
219
+ await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
220
+ await chmod(join(serverRoot, 'deploy.sh'), 0o755);
221
+ return serverRoot;
222
+ }
223
+
224
+ function runServer(fixture, serverRoot, command, extraEnv = {}, argument) {
225
+ const args = [join(serverRoot, 'deploy.sh'), command];
226
+ if (argument) args.push(argument);
227
+ return run(fixture.root, args, fixture, {
228
+ SERVER_ROOT: serverRoot,
229
+ NGINX_FAIL_MARKER: join(fixture.root, 'nginx-failed-once'),
230
+ ...extraEnv,
231
+ });
232
+ }
233
+
234
+ test('build 生成根 .env、双镜像和四服务完整包', async () => {
235
+ const fixture = await createFixture();
236
+ const result = runBuild(fixture);
237
+ assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
238
+ const version = await releaseVersion(fixture.root);
239
+ assert.deepEqual((await readdir(join(fixture.root, 'deploy'))).sort(), ['.env', version, 'deploy.sh', 'release.env'].sort());
240
+ await Promise.all([
241
+ access(join(fixture.root, `deploy/${version}/frontend-image.tar`)),
242
+ access(join(fixture.root, `deploy/${version}/backend-image.tar`)),
243
+ ]);
244
+ const validation = runValidator(fixture.root, version);
245
+ assert.equal(validation.status, 0, validation.stderr);
246
+ assert.match(validation.stdout, /valid dual-image deployment package/);
247
+ const log = await readFile(fixture.commandLog, 'utf8');
248
+ assert.match(log, new RegExp(`--tag fixture_frontend:${version}`));
249
+ assert.match(log, new RegExp(`--tag fixture_backend:${version}`));
250
+ assert.match(log, /--build-arg BACKEND_PORT=3000/);
251
+ assert.doesNotMatch(await readFile(join(fixture.root, `deploy/${version}/docker/docker-compose.blue-green.yml`), 'utf8'), /FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|_HEALTH_URL|_VERSION_URL/);
490
252
  });
491
253
 
492
- test('build 门禁失败时不写发布记录且不暴露 deploy 目录', async () => {
493
- const fixture = await createBuildFixture();
494
- const originalAgents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
495
- const result = runBuild(fixture, { FAIL_GATE: 'lint' });
254
+ test('none 模式不伪造数据库文件,manual 模式只打包人工脚本', async () => {
255
+ const none = await createFixture();
256
+ assert.equal(runBuild(none).status, 0);
257
+ const noneVersion = await releaseVersion(none.root);
258
+ await assert.rejects(stat(join(none.root, `deploy/${noneVersion}/database`)));
259
+
260
+ const manual = await createFixture({ mode: 'manual' });
261
+ const built = runBuild(manual);
262
+ assert.equal(built.status, 0, built.stderr);
263
+ assert.match(built.stdout, /migration was not executed/);
264
+ const manualVersion = await releaseVersion(manual.root);
265
+ assert.ok((await stat(join(manual.root, `deploy/${manualVersion}/database/apply-migrations.sh`))).mode & 0o111);
266
+ assert.doesNotMatch(await readFile(join(manual.root, 'deploy/deploy.sh'), 'utf8'), /\bpsql\b/);
267
+ });
496
268
 
497
- assert.equal(result.status, 9);
498
- assert.equal(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), originalAgents);
499
- await assert.rejects(stat(join(fixture.directory, 'deploy')));
269
+ test('连续构建的 deploy.sh、Compose 和 Nginx 配置字节一致', async () => {
270
+ const fixture = await createFixture();
271
+ assert.equal(runBuild(fixture).status, 0);
272
+ const firstVersion = await releaseVersion(fixture.root);
273
+ const first = await configurationHashes(fixture.root, firstVersion);
274
+ assert.equal(runBuild(fixture).status, 0);
275
+ const secondVersion = await releaseVersion(fixture.root);
276
+ const second = await configurationHashes(fixture.root, secondVersion);
277
+ assert.notEqual(firstVersion, secondVersion);
278
+ assert.deepEqual(second, first);
500
279
  });
501
280
 
502
- test('build 拒绝损坏的 AGENTS 发布记录', async () => {
503
- const fixture = await createBuildFixture({
504
- agentsContent: '# Target\n\n## 发布记录\n\n| 版本 | 时间 |\n|---|---|\n',
505
- });
506
- const result = runBuild(fixture);
281
+ async function configurationHashes(root, version) {
282
+ const paths = ['deploy/deploy.sh', `deploy/${version}/docker/docker-compose.blue-green.yml`, `deploy/${version}/nginx/site.conf`];
283
+ return Promise.all(paths.map(async (path) => createHash('sha256').update(await readFile(join(root, path))).digest('hex')));
284
+ }
507
285
 
508
- assert.notEqual(result.status, 0);
509
- assert.match(result.stderr, /release record table/);
510
- await assert.rejects(stat(join(fixture.directory, 'deploy')));
286
+ async function rewriteManifest(versionRoot, directory = versionRoot) {
287
+ const files = [];
288
+ async function walk(current) {
289
+ for (const entry of await readdir(current, { withFileTypes: true })) {
290
+ const path = join(current, entry.name);
291
+ if (entry.isDirectory()) await walk(path);
292
+ else if (entry.isFile() && entry.name !== 'manifest.sha256') files.push(path);
293
+ }
294
+ }
295
+ await walk(directory);
296
+ const lines = await Promise.all(files.sort().map(async (path) => {
297
+ const relative = path.slice(versionRoot.length + 1).split('\\').join('/');
298
+ const hash = createHash('sha256').update(await readFile(path)).digest('hex');
299
+ return `${hash} ${relative}`;
300
+ }));
301
+ await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
302
+ }
303
+
304
+ test('失败构建保留上一发布包和发布账本', async () => {
305
+ const fixture = await createFixture();
306
+ assert.equal(runBuild(fixture).status, 0);
307
+ const version = await releaseVersion(fixture.root);
308
+ const agents = await readFile(join(fixture.root, 'AGENTS.md'), 'utf8');
309
+ const failed = runBuild(fixture, { FAIL_GATE: 'lint' });
310
+ assert.equal(failed.status, 9);
311
+ assert.equal(await releaseVersion(fixture.root), version);
312
+ assert.equal(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), agents);
511
313
  });
512
314
 
513
- test('build 拒绝把生产秘密从迁移目录带入发布包', async () => {
514
- const fixture = await createBuildFixture({ secretMigration: true });
515
- const originalAgents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
315
+ test('dotenv 被作为数据解析,不执行命令替换或覆盖脚本 PATH', async () => {
316
+ const fixture = await createFixture({ extraEnv: 'UNUSED_VALUE=$(touch should-not-exist)\nPATH=/malicious\n' });
516
317
  const result = runBuild(fixture);
318
+ assert.equal(result.status, 0, result.stderr);
319
+ await assert.rejects(access(join(fixture.root, 'should-not-exist')));
320
+ });
517
321
 
518
- assert.notEqual(result.status, 0);
519
- assert.match(result.stderr, /secret file/);
520
- assert.equal(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), originalAgents);
521
- await assert.rejects(stat(join(fixture.directory, 'deploy')));
322
+ test('build 拒绝重复键、漂移基础镜像和额外版本参数', async () => {
323
+ const duplicate = await createFixture({ extraEnv: 'PROJECT_NAME=duplicate\n' });
324
+ assert.notEqual(runBuild(duplicate).status, 0);
325
+ const mutable = await createFixture();
326
+ await writeFile(join(mutable.root, '.env'), (await readFile(join(mutable.root, '.env'), 'utf8')).replace(/nginx@sha256:[0-9a-f]+/, 'nginx:latest'));
327
+ assert.notEqual(runBuild(mutable).status, 0);
328
+ const extra = await createFixture();
329
+ assert.equal(runBuild(extra, {}, '20260808-001').status, 2);
330
+ const logDirectory = await createFixture({ extraEnv: 'LOG_DIR=/tmp/logs\n' });
331
+ const rejected = runBuild(logDirectory);
332
+ assert.notEqual(rejected.status, 0);
333
+ assert.match(rejected.stderr, /must not configure log directories/);
334
+ const legacy = await createFixture({ extraEnv: 'FRONTEND_CONTAINER_PORT=8080\n' });
335
+ const legacyRejected = runBuild(legacy);
336
+ assert.notEqual(legacyRejected.status, 0);
337
+ assert.match(legacyRejected.stderr, /legacy duplicate environment key/);
338
+ const reverseInclude = await createFixture();
339
+ await writeFile(join(reverseInclude.root, '.dockerignore'), `${await readFile(join(reverseInclude.root, '.dockerignore'), 'utf8')}!.env\n`);
340
+ const reverseRejected = runBuild(reverseInclude);
341
+ assert.notEqual(reverseRejected.status, 0);
342
+ assert.match(reverseRejected.stderr, /forbidden reverse include/);
522
343
  });
523
344
 
524
- test('build Docker ignore 不能排除秘密时明确失败', async () => {
525
- const fixture = await createBuildFixture();
526
- await writeFile(join(fixture.directory, '.dockerignore'), '.env\ndeploy/\n');
527
- const originalAgents = await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8');
528
- const result = runBuild(fixture);
345
+ test('validator 拒绝缺失前端镜像、镜像 fallback 和嵌套日志', async () => {
346
+ const fixture = await createFixture();
347
+ assert.equal(runBuild(fixture).status, 0);
348
+ const version = await releaseVersion(fixture.root);
349
+ await unlink(join(fixture.root, `deploy/${version}/frontend-image.tar`));
350
+ assert.notEqual(runValidator(fixture.root, version).status, 0);
351
+
352
+ assert.equal(runBuild(fixture).status, 0);
353
+ const next = await releaseVersion(fixture.root);
354
+ const composePath = join(fixture.root, `deploy/${next}/docker/docker-compose.blue-green.yml`);
355
+ await writeFile(composePath, (await readFile(composePath, 'utf8')).replace('${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}', '${FRONTEND_IMAGE:-fallback}').replace('../../logs:/app/logs', '../../logs/frontend:/app/logs'));
356
+ const validation = runValidator(fixture.root, next);
357
+ assert.notEqual(validation.status, 0);
358
+ assert.match(validation.stderr, /fallback|logs/);
359
+ });
529
360
 
530
- assert.notEqual(result.status, 0);
531
- assert.match(result.stderr, /Docker ignore file is missing required pattern/);
532
- assert.equal(await readFile(join(fixture.directory, 'AGENTS.md'), 'utf8'), originalAgents);
533
- await assert.rejects(stat(join(fixture.directory, 'deploy')));
361
+ test('validator 拒绝版本目录符号链接', async () => {
362
+ const fixture = await createFixture();
363
+ assert.equal(runBuild(fixture).status, 0);
364
+ const version = await releaseVersion(fixture.root);
365
+ await symlink('../outside', join(fixture.root, `deploy/${version}/linked-file`));
366
+ const validation = runValidator(fixture.root, version);
367
+ assert.notEqual(validation.status, 0);
368
+ assert.match(validation.stderr, /symbolic links/);
534
369
  });
535
370
 
536
- test('build start 都拒绝人工版本参数', async () => {
537
- const fixture = await createBuildFixture();
538
- const build = runBuild(fixture, {}, '20260730-001');
539
- const start = spawnSync(
540
- 'bash',
541
- ['deploy.sh', 'start', '20260730-001'],
542
- { cwd: fixture.directory, encoding: 'utf8' },
543
- );
371
+ test('服务器拒绝标签不匹配的镜像归档且不执行 docker load', async () => {
372
+ const fixture = await createFixture();
373
+ assert.equal(runBuild(fixture).status, 0);
374
+ const version = await releaseVersion(fixture.root);
375
+ const versionRoot = join(fixture.root, `deploy/${version}`);
376
+ const badArchiveRoot = await temporary('bad-image-');
377
+ await writeFile(join(badArchiveRoot, 'manifest.json'), `[{"Config":"config.json","RepoTags":["fixture_frontend:${version}","other_frontend:bad"],"Layers":[]}]\n`);
378
+ await writeFile(join(badArchiveRoot, 'config.json'), '{}\n');
379
+ const tarResult = spawnSync('/usr/bin/tar', ['-cf', join(versionRoot, 'frontend-image.tar'), '-C', badArchiveRoot, 'manifest.json', 'config.json'], { encoding: 'utf8' });
380
+ assert.equal(tarResult.status, 0, tarResult.stderr);
381
+ await rewriteManifest(versionRoot);
382
+ const serverRoot = await installOnServer(fixture);
383
+ await writeFile(fixture.commandLog, '');
384
+ const started = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
385
+ assert.notEqual(started.status, 0);
386
+ assert.match(started.stderr, /exactly one expected tag/);
387
+ assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load/);
388
+ });
544
389
 
545
- assert.equal(build.status, 2);
546
- assert.match(build.stderr, /usage/);
547
- assert.equal(start.status, 2);
548
- assert.match(start.stderr, /usage/);
390
+ test('start、replace 和 rollback 成对切换前后端', async () => {
391
+ const fixture = await createFixture();
392
+ assert.equal(runBuild(fixture).status, 0);
393
+ const first = await releaseVersion(fixture.root);
394
+ const serverRoot = await installOnServer(fixture);
395
+ const started = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first });
396
+ assert.equal(started.status, 0, started.stderr);
397
+ assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), new RegExp(`ACTIVE_COLOR=blue[\\s\\S]*${first}`));
398
+
399
+ assert.equal(runBuild(fixture).status, 0);
400
+ const second = await releaseVersion(fixture.root);
401
+ await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
402
+ const replaced = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second });
403
+ assert.equal(replaced.status, 0, replaced.stderr);
404
+ assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), /ACTIVE_COLOR=green/);
405
+ assert.match(await readFile(join(serverRoot, 'state/previous.env'), 'utf8'), new RegExp(first));
406
+ const upstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
407
+ assert.match(upstream, /fixture_frontend_active/);
408
+ assert.match(upstream, /fixture_backend_active/);
409
+
410
+ const rolledBack = runServer(fixture, serverRoot, 'rollback', { EXPECTED_VERSION: first }, first);
411
+ assert.equal(rolledBack.status, 0, rolledBack.stderr);
412
+ assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), new RegExp(first));
549
413
  });
550
414
 
551
- test('上传到任意目录后 start 从 release.env 完成首个蓝色实例切换', async () => {
552
- const fixture = await createBuildFixture();
553
- const build = runBuild(fixture);
554
- assert.equal(build.status, 0, build.stderr);
555
- const releaseEnv = await readFile(join(fixture.directory, 'deploy', 'release.env'), 'utf8');
556
- const version = releaseEnv.trim().slice('RELEASE_VERSION='.length);
415
+ test('restart、stop、status 只操作当前颜色的一对容器', async () => {
416
+ const fixture = await createFixture();
417
+ assert.equal(runBuild(fixture).status, 0);
418
+ const version = await releaseVersion(fixture.root);
419
+ const serverRoot = await installOnServer(fixture);
420
+ assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version }).status, 0);
421
+ await writeFile(fixture.commandLog, '');
422
+ assert.equal(runServer(fixture, serverRoot, 'restart', { EXPECTED_VERSION: version }).status, 0);
423
+ assert.equal(runServer(fixture, serverRoot, 'stop', { EXPECTED_VERSION: version }).status, 0);
424
+ const statusResult = runServer(fixture, serverRoot, 'status', { EXPECTED_VERSION: version });
425
+ assert.equal(statusResult.status, 0, statusResult.stderr);
426
+ assert.match(statusResult.stdout, new RegExp(version));
427
+ const log = await readFile(fixture.commandLog, 'utf8');
428
+ assert.match(log, /up -d --force-recreate frontend-blue backend-blue/);
429
+ assert.match(log, /stop frontend-blue backend-blue/);
430
+ });
557
431
 
558
- const serverRoot = join(fixture.directory, 'chosen server root');
559
- await cp(join(fixture.directory, 'deploy'), serverRoot, { recursive: true });
432
+ test('remove 只删除本项目容器、网络和两个镜像仓库并保留日志', async () => {
433
+ const fixture = await createFixture();
434
+ assert.equal(runBuild(fixture).status, 0);
435
+ const serverRoot = await installOnServer(fixture);
436
+ await mkdir(join(serverRoot, 'logs'), { recursive: true });
437
+ await writeFile(join(serverRoot, 'logs/backend.log'), 'keep\n');
438
+ await writeFile(fixture.commandLog, '');
439
+ const removed = runServer(fixture, serverRoot, 'remove');
440
+ assert.equal(removed.status, 0, removed.stderr);
441
+ const log = await readFile(fixture.commandLog, 'utf8');
442
+ assert.match(log, /docker rm -f c111 c222/);
443
+ assert.match(log, /docker network rm n111/);
444
+ assert.match(log, /docker image rm -f fixture_backend:20260808-001 fixture_frontend:20260808-001|docker image rm -f fixture_frontend:20260808-001 fixture_backend:20260808-001/);
445
+ assert.doesNotMatch(log, /other_project_frontend:shared/);
446
+ assert.equal(await readFile(join(serverRoot, 'logs/backend.log'), 'utf8'), 'keep\n');
447
+ await access(join(serverRoot, 'release.env'));
448
+ });
449
+
450
+ test('端口冲突在加载镜像或启动容器前失败', async () => {
451
+ const fixture = await createFixture();
452
+ assert.equal(runBuild(fixture).status, 0);
453
+ const version = await releaseVersion(fixture.root);
454
+ const serverRoot = await installOnServer(fixture);
455
+ await mkdir(join(fixture.serverProjectsRoot, 'other/nginx'), { recursive: true });
456
+ await writeFile(join(fixture.serverProjectsRoot, 'other/nginx/site.conf'), '# wdyy-project: other\n# wdyy-port: 9099\nserver { listen 9099; }\n');
457
+ await writeFile(fixture.commandLog, '');
458
+ const result = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
459
+ assert.notEqual(result.status, 0);
460
+ assert.match(result.stderr, /already used by another project/);
461
+ const log = await readFile(fixture.commandLog, 'utf8');
462
+ assert.doesNotMatch(log, /docker load|docker compose/);
463
+ });
464
+
465
+ test('展开的全局 Nginx 中存在非标准项目端口时也拒绝部署', async () => {
466
+ const fixture = await createFixture();
467
+ assert.equal(runBuild(fixture).status, 0);
468
+ const version = await releaseVersion(fixture.root);
469
+ const serverRoot = await installOnServer(fixture);
470
+ await writeFile(fixture.commandLog, '');
471
+ const result = runServer(fixture, serverRoot, 'start', {
472
+ EXPECTED_VERSION: version,
473
+ EXTRA_NGINX_CONFIG: 'server {\n listen 9099;\n}',
474
+ });
475
+ assert.notEqual(result.status, 0);
476
+ assert.match(result.stderr, /expanded Nginx configuration/);
477
+ assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
478
+ });
479
+
480
+ test('其他项目预留任一 Docker blue-green 宿主机端口时在启动前失败', async () => {
481
+ const fixture = await createFixture();
482
+ assert.equal(runBuild(fixture).status, 0);
483
+ const version = await releaseVersion(fixture.root);
484
+ const serverRoot = await installOnServer(fixture);
485
+ await mkdir(join(fixture.serverProjectsRoot, 'other/nginx'), { recursive: true });
560
486
  await writeFile(
561
- join(serverRoot, '.env.production'),
562
- `PROJECT_NAME=fixture
563
- DATABASE_URL=postgresql://fixture
564
- BACKEND_BIND_IP=127.0.0.1
565
- BACKEND_LISTEN_HOST=0.0.0.0
566
- BACKEND_CONTAINER_PORT=3000
567
- BACKEND_CONTAINER_HEALTH_URL=http://127.0.0.1:3000/health
568
- BLUE_PORT=3001
569
- GREEN_PORT=3002
570
- BLUE_HEALTH_URL=http://127.0.0.1:3001/health
571
- GREEN_HEALTH_URL=http://127.0.0.1:3002/health
572
- BLUE_VERSION_URL=http://127.0.0.1:3001/version
573
- GREEN_VERSION_URL=http://127.0.0.1:3002/version
574
- BLUE_UPSTREAM=127.0.0.1:3001
575
- GREEN_UPSTREAM=127.0.0.1:3002
576
- BACKEND_PROXY_URL=http://backend_active
577
- `,
578
- );
579
- const start = spawnSync(
580
- 'bash',
581
- [join(serverRoot, 'deploy.sh'), 'start'],
582
- {
583
- cwd: fixture.directory,
584
- encoding: 'utf8',
585
- env: {
586
- ...process.env,
587
- PATH: `${fixture.fakeBin}:${process.env.PATH}`,
588
- COMMAND_LOG: fixture.commandLog,
589
- EXPECTED_VERSION: version,
590
- },
591
- },
487
+ join(fixture.serverProjectsRoot, 'other/nginx/site.conf'),
488
+ '# wdyy-project: other\n# wdyy-port: 9100\n# wdyy-docker-ports: 19091,29092,29093,29094\nserver { listen 9100; }\n',
592
489
  );
490
+ await writeFile(fixture.commandLog, '');
491
+ const result = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
492
+ assert.notEqual(result.status, 0);
493
+ assert.match(result.stderr, /Docker host port 19091/);
494
+ assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
495
+ });
496
+
497
+ test('其他运行中容器发布项目 Docker 端口时在镜像加载前失败', async () => {
498
+ const fixture = await createFixture();
499
+ assert.equal(runBuild(fixture).status, 0);
500
+ const version = await releaseVersion(fixture.root);
501
+ const serverRoot = await installOnServer(fixture);
502
+ await writeFile(fixture.commandLog, '');
503
+ const result = runServer(fixture, serverRoot, 'start', {
504
+ EXPECTED_VERSION: version,
505
+ DOCKER_RUNNING_PORTS: 'other_project|0.0.0.0:19093->3000/tcp, [::]:19093->3000/tcp',
506
+ });
507
+ assert.notEqual(result.status, 0);
508
+ assert.match(result.stderr, /Docker host port 19093 is already published by another container/);
509
+ assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
510
+ });
511
+
512
+ test('replace 的 Nginx reload 失败时恢复旧 upstream 和状态', async () => {
513
+ const fixture = await createFixture();
514
+ assert.equal(runBuild(fixture).status, 0);
515
+ const first = await releaseVersion(fixture.root);
516
+ const serverRoot = await installOnServer(fixture);
517
+ assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first }).status, 0);
518
+ const oldUpstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
519
+ const oldState = await readFile(join(serverRoot, 'state/active.env'), 'utf8');
520
+ assert.equal(runBuild(fixture).status, 0);
521
+ const second = await releaseVersion(fixture.root);
522
+ await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
523
+ const failed = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second, FAIL_NGINX_RELOAD_ONCE: '1' });
524
+ assert.notEqual(failed.status, 0);
525
+ assert.equal(await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8'), oldUpstream);
526
+ assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
527
+ });
528
+
529
+ test('首次切流后的外部端口验证失败时不提交状态并移除候选 Nginx 文件', async () => {
530
+ const fixture = await createFixture();
531
+ assert.equal(runBuild(fixture).status, 0);
532
+ const version = await releaseVersion(fixture.root);
533
+ const serverRoot = await installOnServer(fixture);
534
+ const failed = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version, FAIL_PUBLIC_ENDPOINT: '1' });
535
+ assert.notEqual(failed.status, 0);
536
+ assert.match(failed.stderr, /public endpoint verification failed/);
537
+ await assert.rejects(access(join(serverRoot, 'state/active.env')));
538
+ await assert.rejects(access(join(serverRoot, 'nginx/site.conf')));
539
+ await assert.rejects(access(join(serverRoot, 'nginx/active-upstreams.conf')));
540
+ });
541
+
542
+ test('状态提交失败时恢复旧流量和旧状态', async () => {
543
+ const fixture = await createFixture();
544
+ assert.equal(runBuild(fixture).status, 0);
545
+ const first = await releaseVersion(fixture.root);
546
+ const serverRoot = await installOnServer(fixture);
547
+ assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first }).status, 0);
548
+ const oldUpstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
549
+ const oldState = await readFile(join(serverRoot, 'state/active.env'), 'utf8');
550
+ assert.equal(runBuild(fixture).status, 0);
551
+ const second = await releaseVersion(fixture.root);
552
+ await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
553
+ const failed = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second, FAIL_STATE_INSTALL: '1' });
554
+ assert.notEqual(failed.status, 0);
555
+ assert.equal(await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8'), oldUpstream);
556
+ assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
557
+ });
593
558
 
594
- assert.equal(start.status, 0, start.stderr);
595
- assert.equal(await readlink(join(serverRoot, 'current')), `releases/${version}`);
596
- assert.match(await readFile(join(serverRoot, 'state', 'active.env'), 'utf8'), /ACTIVE_COLOR=blue/);
597
- assert.match(await readFile(join(serverRoot, 'state', 'active.env'), 'utf8'), new RegExp(version));
598
- assert.ok(await readFile(join(serverRoot, 'releases', version, 'index.html'), 'utf8'));
599
- const nginxSite = await readFile(join(serverRoot, 'nginx', 'site.conf'), 'utf8');
600
- assert.ok(!nginxSite.includes('__DEPLOY_ROOT__'));
601
- assert.ok(!nginxSite.includes('__BACKEND_PROXY_URL__'));
602
- assert.ok(nginxSite.includes(serverRoot));
603
- assert.match(nginxSite, /proxy_pass http:\/\/backend_active;/);
604
- const commandLog = await readFile(fixture.commandLog, 'utf8');
605
- assert.match(commandLog, /migration/);
606
- assert.match(commandLog, /docker load/);
607
- assert.match(commandLog, /backend-blue/);
608
- assert.match(commandLog, /nginx -t/);
609
- assert.match(commandLog, /nginx -s reload/);
559
+ test('服务器命令拒绝额外参数和错误部署根', async () => {
560
+ const fixture = await createFixture();
561
+ assert.equal(runBuild(fixture).status, 0);
562
+ const serverRoot = await installOnServer(fixture);
563
+ assert.equal(runServer(fixture, serverRoot, 'start', {}, 'unexpected').status, 2);
564
+ const wrongRoot = join(fixture.root, 'wrong');
565
+ await cp(join(fixture.root, 'deploy'), wrongRoot, { recursive: true });
566
+ const wrong = runServer(fixture, wrongRoot, 'status');
567
+ assert.notEqual(wrong.status, 0);
568
+ assert.match(wrong.stderr, /server deploy root must be exactly/);
610
569
  });