@wdyy/skills 0.1.5 → 0.1.7
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 +3 -3
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +51 -46
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +133 -7
- 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 +201 -32
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +548 -51
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +11 -4
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +835 -142
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +48 -12
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +17 -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 -17
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +8 -7
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +4 -3
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +50 -1
- package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +7 -0
- package/README.md +2 -2
- 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,72 +1,569 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
access, chmod, cp, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, unlink, writeFile,
|
|
5
|
+
} from 'node:fs/promises';
|
|
3
6
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
5
9
|
import { afterEach, test } from 'node:test';
|
|
6
10
|
import { spawnSync } from 'node:child_process';
|
|
7
11
|
|
|
8
|
-
const
|
|
12
|
+
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const skillRoot = resolve(scriptsRoot, '..');
|
|
14
|
+
const templatesRoot = join(skillRoot, 'templates');
|
|
15
|
+
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
16
|
+
const deployTemplate = join(templatesRoot, 'deploy.sh.template');
|
|
17
|
+
const composeTemplate = join(templatesRoot, 'docker-compose.blue-green.yml');
|
|
18
|
+
const nginxTemplate = join(templatesRoot, 'nginx-upstream.template.conf');
|
|
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');
|
|
9
23
|
const temporaryDirectories = [];
|
|
10
24
|
|
|
11
25
|
afterEach(async () => {
|
|
12
|
-
await Promise.all(
|
|
13
|
-
temporaryDirectories.splice(0).map((directory) =>
|
|
14
|
-
rm(directory, { force: true, recursive: true }),
|
|
15
|
-
),
|
|
16
|
-
);
|
|
26
|
+
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
17
27
|
});
|
|
18
28
|
|
|
19
|
-
async function
|
|
20
|
-
const
|
|
21
|
-
temporaryDirectories.push(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
async function temporary(prefix) {
|
|
30
|
+
const path = await mkdtemp(join(tmpdir(), prefix));
|
|
31
|
+
temporaryDirectories.push(path);
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
|
|
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}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
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');
|
|
74
|
+
await Promise.all([
|
|
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 }),
|
|
80
|
+
]);
|
|
81
|
+
await Promise.all([
|
|
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, ''),
|
|
92
|
+
]);
|
|
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);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await writeExecutable(join(fakeBin, 'pnpm'), `#!/usr/bin/env bash
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
|
|
106
|
+
[[ "\${FAIL_GATE:-}" != "\${1:-}" ]] || exit 9
|
|
107
|
+
if [[ "\${1:-}" == build ]]; then
|
|
108
|
+
mkdir -p src/frontend/dist
|
|
109
|
+
printf '<!doctype html><div id="app"></div>\n' > src/frontend/dist/index.html
|
|
110
|
+
fi
|
|
111
|
+
`);
|
|
112
|
+
await writeExecutable(join(fakeBin, 'docker'), `#!/usr/bin/env bash
|
|
113
|
+
set -euo pipefail
|
|
114
|
+
printf 'docker %s\n' "$*" >> "$COMMAND_LOG"
|
|
115
|
+
case "\${1:-}" in
|
|
116
|
+
build) exit 0 ;;
|
|
117
|
+
image)
|
|
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
|
|
122
|
+
;;
|
|
123
|
+
save)
|
|
124
|
+
shift; output=; tag=
|
|
125
|
+
while [[ "$#" -gt 0 ]]; do
|
|
126
|
+
if [[ "$1" == -o ]]; then output="$2"; shift 2; else tag="$1"; shift; fi
|
|
127
|
+
done
|
|
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"
|
|
133
|
+
;;
|
|
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
|
|
143
|
+
;;
|
|
144
|
+
network)
|
|
145
|
+
if [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_NETWORK_IDS:-n111}"; fi
|
|
146
|
+
;;
|
|
147
|
+
rm) exit 0 ;;
|
|
148
|
+
*) echo "unexpected docker command: $*" >&2; exit 10 ;;
|
|
33
149
|
esac
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
150
|
+
`);
|
|
151
|
+
await writeExecutable(join(fakeBin, 'curl'), `#!/usr/bin/env bash
|
|
152
|
+
set -euo pipefail
|
|
153
|
+
url="\${!#}"
|
|
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
|
|
158
|
+
set -euo pipefail
|
|
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
|
|
165
|
+
fi
|
|
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
|
+
`);
|
|
177
|
+
|
|
178
|
+
return { root, fakeBin, serverProjectsRoot, commandLog, mode };
|
|
48
179
|
}
|
|
49
180
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
[validator.pathname, files.deploy, files.nginx, files.compose],
|
|
55
|
-
{ encoding: 'utf8' },
|
|
56
|
-
);
|
|
181
|
+
async function writeExecutable(path, content) {
|
|
182
|
+
await writeFile(path, content);
|
|
183
|
+
await chmod(path, 0o755);
|
|
184
|
+
}
|
|
57
185
|
|
|
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,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function runBuild(fixture, extraEnv = {}, ...extraArgs) {
|
|
200
|
+
return run(fixture.root, ['deploy.sh', 'build', ...extraArgs], fixture, extraEnv);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function releaseVersion(root) {
|
|
204
|
+
return (await readFile(join(root, 'deploy/release.env'), 'utf8')).trim().slice('RELEASE_VERSION='.length);
|
|
205
|
+
}
|
|
206
|
+
|
|
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/);
|
|
252
|
+
});
|
|
253
|
+
|
|
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
|
+
});
|
|
268
|
+
|
|
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);
|
|
279
|
+
});
|
|
280
|
+
|
|
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
|
+
}
|
|
285
|
+
|
|
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);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('dotenv 被作为数据解析,不执行命令替换或覆盖脚本 PATH', async () => {
|
|
316
|
+
const fixture = await createFixture({ extraEnv: 'UNUSED_VALUE=$(touch should-not-exist)\nPATH=/malicious\n' });
|
|
317
|
+
const result = runBuild(fixture);
|
|
58
318
|
assert.equal(result.status, 0, result.stderr);
|
|
59
|
-
assert.
|
|
319
|
+
await assert.rejects(access(join(fixture.root, 'should-not-exist')));
|
|
320
|
+
});
|
|
321
|
+
|
|
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/);
|
|
343
|
+
});
|
|
344
|
+
|
|
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
|
+
});
|
|
360
|
+
|
|
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/);
|
|
369
|
+
});
|
|
370
|
+
|
|
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
|
+
});
|
|
389
|
+
|
|
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));
|
|
413
|
+
});
|
|
414
|
+
|
|
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/);
|
|
60
430
|
});
|
|
61
431
|
|
|
62
|
-
test('
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 });
|
|
486
|
+
await writeFile(
|
|
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',
|
|
68
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
|
+
});
|
|
69
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
|
+
});
|
|
70
507
|
assert.notEqual(result.status, 0);
|
|
71
|
-
assert.match(result.stderr, /
|
|
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
|
+
});
|
|
558
|
+
|
|
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/);
|
|
72
569
|
});
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
ARG BACKEND_BASE_IMAGE
|
|
2
|
+
FROM ${BACKEND_BASE_IMAGE} AS build
|
|
2
3
|
WORKDIR /app
|
|
3
|
-
COPY package.json pnpm-lock.yaml ./
|
|
4
|
+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
4
5
|
RUN corepack enable && pnpm install --frozen-lockfile
|
|
5
6
|
COPY . .
|
|
6
7
|
RUN pnpm build
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
ARG BACKEND_BASE_IMAGE
|
|
10
|
+
FROM ${BACKEND_BASE_IMAGE} AS runtime
|
|
9
11
|
WORKDIR /app
|
|
12
|
+
ARG RELEASE_VERSION
|
|
13
|
+
ARG BACKEND_PORT
|
|
10
14
|
ENV NODE_ENV=production
|
|
15
|
+
ENV RELEASE_VERSION=${RELEASE_VERSION}
|
|
16
|
+
ENV PORT=${BACKEND_PORT}
|
|
17
|
+
LABEL org.opencontainers.image.version=${RELEASE_VERSION}
|
|
11
18
|
COPY --from=build /app/package.json ./
|
|
12
19
|
COPY --from=build /app/node_modules ./node_modules
|
|
13
20
|
COPY --from=build /app/dist ./dist
|
|
14
|
-
EXPOSE
|
|
21
|
+
EXPOSE ${BACKEND_PORT}
|
|
15
22
|
CMD ["node", "dist/main.js"]
|