@wdyy/skills 0.1.11 → 0.1.12
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 +31 -40
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -55
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +9 -5
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +14 -2
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +60 -109
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +151 -277
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +182 -576
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -5
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
- package/README.md +4 -2
- package/lib/wdyy-cli.js +3 -2
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -25
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
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
|
+
import { access, chmod, cp, copyFile, mkdir, mkdtemp, readFile, readdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
6
4
|
import { tmpdir } from 'node:os';
|
|
7
5
|
import { dirname, join, resolve } from 'node:path';
|
|
8
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,12 +12,7 @@ const skillRoot = resolve(scriptsRoot, '..');
|
|
|
14
12
|
const templatesRoot = join(skillRoot, 'templates');
|
|
15
13
|
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
16
14
|
const deployTemplate = join(templatesRoot, 'deploy.sh.template');
|
|
17
|
-
const composeTemplate = join(templatesRoot, 'docker-compose.
|
|
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');
|
|
15
|
+
const composeTemplate = join(templatesRoot, 'docker-compose.yml');
|
|
23
16
|
const temporaryDirectories = [];
|
|
24
17
|
|
|
25
18
|
afterEach(async () => {
|
|
@@ -53,37 +46,34 @@ DB_SCHEMA=
|
|
|
53
46
|
|
|
54
47
|
# 部署
|
|
55
48
|
PROJECT_NAME=fixture
|
|
56
|
-
PROJECT_HTTP_PORT=9099
|
|
57
49
|
DOCKER_BIND_IP=127.0.0.1
|
|
58
|
-
FRONTEND_BLUE_PORT=19091
|
|
59
|
-
FRONTEND_GREEN_PORT=19092
|
|
60
|
-
BACKEND_BLUE_PORT=19093
|
|
61
|
-
BACKEND_GREEN_PORT=19094
|
|
62
50
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
63
51
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
64
52
|
${extra}`;
|
|
65
53
|
}
|
|
66
54
|
|
|
55
|
+
async function writeExecutable(path, content) {
|
|
56
|
+
await writeFile(path, content);
|
|
57
|
+
await chmod(path, 0o755);
|
|
58
|
+
}
|
|
59
|
+
|
|
67
60
|
async function createFixture({ agents = '# Target project\n', extraEnv = '' } = {}) {
|
|
68
|
-
const root = await temporary('wdyy-deploy-');
|
|
61
|
+
const root = await temporary('wdyy-direct-deploy-');
|
|
69
62
|
const fakeBin = join(root, 'fake-bin');
|
|
70
|
-
const serverProjectsRoot = join(root, 'servers');
|
|
71
63
|
const commandLog = join(root, 'commands.log');
|
|
72
64
|
await Promise.all([
|
|
73
65
|
mkdir(fakeBin, { recursive: true }),
|
|
74
66
|
mkdir(join(root, 'src/frontend'), { recursive: true }),
|
|
75
67
|
mkdir(join(root, 'src/backend'), { recursive: true }),
|
|
76
68
|
mkdir(join(root, 'scripts/deployment'), { recursive: true }),
|
|
77
|
-
mkdir(serverProjectsRoot, { recursive: true }),
|
|
78
69
|
]);
|
|
79
70
|
await Promise.all([
|
|
80
71
|
copyFile(deployTemplate, join(root, 'deploy.sh')),
|
|
81
|
-
copyFile(
|
|
82
|
-
copyFile(
|
|
83
|
-
copyFile(composeTemplate, join(root, 'scripts/deployment/docker-compose.
|
|
84
|
-
copyFile(
|
|
85
|
-
copyFile(
|
|
86
|
-
copyFile(dockerignoreTemplate, join(root, '.dockerignore')),
|
|
72
|
+
copyFile(join(templatesRoot, 'frontend.Dockerfile.template'), join(root, 'src/frontend/Dockerfile')),
|
|
73
|
+
copyFile(join(templatesRoot, 'Dockerfile.template'), join(root, 'src/backend/Dockerfile')),
|
|
74
|
+
copyFile(composeTemplate, join(root, 'scripts/deployment/docker-compose.yml')),
|
|
75
|
+
copyFile(join(templatesRoot, 'frontend-container.conf.template'), join(root, 'scripts/deployment/frontend-container.conf.template')),
|
|
76
|
+
copyFile(join(templatesRoot, 'dockerignore.template'), join(root, '.dockerignore')),
|
|
87
77
|
writeFile(join(root, 'AGENTS.md'), agents),
|
|
88
78
|
writeFile(join(root, '.env'), envText(extraEnv)),
|
|
89
79
|
writeFile(commandLog, ''),
|
|
@@ -95,10 +85,7 @@ async function createFixture({ agents = '# Target project\n', extraEnv = '' } =
|
|
|
95
85
|
set -euo pipefail
|
|
96
86
|
printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
|
|
97
87
|
[[ "\${FAIL_GATE:-}" != "\${1:-}" ]] || exit 9
|
|
98
|
-
if [[ "\${1:-}" == build ]]; then
|
|
99
|
-
mkdir -p src/frontend/dist
|
|
100
|
-
printf '<!doctype html><div id="app"></div>\n' > src/frontend/dist/index.html
|
|
101
|
-
fi
|
|
88
|
+
if [[ "\${1:-}" == build ]]; then mkdir -p src/frontend/dist; printf '<div id="app"></div>\n' > src/frontend/dist/index.html; fi
|
|
102
89
|
`);
|
|
103
90
|
await writeExecutable(join(fakeBin, 'docker'), `#!/usr/bin/env bash
|
|
104
91
|
set -euo pipefail
|
|
@@ -106,89 +93,41 @@ printf 'docker %s\n' "$*" >> "$COMMAND_LOG"
|
|
|
106
93
|
case "\${1:-}" in
|
|
107
94
|
build) exit 0 ;;
|
|
108
95
|
image)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
fixture_backend:20260808-001
|
|
112
|
-
other_project_frontend:shared}"; fi
|
|
96
|
+
[[ "\${2:-}" == inspect ]] || exit 10
|
|
97
|
+
tag="\${!#}"; printf '%s\n' "\${tag##*:}"
|
|
113
98
|
;;
|
|
114
99
|
save)
|
|
115
100
|
shift; output=; tag=
|
|
116
|
-
while [[ "$#" -gt 0 ]]; do
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
work="$(mktemp -d)"
|
|
120
|
-
printf '[{"Config":"config.json","RepoTags":["%s"],"Layers":[]}]\n' "$tag" > "$work/manifest.json"
|
|
121
|
-
printf '{}\n' > "$work/config.json"
|
|
122
|
-
/usr/bin/tar -cf "$output" -C "$work" manifest.json config.json
|
|
123
|
-
/bin/rm -rf "$work"
|
|
101
|
+
while [[ "$#" -gt 0 ]]; do if [[ "$1" == -o ]]; then output="$2"; shift 2; else tag="$1"; shift; fi; done
|
|
102
|
+
work="$(mktemp -d)"; printf '[{"Config":"config.json","RepoTags":["%s"],"Layers":[]}]\n' "$tag" > "$work/manifest.json"; printf '{}\n' > "$work/config.json"
|
|
103
|
+
/usr/bin/tar -cf "$output" -C "$work" manifest.json config.json; /bin/rm -rf "$work"
|
|
124
104
|
;;
|
|
125
105
|
load) exit 0 ;;
|
|
126
106
|
compose) exit 0 ;;
|
|
127
|
-
ps)
|
|
128
|
-
if [[ "\${2:-}" == -aq ]]; then
|
|
129
|
-
printf '%s\n' "\${DOCKER_CONTAINER_IDS:-c111
|
|
130
|
-
c222}"
|
|
131
|
-
elif [[ "\${2:-}" == --format && -n "\${DOCKER_RUNNING_PORTS:-}" ]]; then
|
|
132
|
-
printf '%s\n' "$DOCKER_RUNNING_PORTS"
|
|
133
|
-
fi
|
|
134
|
-
;;
|
|
135
|
-
network)
|
|
136
|
-
if [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_NETWORK_IDS:-n111}"; fi
|
|
137
|
-
;;
|
|
138
|
-
rm) exit 0 ;;
|
|
139
107
|
*) echo "unexpected docker command: $*" >&2; exit 10 ;;
|
|
140
108
|
esac
|
|
141
109
|
`);
|
|
142
110
|
await writeExecutable(join(fakeBin, 'curl'), `#!/usr/bin/env bash
|
|
143
111
|
set -euo pipefail
|
|
144
112
|
url="\${!#}"
|
|
145
|
-
if [[ "\${
|
|
113
|
+
if [[ "\${FAIL_HEALTH:-}" == frontend && "$url" == *:5173/* ]]; then exit 26; fi
|
|
114
|
+
if [[ "\${FAIL_HEALTH:-}" == backend && "$url" == *:3000/* ]]; then exit 27; fi
|
|
146
115
|
if [[ "$url" == */version ]]; then printf '%s' "\${EXPECTED_VERSION:?}"; else printf 'ok'; fi
|
|
147
116
|
`);
|
|
148
|
-
|
|
149
|
-
set -euo pipefail
|
|
150
|
-
printf 'nginx %s\n' "$*" >> "$COMMAND_LOG"
|
|
151
|
-
if [[ "\${1:-}" == -t ]]; then [[ "\${FAIL_NGINX_TEST:-0}" != 1 ]] || exit 23; exit 0; fi
|
|
152
|
-
if [[ "\${1:-}" == -T ]]; then
|
|
153
|
-
[[ ! -f "$SERVER_ROOT/nginx/site.conf" ]] || cat "$SERVER_ROOT/nginx/site.conf"
|
|
154
|
-
[[ -z "\${EXTRA_NGINX_CONFIG:-}" ]] || printf '%s\n' "$EXTRA_NGINX_CONFIG"
|
|
155
|
-
exit 0
|
|
156
|
-
fi
|
|
157
|
-
if [[ "\${1:-}" == -s && "\${2:-}" == reload && "\${FAIL_NGINX_RELOAD_ONCE:-0}" == 1 && ! -f "$NGINX_FAIL_MARKER" ]]; then
|
|
158
|
-
: > "$NGINX_FAIL_MARKER"; exit 24
|
|
159
|
-
fi
|
|
160
|
-
`);
|
|
161
|
-
await writeExecutable(join(fakeBin, 'flock'), '#!/usr/bin/env bash\nexit 0\n');
|
|
162
|
-
await writeExecutable(join(fakeBin, 'install'), `#!/usr/bin/env bash
|
|
163
|
-
set -euo pipefail
|
|
164
|
-
target="\${!#}"
|
|
165
|
-
if [[ "\${FAIL_STATE_INSTALL:-0}" == 1 && "$target" == */state/active.env ]]; then exit 25; fi
|
|
166
|
-
exec /usr/bin/install "$@"
|
|
167
|
-
`);
|
|
168
|
-
|
|
169
|
-
return { root, fakeBin, serverProjectsRoot, commandLog };
|
|
117
|
+
return { root, fakeBin, commandLog };
|
|
170
118
|
}
|
|
171
119
|
|
|
172
|
-
|
|
173
|
-
await writeFile(path, content);
|
|
174
|
-
await chmod(path, 0o755);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function run(root, args, fixture, extraEnv = {}) {
|
|
120
|
+
function run(fixture, args, { input, extraEnv = {}, cwd = fixture.root } = {}) {
|
|
178
121
|
return spawnSync('bash', args, {
|
|
179
|
-
cwd
|
|
122
|
+
cwd,
|
|
180
123
|
encoding: 'utf8',
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
PATH: `${fixture.fakeBin}:${process.env.PATH}`,
|
|
184
|
-
COMMAND_LOG: fixture.commandLog,
|
|
185
|
-
...extraEnv,
|
|
186
|
-
},
|
|
124
|
+
input,
|
|
125
|
+
env: { ...process.env, PATH: `${fixture.fakeBin}:${process.env.PATH}`, COMMAND_LOG: fixture.commandLog, ...extraEnv },
|
|
187
126
|
});
|
|
188
127
|
}
|
|
189
128
|
|
|
190
129
|
function runBuild(fixture, extraEnv = {}, ...extraArgs) {
|
|
191
|
-
return run(fixture
|
|
130
|
+
return run(fixture, ['deploy.sh', 'build', ...extraArgs], { extraEnv });
|
|
192
131
|
}
|
|
193
132
|
|
|
194
133
|
async function releaseVersion(root) {
|
|
@@ -199,30 +138,44 @@ function runValidator(root, version) {
|
|
|
199
138
|
return spawnSync(process.execPath, [
|
|
200
139
|
validator,
|
|
201
140
|
join(root, 'deploy/deploy.sh'),
|
|
202
|
-
join(root, `deploy/${version}/
|
|
203
|
-
join(root, `deploy/${version}/docker/docker-compose.blue-green.yml`),
|
|
141
|
+
join(root, `deploy/${version}/docker/docker-compose.yml`),
|
|
204
142
|
join(root, 'deploy'),
|
|
205
143
|
], { encoding: 'utf8' });
|
|
206
144
|
}
|
|
207
145
|
|
|
208
146
|
async function installOnServer(fixture) {
|
|
209
|
-
const serverRoot =
|
|
147
|
+
const serverRoot = await temporary('wdyy-server-root-');
|
|
210
148
|
await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
|
|
211
149
|
await chmod(join(serverRoot, 'deploy.sh'), 0o755);
|
|
212
150
|
return serverRoot;
|
|
213
151
|
}
|
|
214
152
|
|
|
215
|
-
function runServer(fixture, serverRoot,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
NGINX_FAIL_MARKER: join(fixture.root, 'nginx-failed-once'),
|
|
221
|
-
...extraEnv,
|
|
153
|
+
function runServer(fixture, serverRoot, choice, extraEnv = {}, args = []) {
|
|
154
|
+
return run(fixture, [join(serverRoot, 'deploy.sh'), ...args], {
|
|
155
|
+
cwd: fixture.root,
|
|
156
|
+
input: `${choice}\n`,
|
|
157
|
+
extraEnv,
|
|
222
158
|
});
|
|
223
159
|
}
|
|
224
160
|
|
|
225
|
-
|
|
161
|
+
async function rewriteManifest(versionRoot) {
|
|
162
|
+
const files = [];
|
|
163
|
+
async function walk(current) {
|
|
164
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
165
|
+
const path = join(current, entry.name);
|
|
166
|
+
if (entry.isDirectory()) await walk(path);
|
|
167
|
+
else if (entry.isFile() && entry.name !== 'manifest.sha256') files.push(path);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await walk(versionRoot);
|
|
171
|
+
const lines = await Promise.all(files.sort().map(async (path) => {
|
|
172
|
+
const relativePath = path.slice(versionRoot.length + 1).split('\\').join('/');
|
|
173
|
+
return `${createHash('sha256').update(await readFile(path)).digest('hex')} ${relativePath}`;
|
|
174
|
+
}));
|
|
175
|
+
await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test('build 导出带版本号双镜像和双服务完整包', async () => {
|
|
226
179
|
const fixture = await createFixture();
|
|
227
180
|
const result = runBuild(fixture);
|
|
228
181
|
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
@@ -234,57 +187,27 @@ test('build 生成根 .env、双镜像和四服务完整包', async () => {
|
|
|
234
187
|
]);
|
|
235
188
|
const validation = runValidator(fixture.root, version);
|
|
236
189
|
assert.equal(validation.status, 0, validation.stderr);
|
|
237
|
-
assert.match(validation.stdout, /valid dual-image deployment package/);
|
|
190
|
+
assert.match(validation.stdout, /valid direct dual-image deployment package/);
|
|
238
191
|
const log = await readFile(fixture.commandLog, 'utf8');
|
|
239
192
|
assert.match(log, new RegExp(`--tag fixture_frontend:${version}`));
|
|
240
193
|
assert.match(log, new RegExp(`--tag fixture_backend:${version}`));
|
|
241
|
-
|
|
242
|
-
assert.
|
|
194
|
+
const compose = await readFile(join(fixture.root, `deploy/${version}/docker/docker-compose.yml`), 'utf8');
|
|
195
|
+
assert.deepEqual([...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]), ['frontend', 'backend']);
|
|
196
|
+
assert.doesNotMatch(compose, /blue|green|PROJECT_HTTP_PORT|nginx\/site/);
|
|
243
197
|
});
|
|
244
198
|
|
|
245
|
-
test('
|
|
199
|
+
test('连续构建保持 deploy.sh 与 Compose 字节一致', async () => {
|
|
246
200
|
const fixture = await createFixture();
|
|
247
201
|
assert.equal(runBuild(fixture).status, 0);
|
|
248
|
-
const
|
|
249
|
-
await
|
|
250
|
-
assert.doesNotMatch(await readFile(join(fixture.root, 'deploy/deploy.sh'), 'utf8'), /apply-migrations\.sh|database\/migrations|\bpsql\b/);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('连续构建的 deploy.sh、Compose 和 Nginx 配置字节一致', async () => {
|
|
254
|
-
const fixture = await createFixture();
|
|
255
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
256
|
-
const firstVersion = await releaseVersion(fixture.root);
|
|
257
|
-
const first = await configurationHashes(fixture.root, firstVersion);
|
|
202
|
+
const first = await releaseVersion(fixture.root);
|
|
203
|
+
const hashes = await Promise.all(['deploy/deploy.sh', `deploy/${first}/docker/docker-compose.yml`].map(async (path) => createHash('sha256').update(await readFile(join(fixture.root, path))).digest('hex')));
|
|
258
204
|
assert.equal(runBuild(fixture).status, 0);
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
assert.deepEqual(
|
|
205
|
+
const second = await releaseVersion(fixture.root);
|
|
206
|
+
assert.notEqual(second, first);
|
|
207
|
+
const nextHashes = await Promise.all(['deploy/deploy.sh', `deploy/${second}/docker/docker-compose.yml`].map(async (path) => createHash('sha256').update(await readFile(join(fixture.root, path))).digest('hex')));
|
|
208
|
+
assert.deepEqual(nextHashes, hashes);
|
|
263
209
|
});
|
|
264
210
|
|
|
265
|
-
async function configurationHashes(root, version) {
|
|
266
|
-
const paths = ['deploy/deploy.sh', `deploy/${version}/docker/docker-compose.blue-green.yml`, `deploy/${version}/nginx/site.conf`];
|
|
267
|
-
return Promise.all(paths.map(async (path) => createHash('sha256').update(await readFile(join(root, path))).digest('hex')));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
async function rewriteManifest(versionRoot, directory = versionRoot) {
|
|
271
|
-
const files = [];
|
|
272
|
-
async function walk(current) {
|
|
273
|
-
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
274
|
-
const path = join(current, entry.name);
|
|
275
|
-
if (entry.isDirectory()) await walk(path);
|
|
276
|
-
else if (entry.isFile() && entry.name !== 'manifest.sha256') files.push(path);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
await walk(directory);
|
|
280
|
-
const lines = await Promise.all(files.sort().map(async (path) => {
|
|
281
|
-
const relative = path.slice(versionRoot.length + 1).split('\\').join('/');
|
|
282
|
-
const hash = createHash('sha256').update(await readFile(path)).digest('hex');
|
|
283
|
-
return `${hash} ${relative}`;
|
|
284
|
-
}));
|
|
285
|
-
await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
211
|
test('失败构建保留上一发布包和发布账本', async () => {
|
|
289
212
|
const fixture = await createFixture();
|
|
290
213
|
assert.equal(runBuild(fixture).status, 0);
|
|
@@ -296,197 +219,148 @@ test('失败构建保留上一发布包和发布账本', async () => {
|
|
|
296
219
|
assert.equal(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), agents);
|
|
297
220
|
});
|
|
298
221
|
|
|
299
|
-
test('
|
|
300
|
-
const fixture = await createFixture({ extraEnv: 'UNUSED_VALUE=$(touch should-not-exist)\nPATH=/malicious\n' });
|
|
301
|
-
const result = runBuild(fixture);
|
|
302
|
-
assert.equal(result.status, 0, result.stderr);
|
|
303
|
-
await assert.rejects(access(join(fixture.root, 'should-not-exist')));
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test('build 接受带标签基础镜像并拒绝重复键和额外版本参数', async () => {
|
|
307
|
-
const duplicate = await createFixture({ extraEnv: 'PROJECT_NAME=duplicate\n' });
|
|
308
|
-
assert.notEqual(runBuild(duplicate).status, 0);
|
|
309
|
-
const tagged = await createFixture();
|
|
310
|
-
assert.equal(runBuild(tagged).status, 0);
|
|
222
|
+
test('build 拒绝额外参数、旧部署键和日志目录配置', async () => {
|
|
311
223
|
const extra = await createFixture();
|
|
312
224
|
assert.equal(runBuild(extra, {}, '20260808-001').status, 2);
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
assert.notEqual(legacyRejected.status, 0);
|
|
320
|
-
assert.match(legacyRejected.stderr, /legacy duplicate environment key/);
|
|
321
|
-
const migration = await createFixture({ extraEnv: 'DATABASE_MIGRATION_MODE=none\n' });
|
|
322
|
-
const migrationRejected = runBuild(migration);
|
|
323
|
-
assert.notEqual(migrationRejected.status, 0);
|
|
324
|
-
assert.match(migrationRejected.stderr, /legacy duplicate environment key/);
|
|
325
|
-
const reverseInclude = await createFixture();
|
|
326
|
-
await writeFile(join(reverseInclude.root, '.dockerignore'), `${await readFile(join(reverseInclude.root, '.dockerignore'), 'utf8')}!.env\n`);
|
|
327
|
-
const reverseRejected = runBuild(reverseInclude);
|
|
328
|
-
assert.notEqual(reverseRejected.status, 0);
|
|
329
|
-
assert.match(reverseRejected.stderr, /forbidden reverse include/);
|
|
225
|
+
for (const setting of ['PROJECT_HTTP_PORT=9099\n', 'FRONTEND_BLUE_PORT=19091\n', 'LOG_DIR=/tmp/logs\n', 'DATABASE_MIGRATION_MODE=none\n']) {
|
|
226
|
+
const fixture = await createFixture({ extraEnv: setting });
|
|
227
|
+
const result = runBuild(fixture);
|
|
228
|
+
assert.notEqual(result.status, 0);
|
|
229
|
+
assert.match(result.stderr, /removed deployment configuration/);
|
|
230
|
+
}
|
|
330
231
|
});
|
|
331
232
|
|
|
332
|
-
test('validator
|
|
233
|
+
test('validator 拒绝镜像 fallback、嵌套日志和符号链接', async () => {
|
|
333
234
|
const fixture = await createFixture();
|
|
334
235
|
assert.equal(runBuild(fixture).status, 0);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
assert.notEqual(runValidator(fixture.root, version).status, 0);
|
|
338
|
-
|
|
339
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
340
|
-
const next = await releaseVersion(fixture.root);
|
|
341
|
-
const composePath = join(fixture.root, `deploy/${next}/docker/docker-compose.blue-green.yml`);
|
|
236
|
+
let version = await releaseVersion(fixture.root);
|
|
237
|
+
let composePath = join(fixture.root, `deploy/${version}/docker/docker-compose.yml`);
|
|
342
238
|
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'));
|
|
343
|
-
|
|
239
|
+
let validation = runValidator(fixture.root, version);
|
|
344
240
|
assert.notEqual(validation.status, 0);
|
|
345
241
|
assert.match(validation.stderr, /fallback|logs/);
|
|
346
|
-
});
|
|
347
242
|
|
|
348
|
-
test('validator 拒绝版本目录符号链接', async () => {
|
|
349
|
-
const fixture = await createFixture();
|
|
350
243
|
assert.equal(runBuild(fixture).status, 0);
|
|
351
|
-
|
|
244
|
+
version = await releaseVersion(fixture.root);
|
|
352
245
|
await symlink('../outside', join(fixture.root, `deploy/${version}/linked-file`));
|
|
353
|
-
|
|
246
|
+
validation = runValidator(fixture.root, version);
|
|
354
247
|
assert.notEqual(validation.status, 0);
|
|
355
248
|
assert.match(validation.stderr, /symbolic links/);
|
|
356
249
|
});
|
|
357
250
|
|
|
358
|
-
test('
|
|
251
|
+
test('菜单可独立运行或替换前端且记录成功日志', async () => {
|
|
359
252
|
const fixture = await createFixture();
|
|
360
253
|
assert.equal(runBuild(fixture).status, 0);
|
|
361
254
|
const version = await releaseVersion(fixture.root);
|
|
362
|
-
const versionRoot = join(fixture.root, `deploy/${version}`);
|
|
363
|
-
const badArchiveRoot = await temporary('bad-image-');
|
|
364
|
-
await writeFile(join(badArchiveRoot, 'manifest.json'), `[{"Config":"config.json","RepoTags":["fixture_frontend:${version}","other_frontend:bad"],"Layers":[]}]\n`);
|
|
365
|
-
await writeFile(join(badArchiveRoot, 'config.json'), '{}\n');
|
|
366
|
-
const tarResult = spawnSync('/usr/bin/tar', ['-cf', join(versionRoot, 'frontend-image.tar'), '-C', badArchiveRoot, 'manifest.json', 'config.json'], { encoding: 'utf8' });
|
|
367
|
-
assert.equal(tarResult.status, 0, tarResult.stderr);
|
|
368
|
-
await rewriteManifest(versionRoot);
|
|
369
255
|
const serverRoot = await installOnServer(fixture);
|
|
370
256
|
await writeFile(fixture.commandLog, '');
|
|
371
|
-
const
|
|
372
|
-
assert.
|
|
373
|
-
|
|
374
|
-
assert.
|
|
257
|
+
const result = runServer(fixture, serverRoot, '1', { EXPECTED_VERSION: version });
|
|
258
|
+
assert.equal(result.status, 0, result.stderr);
|
|
259
|
+
const commands = await readFile(fixture.commandLog, 'utf8');
|
|
260
|
+
assert.match(commands, /docker load -i .*frontend-image\.tar/);
|
|
261
|
+
assert.doesNotMatch(commands, /docker load -i .*backend-image\.tar/);
|
|
262
|
+
assert.match(commands, /compose .* up -d --force-recreate --no-deps frontend/);
|
|
263
|
+
assert.doesNotMatch(commands, /--no-deps backend/);
|
|
264
|
+
assert.match(await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8'), new RegExp(`version=${version} operation=run-or-replace-frontend result=success`));
|
|
375
265
|
});
|
|
376
266
|
|
|
377
|
-
test('
|
|
267
|
+
test('菜单可独立运行后端、同时运行前后端并同时停止', async () => {
|
|
378
268
|
const fixture = await createFixture();
|
|
379
269
|
assert.equal(runBuild(fixture).status, 0);
|
|
380
|
-
const
|
|
270
|
+
const version = await releaseVersion(fixture.root);
|
|
381
271
|
const serverRoot = await installOnServer(fixture);
|
|
382
|
-
const started = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first });
|
|
383
|
-
assert.equal(started.status, 0, started.stderr);
|
|
384
|
-
assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), new RegExp(`ACTIVE_COLOR=blue[\\s\\S]*${first}`));
|
|
385
272
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
await
|
|
389
|
-
|
|
390
|
-
assert.
|
|
391
|
-
assert.match(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
assert.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
assert.
|
|
273
|
+
await writeFile(fixture.commandLog, '');
|
|
274
|
+
assert.equal(runServer(fixture, serverRoot, '2', { EXPECTED_VERSION: version }).status, 0);
|
|
275
|
+
let commands = await readFile(fixture.commandLog, 'utf8');
|
|
276
|
+
assert.match(commands, /docker load -i .*backend-image\.tar/);
|
|
277
|
+
assert.doesNotMatch(commands, /docker load -i .*frontend-image\.tar/);
|
|
278
|
+
assert.match(commands, /--no-deps backend/);
|
|
279
|
+
|
|
280
|
+
await writeFile(fixture.commandLog, '');
|
|
281
|
+
assert.equal(runServer(fixture, serverRoot, '3', { EXPECTED_VERSION: version }).status, 0);
|
|
282
|
+
commands = await readFile(fixture.commandLog, 'utf8');
|
|
283
|
+
assert.match(commands, /up -d --force-recreate frontend backend/);
|
|
284
|
+
|
|
285
|
+
await writeFile(fixture.commandLog, '');
|
|
286
|
+
assert.equal(runServer(fixture, serverRoot, '4').status, 0);
|
|
287
|
+
commands = await readFile(fixture.commandLog, 'utf8');
|
|
288
|
+
assert.match(commands, /stop frontend backend/);
|
|
289
|
+
const operationLog = await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8');
|
|
290
|
+
assert.match(operationLog, /operation=run-or-replace-backend result=success/);
|
|
291
|
+
assert.match(operationLog, /operation=run-or-replace-both result=success/);
|
|
292
|
+
assert.match(operationLog, /operation=stop-both result=success/);
|
|
400
293
|
});
|
|
401
294
|
|
|
402
|
-
test('
|
|
295
|
+
test('健康检查失败返回非零并记录失败,不自动回滚', async () => {
|
|
403
296
|
const fixture = await createFixture();
|
|
404
297
|
assert.equal(runBuild(fixture).status, 0);
|
|
405
298
|
const version = await releaseVersion(fixture.root);
|
|
406
299
|
const serverRoot = await installOnServer(fixture);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
assert.
|
|
411
|
-
|
|
412
|
-
assert.
|
|
413
|
-
assert.match(statusResult.stdout, new RegExp(version));
|
|
414
|
-
const log = await readFile(fixture.commandLog, 'utf8');
|
|
415
|
-
assert.match(log, /up -d --force-recreate frontend-blue backend-blue/);
|
|
416
|
-
assert.match(log, /stop frontend-blue backend-blue/);
|
|
300
|
+
const result = runServer(fixture, serverRoot, '2', { EXPECTED_VERSION: version, FAIL_HEALTH: 'backend' });
|
|
301
|
+
assert.notEqual(result.status, 0);
|
|
302
|
+
const commands = await readFile(fixture.commandLog, 'utf8');
|
|
303
|
+
assert.match(commands, /up -d --force-recreate --no-deps backend/);
|
|
304
|
+
assert.doesNotMatch(commands, /docker .*rollback|nginx -(?:t|s)/);
|
|
305
|
+
assert.match(await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8'), /operation=run-or-replace-backend result=failure/);
|
|
417
306
|
});
|
|
418
307
|
|
|
419
|
-
test('
|
|
308
|
+
test('非法菜单输入和额外命令参数不执行 Docker 状态变更', async () => {
|
|
420
309
|
const fixture = await createFixture();
|
|
421
310
|
assert.equal(runBuild(fixture).status, 0);
|
|
422
311
|
const serverRoot = await installOnServer(fixture);
|
|
423
|
-
await mkdir(join(serverRoot, 'logs'), { recursive: true });
|
|
424
|
-
await writeFile(join(serverRoot, 'logs/backend.log'), 'keep\n');
|
|
425
312
|
await writeFile(fixture.commandLog, '');
|
|
426
|
-
const
|
|
427
|
-
assert.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
assert.
|
|
431
|
-
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/);
|
|
432
|
-
assert.doesNotMatch(log, /other_project_frontend:shared/);
|
|
433
|
-
assert.equal(await readFile(join(serverRoot, 'logs/backend.log'), 'utf8'), 'keep\n');
|
|
434
|
-
await access(join(serverRoot, 'release.env'));
|
|
313
|
+
const invalid = runServer(fixture, serverRoot, '9');
|
|
314
|
+
assert.notEqual(invalid.status, 0);
|
|
315
|
+
assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker (?:load|compose)/);
|
|
316
|
+
const extra = runServer(fixture, serverRoot, '1', {}, ['start']);
|
|
317
|
+
assert.equal(extra.status, 2);
|
|
435
318
|
});
|
|
436
319
|
|
|
437
|
-
test('
|
|
320
|
+
test('服务器拒绝标签不匹配的镜像归档且不执行 docker load', async () => {
|
|
438
321
|
const fixture = await createFixture();
|
|
439
322
|
assert.equal(runBuild(fixture).status, 0);
|
|
440
|
-
const
|
|
323
|
+
const version = await releaseVersion(fixture.root);
|
|
324
|
+
const versionRoot = join(fixture.root, `deploy/${version}`);
|
|
325
|
+
const badArchiveRoot = await temporary('bad-image-');
|
|
326
|
+
await writeFile(join(badArchiveRoot, 'manifest.json'), `[{"Config":"config.json","RepoTags":["fixture_frontend:${version}","other_frontend:bad"],"Layers":[]}]\n`);
|
|
327
|
+
await writeFile(join(badArchiveRoot, 'config.json'), '{}\n');
|
|
328
|
+
assert.equal(spawnSync('/usr/bin/tar', ['-cf', join(versionRoot, 'frontend-image.tar'), '-C', badArchiveRoot, 'manifest.json', 'config.json']).status, 0);
|
|
329
|
+
await rewriteManifest(versionRoot);
|
|
441
330
|
const serverRoot = await installOnServer(fixture);
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
assert.
|
|
446
|
-
|
|
447
|
-
await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
|
|
448
|
-
const failed = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second, FAIL_NGINX_RELOAD_ONCE: '1' });
|
|
449
|
-
assert.notEqual(failed.status, 0);
|
|
450
|
-
assert.equal(await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8'), oldUpstream);
|
|
451
|
-
assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
|
|
331
|
+
await writeFile(fixture.commandLog, '');
|
|
332
|
+
const result = runServer(fixture, serverRoot, '1', { EXPECTED_VERSION: version });
|
|
333
|
+
assert.notEqual(result.status, 0);
|
|
334
|
+
assert.match(result.stderr, /exactly one expected tag/);
|
|
335
|
+
assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load/);
|
|
452
336
|
});
|
|
453
337
|
|
|
454
|
-
test('
|
|
338
|
+
test('服务器部署根由脚本物理路径确定', async () => {
|
|
455
339
|
const fixture = await createFixture();
|
|
456
340
|
assert.equal(runBuild(fixture).status, 0);
|
|
457
341
|
const version = await releaseVersion(fixture.root);
|
|
458
342
|
const serverRoot = await installOnServer(fixture);
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
assert.
|
|
462
|
-
await
|
|
463
|
-
await assert.rejects(access(join(
|
|
464
|
-
await assert.rejects(access(join(serverRoot, 'nginx/active-upstreams.conf')));
|
|
343
|
+
const unrelated = await temporary('unrelated-cwd-');
|
|
344
|
+
const result = run(fixture, [join(serverRoot, 'deploy.sh')], { cwd: unrelated, input: '1\n', extraEnv: { EXPECTED_VERSION: version } });
|
|
345
|
+
assert.equal(result.status, 0, result.stderr);
|
|
346
|
+
await access(join(serverRoot, 'logs/deploy.log'));
|
|
347
|
+
await assert.rejects(access(join(unrelated, 'logs/deploy.log')));
|
|
465
348
|
});
|
|
466
349
|
|
|
467
|
-
test('
|
|
350
|
+
test('validator 拒绝缺失镜像和宿主机 Nginx 文件', async () => {
|
|
468
351
|
const fixture = await createFixture();
|
|
469
352
|
assert.equal(runBuild(fixture).status, 0);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
assert.
|
|
473
|
-
const oldUpstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
|
|
474
|
-
const oldState = await readFile(join(serverRoot, 'state/active.env'), 'utf8');
|
|
475
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
476
|
-
const second = await releaseVersion(fixture.root);
|
|
477
|
-
await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
|
|
478
|
-
const failed = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second, FAIL_STATE_INSTALL: '1' });
|
|
479
|
-
assert.notEqual(failed.status, 0);
|
|
480
|
-
assert.equal(await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8'), oldUpstream);
|
|
481
|
-
assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
|
|
482
|
-
});
|
|
353
|
+
let version = await releaseVersion(fixture.root);
|
|
354
|
+
await unlink(join(fixture.root, `deploy/${version}/frontend-image.tar`));
|
|
355
|
+
assert.notEqual(runValidator(fixture.root, version).status, 0);
|
|
483
356
|
|
|
484
|
-
test('服务器命令拒绝额外参数且部署根由脚本位置确定', async () => {
|
|
485
|
-
const fixture = await createFixture();
|
|
486
357
|
assert.equal(runBuild(fixture).status, 0);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
await
|
|
491
|
-
|
|
358
|
+
version = await releaseVersion(fixture.root);
|
|
359
|
+
const nginxDir = join(fixture.root, `deploy/${version}/nginx`);
|
|
360
|
+
await mkdir(nginxDir);
|
|
361
|
+
await writeFile(join(nginxDir, 'site.conf'), 'server {}\n');
|
|
362
|
+
await rewriteManifest(join(fixture.root, `deploy/${version}`));
|
|
363
|
+
const validation = runValidator(fixture.root, version);
|
|
364
|
+
assert.notEqual(validation.status, 0);
|
|
365
|
+
assert.match(validation.stderr, /must not contain host Nginx/);
|
|
492
366
|
});
|