@wdyy/skills 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +53 -61
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +92 -88
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +213 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +106 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +168 -147
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +485 -526
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +10 -8
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +598 -416
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +46 -12
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +2 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +30 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +24 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +7 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +17 -18
- package/README.md +66 -10
- package/lib/wdyy-cli.js +35 -9
- package/package.json +1 -1
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
43
|
-
const
|
|
44
|
-
temporaryDirectories.push(
|
|
45
|
-
return
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
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(
|
|
79
|
-
mkdir(join(
|
|
80
|
-
mkdir(join(
|
|
81
|
-
mkdir(join(
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
286
|
-
await chmod(join(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
join(
|
|
290
|
-
'
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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><
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
printf '
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
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" ==
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
await chmod(fakePsql, 0o755);
|
|
178
|
+
return { root, fakeBin, serverProjectsRoot, commandLog, mode };
|
|
179
|
+
}
|
|
412
180
|
|
|
413
|
-
|
|
414
|
-
await writeFile(
|
|
415
|
-
|
|
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
|
|
430
|
-
return spawnSync(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
{
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
446
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
203
|
+
async function releaseVersion(root) {
|
|
204
|
+
return (await readFile(join(root, 'deploy/release.env'), 'utf8')).trim().slice('RELEASE_VERSION='.length);
|
|
205
|
+
}
|
|
457
206
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
`
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
);
|
|
487
|
-
const
|
|
488
|
-
assert.
|
|
489
|
-
|
|
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('
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
const
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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('
|
|
514
|
-
const fixture = await
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
assert.
|
|
521
|
-
await
|
|
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('
|
|
525
|
-
const fixture = await
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
assert.equal(
|
|
533
|
-
await
|
|
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('
|
|
537
|
-
const fixture = await
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
assert.equal(
|
|
548
|
-
|
|
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('
|
|
552
|
-
const fixture = await
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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(
|
|
562
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
assert.
|
|
597
|
-
|
|
598
|
-
assert.
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
assert.
|
|
603
|
-
assert.match(
|
|
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
|
});
|