@wdyy/skills 0.1.11 → 0.1.13
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 +30 -55
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +103 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +140 -153
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -85
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +102 -174
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +203 -455
- package/.well-known/skills/wdyy-deployment-standard/templates/{Dockerfile.template → backend.Dockerfile.template} +5 -9
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +207 -647
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +33 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -24
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -8
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
- 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 +4 -2
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -127
- 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,46 +1,46 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
1
2
|
import assert from 'node:assert/strict';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
} from 'node:fs/promises';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { chmod, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
|
-
import { dirname, join
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import {
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
11
10
|
|
|
11
|
+
const run = promisify(execFile);
|
|
12
12
|
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const
|
|
14
|
-
const templatesRoot = join(skillRoot, 'templates');
|
|
13
|
+
const generator = join(scriptsRoot, 'generate-deployment-files.mjs');
|
|
15
14
|
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
16
|
-
const
|
|
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');
|
|
23
|
-
const temporaryDirectories = [];
|
|
15
|
+
const created = [];
|
|
24
16
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
const envText = `# 前端
|
|
18
|
+
FRONTEND_URL=0.0.0.0
|
|
19
|
+
FRONTEND_PORT=5173
|
|
20
|
+
# 后端
|
|
21
|
+
BACKEND_URL=0.0.0.0
|
|
22
|
+
BACKEND_PORT=3000
|
|
23
|
+
# 数据库
|
|
24
|
+
DB_URL=db.internal
|
|
25
|
+
DB_PORT=5432
|
|
26
|
+
DB_USER=app
|
|
27
|
+
DB_PASSWORD=secret
|
|
28
|
+
DB_NAME=app
|
|
29
|
+
DB_SCHEMA=public
|
|
30
|
+
# API
|
|
31
|
+
API_URL=
|
|
32
|
+
# 部署
|
|
33
|
+
PROJECT_NAME=example_project
|
|
34
|
+
DOCKER_BIND_IP=0.0.0.0
|
|
35
|
+
FRONTEND_BASE_IMAGE=nginx:stable
|
|
36
|
+
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
37
|
+
`;
|
|
38
|
+
const exampleText = `# 前端
|
|
37
39
|
FRONTEND_URL=0.0.0.0
|
|
38
40
|
FRONTEND_PORT=5173
|
|
39
|
-
|
|
40
41
|
# 后端
|
|
41
42
|
BACKEND_URL=0.0.0.0
|
|
42
43
|
BACKEND_PORT=3000
|
|
43
|
-
|
|
44
44
|
# 数据库
|
|
45
45
|
DB_URL=
|
|
46
46
|
DB_PORT=5432
|
|
@@ -48,445 +48,193 @@ DB_USER=
|
|
|
48
48
|
DB_PASSWORD=
|
|
49
49
|
DB_NAME=
|
|
50
50
|
DB_SCHEMA=
|
|
51
|
-
|
|
52
51
|
# API
|
|
53
|
-
|
|
52
|
+
API_URL=
|
|
54
53
|
# 部署
|
|
55
|
-
PROJECT_NAME=
|
|
56
|
-
|
|
57
|
-
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
|
|
54
|
+
PROJECT_NAME=
|
|
55
|
+
DOCKER_BIND_IP=
|
|
62
56
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
63
57
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
mkdir(serverProjectsRoot, { recursive: true }),
|
|
78
|
-
]);
|
|
79
|
-
await Promise.all([
|
|
80
|
-
copyFile(deployTemplate, join(root, 'deploy.sh')),
|
|
81
|
-
copyFile(frontendDockerfileTemplate, join(root, 'src/frontend/Dockerfile')),
|
|
82
|
-
copyFile(backendDockerfileTemplate, join(root, 'src/backend/Dockerfile')),
|
|
83
|
-
copyFile(composeTemplate, join(root, 'scripts/deployment/docker-compose.blue-green.yml')),
|
|
84
|
-
copyFile(nginxTemplate, join(root, 'scripts/deployment/nginx-site.conf')),
|
|
85
|
-
copyFile(frontendNginxTemplate, join(root, 'scripts/deployment/frontend-container.conf.template')),
|
|
86
|
-
copyFile(dockerignoreTemplate, join(root, '.dockerignore')),
|
|
87
|
-
writeFile(join(root, 'AGENTS.md'), agents),
|
|
88
|
-
writeFile(join(root, '.env'), envText(extraEnv)),
|
|
89
|
-
writeFile(commandLog, ''),
|
|
90
|
-
]);
|
|
91
|
-
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
92
|
-
await chmod(join(root, '.env'), 0o600);
|
|
93
|
-
|
|
94
|
-
await writeExecutable(join(fakeBin, 'pnpm'), `#!/usr/bin/env bash
|
|
95
|
-
set -euo pipefail
|
|
96
|
-
printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
|
|
97
|
-
[[ "\${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
|
|
102
|
-
`);
|
|
103
|
-
await writeExecutable(join(fakeBin, 'docker'), `#!/usr/bin/env bash
|
|
104
|
-
set -euo pipefail
|
|
105
|
-
printf 'docker %s\n' "$*" >> "$COMMAND_LOG"
|
|
106
|
-
case "\${1:-}" in
|
|
107
|
-
build) exit 0 ;;
|
|
108
|
-
image)
|
|
109
|
-
if [[ "\${2:-}" == inspect ]]; then tag="\${!#}"; printf '%s\n' "\${tag##*:}";
|
|
110
|
-
elif [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_IMAGE_LIST:-fixture_frontend:20260808-001
|
|
111
|
-
fixture_backend:20260808-001
|
|
112
|
-
other_project_frontend:shared}"; fi
|
|
113
|
-
;;
|
|
114
|
-
save)
|
|
115
|
-
shift; output=; tag=
|
|
116
|
-
while [[ "$#" -gt 0 ]]; do
|
|
117
|
-
if [[ "$1" == -o ]]; then output="$2"; shift 2; else tag="$1"; shift; fi
|
|
118
|
-
done
|
|
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"
|
|
124
|
-
;;
|
|
125
|
-
load) exit 0 ;;
|
|
126
|
-
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
|
-
*) echo "unexpected docker command: $*" >&2; exit 10 ;;
|
|
140
|
-
esac
|
|
141
|
-
`);
|
|
142
|
-
await writeExecutable(join(fakeBin, 'curl'), `#!/usr/bin/env bash
|
|
143
|
-
set -euo pipefail
|
|
144
|
-
url="\${!#}"
|
|
145
|
-
if [[ "\${FAIL_PUBLIC_ENDPOINT:-0}" == 1 && "$url" == *:9099/* ]]; then exit 26; fi
|
|
146
|
-
if [[ "$url" == */version ]]; then printf '%s' "\${EXPECTED_VERSION:?}"; else printf 'ok'; fi
|
|
147
|
-
`);
|
|
148
|
-
await writeExecutable(join(fakeBin, 'nginx'), `#!/usr/bin/env bash
|
|
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 };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function writeExecutable(path, content) {
|
|
173
|
-
await writeFile(path, content);
|
|
174
|
-
await chmod(path, 0o755);
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const fakeDocker = `#!/usr/bin/env node
|
|
61
|
+
const fs = require('fs');
|
|
62
|
+
const os = require('os');
|
|
63
|
+
const path = require('path');
|
|
64
|
+
const cp = require('child_process');
|
|
65
|
+
const args = process.argv.slice(2);
|
|
66
|
+
if (process.env.DOCKER_LOG) fs.appendFileSync(process.env.DOCKER_LOG, args.join(' ') + '\\n');
|
|
67
|
+
if (args[0] === 'image' && args[1] === 'inspect') {
|
|
68
|
+
const image = args[args.length - 1];
|
|
69
|
+
if (process.env.MISSING_IMAGE === image) process.exit(1);
|
|
70
|
+
process.exit(0);
|
|
175
71
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
env: {
|
|
182
|
-
...process.env,
|
|
183
|
-
PATH: `${fixture.fakeBin}:${process.env.PATH}`,
|
|
184
|
-
COMMAND_LOG: fixture.commandLog,
|
|
185
|
-
...extraEnv,
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function runBuild(fixture, extraEnv = {}, ...extraArgs) {
|
|
191
|
-
return run(fixture.root, ['deploy.sh', 'build', ...extraArgs], fixture, extraEnv);
|
|
72
|
+
if (args[0] === 'build') {
|
|
73
|
+
const tagIndex = args.indexOf('--tag');
|
|
74
|
+
const tag = tagIndex >= 0 ? args[tagIndex + 1] : '';
|
|
75
|
+
if (process.env.FAIL_BUILD && tag.includes(process.env.FAIL_BUILD)) process.exit(1);
|
|
76
|
+
process.exit(0);
|
|
192
77
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
78
|
+
if (args[0] === 'save') {
|
|
79
|
+
const output = args[args.indexOf('-o') + 1];
|
|
80
|
+
const tag = args[args.length - 1];
|
|
81
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-image-'));
|
|
82
|
+
fs.writeFileSync(path.join(temporary, 'manifest.json'), JSON.stringify([{Config:'config.json',RepoTags:[tag],Layers:[]}]) + '\\n');
|
|
83
|
+
fs.writeFileSync(path.join(temporary, 'config.json'), '{}\\n');
|
|
84
|
+
const result = cp.spawnSync('tar', ['-cf', output, '-C', temporary, 'manifest.json', 'config.json']);
|
|
85
|
+
fs.rmSync(temporary, {recursive:true, force:true});
|
|
86
|
+
process.exit(result.status || 0);
|
|
196
87
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
88
|
+
if (args[0] === 'compose' && args.includes('up') && process.env.FAIL_COMPOSE_UP === '1') process.exit(1);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
async function fixture() {
|
|
93
|
+
const root = await mkdtemp(join(tmpdir(), 'wdyy-unified-delivery-'));
|
|
94
|
+
created.push(root);
|
|
95
|
+
await mkdir(join(root, 'src/frontend'), { recursive: true });
|
|
96
|
+
await mkdir(join(root, 'src/backend'), { recursive: true });
|
|
97
|
+
await mkdir(join(root, 'deploy'), { recursive: true });
|
|
98
|
+
await writeFile(join(root, '.env'), envText, { mode: 0o600 });
|
|
99
|
+
await writeFile(join(root, '.env.example'), exampleText, { mode: 0o644 });
|
|
100
|
+
await writeFile(join(root, 'package.json'), '{}\n');
|
|
101
|
+
await writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
|
|
102
|
+
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - src/*\n');
|
|
103
|
+
await run(process.execPath, [generator, '--target', root, '--write']);
|
|
104
|
+
const bin = join(root, 'fake-bin');
|
|
105
|
+
await mkdir(bin);
|
|
106
|
+
await writeFile(join(bin, 'docker'), fakeDocker, { mode: 0o755 });
|
|
107
|
+
await chmod(join(bin, 'docker'), 0o755);
|
|
108
|
+
const log = join(root, 'docker.log');
|
|
109
|
+
const environment = { ...process.env, PATH: `${bin}:${process.env.PATH}`, DOCKER_LOG: log };
|
|
110
|
+
return { root, log, environment, archive: join(root, 'deploy/example_project-docker.tar.gz') };
|
|
206
111
|
}
|
|
207
112
|
|
|
208
|
-
async function
|
|
209
|
-
|
|
210
|
-
await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
|
|
211
|
-
await chmod(join(serverRoot, 'deploy.sh'), 0o755);
|
|
212
|
-
return serverRoot;
|
|
113
|
+
async function buildProject(item, extraEnvironment = {}) {
|
|
114
|
+
return run(join(item.root, 'deploy.sh'), ['build'], { cwd: item.root, env: { ...item.environment, ...extraEnvironment } });
|
|
213
115
|
}
|
|
214
116
|
|
|
215
|
-
function
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
NGINX_FAIL_MARKER: join(fixture.root, 'nginx-failed-once'),
|
|
221
|
-
...extraEnv,
|
|
222
|
-
});
|
|
117
|
+
async function extractArchive(item) {
|
|
118
|
+
const extracted = await mkdtemp(join(tmpdir(), 'wdyy-unified-package-'));
|
|
119
|
+
created.push(extracted);
|
|
120
|
+
await run('tar', ['-xzf', item.archive, '-C', extracted]);
|
|
121
|
+
return extracted;
|
|
223
122
|
}
|
|
224
123
|
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
await Promise.all([
|
|
232
|
-
access(join(fixture.root, `deploy/${version}/frontend-image.tar`)),
|
|
233
|
-
access(join(fixture.root, `deploy/${version}/backend-image.tar`)),
|
|
234
|
-
]);
|
|
235
|
-
const validation = runValidator(fixture.root, version);
|
|
236
|
-
assert.equal(validation.status, 0, validation.stderr);
|
|
237
|
-
assert.match(validation.stdout, /valid dual-image deployment package/);
|
|
238
|
-
const log = await readFile(fixture.commandLog, 'utf8');
|
|
239
|
-
assert.match(log, new RegExp(`--tag fixture_frontend:${version}`));
|
|
240
|
-
assert.match(log, new RegExp(`--tag fixture_backend:${version}`));
|
|
241
|
-
assert.match(log, /--build-arg BACKEND_PORT=3000/);
|
|
242
|
-
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/);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test('发布包不包含数据库迁移文件或迁移参数', async () => {
|
|
246
|
-
const fixture = await createFixture();
|
|
247
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
248
|
-
const version = await releaseVersion(fixture.root);
|
|
249
|
-
await assert.rejects(stat(join(fixture.root, `deploy/${version}/database`)));
|
|
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);
|
|
258
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
259
|
-
const secondVersion = await releaseVersion(fixture.root);
|
|
260
|
-
const second = await configurationHashes(fixture.root, secondVersion);
|
|
261
|
-
assert.notEqual(firstVersion, secondVersion);
|
|
262
|
-
assert.deepEqual(second, first);
|
|
263
|
-
});
|
|
264
|
-
|
|
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
|
-
}
|
|
124
|
+
async function rewriteManifest(root) {
|
|
125
|
+
const names = ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar'];
|
|
126
|
+
const lines = [];
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
const hash = createHash('sha256').update(await readFile(join(root, name))).digest('hex');
|
|
129
|
+
lines.push(`${hash} ${name}`);
|
|
278
130
|
}
|
|
279
|
-
await
|
|
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`);
|
|
131
|
+
await writeFile(join(root, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
286
132
|
}
|
|
287
133
|
|
|
288
|
-
test(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
assert.
|
|
296
|
-
assert.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
assert.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
assert.
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
assert.
|
|
316
|
-
assert.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
assert.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
await
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
assert.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
assert.
|
|
351
|
-
|
|
352
|
-
await
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
assert.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
await
|
|
369
|
-
|
|
370
|
-
await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
assert.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
assert.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
assert.
|
|
393
|
-
|
|
394
|
-
assert.match(upstream, /fixture_frontend_active/);
|
|
395
|
-
assert.match(upstream, /fixture_backend_active/);
|
|
396
|
-
|
|
397
|
-
const rolledBack = runServer(fixture, serverRoot, 'rollback', { EXPECTED_VERSION: first }, first);
|
|
398
|
-
assert.equal(rolledBack.status, 0, rolledBack.stderr);
|
|
399
|
-
assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), new RegExp(first));
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
test('restart、stop、status 只操作当前颜色的一对容器', async () => {
|
|
403
|
-
const fixture = await createFixture();
|
|
404
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
405
|
-
const version = await releaseVersion(fixture.root);
|
|
406
|
-
const serverRoot = await installOnServer(fixture);
|
|
407
|
-
assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version }).status, 0);
|
|
408
|
-
await writeFile(fixture.commandLog, '');
|
|
409
|
-
assert.equal(runServer(fixture, serverRoot, 'restart', { EXPECTED_VERSION: version }).status, 0);
|
|
410
|
-
assert.equal(runServer(fixture, serverRoot, 'stop', { EXPECTED_VERSION: version }).status, 0);
|
|
411
|
-
const statusResult = runServer(fixture, serverRoot, 'status', { EXPECTED_VERSION: version });
|
|
412
|
-
assert.equal(statusResult.status, 0, statusResult.stderr);
|
|
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/);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
test('remove 只删除本项目容器、网络和两个镜像仓库并保留日志', async () => {
|
|
420
|
-
const fixture = await createFixture();
|
|
421
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
422
|
-
const serverRoot = await installOnServer(fixture);
|
|
423
|
-
await mkdir(join(serverRoot, 'logs'), { recursive: true });
|
|
424
|
-
await writeFile(join(serverRoot, 'logs/backend.log'), 'keep\n');
|
|
425
|
-
await writeFile(fixture.commandLog, '');
|
|
426
|
-
const removed = runServer(fixture, serverRoot, 'remove');
|
|
427
|
-
assert.equal(removed.status, 0, removed.stderr);
|
|
428
|
-
const log = await readFile(fixture.commandLog, 'utf8');
|
|
429
|
-
assert.match(log, /docker rm -f c111 c222/);
|
|
430
|
-
assert.match(log, /docker network rm n111/);
|
|
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'));
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
test('replace 的 Nginx reload 失败时恢复旧 upstream 和状态', async () => {
|
|
438
|
-
const fixture = await createFixture();
|
|
439
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
440
|
-
const first = await releaseVersion(fixture.root);
|
|
441
|
-
const serverRoot = await installOnServer(fixture);
|
|
442
|
-
assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first }).status, 0);
|
|
443
|
-
const oldUpstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
|
|
444
|
-
const oldState = await readFile(join(serverRoot, 'state/active.env'), 'utf8');
|
|
445
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
446
|
-
const second = await releaseVersion(fixture.root);
|
|
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);
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
test('首次切流后的外部端口验证失败时不提交状态并移除候选 Nginx 文件', async () => {
|
|
455
|
-
const fixture = await createFixture();
|
|
456
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
457
|
-
const version = await releaseVersion(fixture.root);
|
|
458
|
-
const serverRoot = await installOnServer(fixture);
|
|
459
|
-
const failed = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version, FAIL_PUBLIC_ENDPOINT: '1' });
|
|
460
|
-
assert.notEqual(failed.status, 0);
|
|
461
|
-
assert.match(failed.stderr, /public endpoint verification failed/);
|
|
462
|
-
await assert.rejects(access(join(serverRoot, 'state/active.env')));
|
|
463
|
-
await assert.rejects(access(join(serverRoot, 'nginx/site.conf')));
|
|
464
|
-
await assert.rejects(access(join(serverRoot, 'nginx/active-upstreams.conf')));
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
test('状态提交失败时恢复旧流量和旧状态', async () => {
|
|
468
|
-
const fixture = await createFixture();
|
|
469
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
470
|
-
const first = await releaseVersion(fixture.root);
|
|
471
|
-
const serverRoot = await installOnServer(fixture);
|
|
472
|
-
assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first }).status, 0);
|
|
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
|
-
});
|
|
483
|
-
|
|
484
|
-
test('服务器命令拒绝额外参数且部署根由脚本位置确定', async () => {
|
|
485
|
-
const fixture = await createFixture();
|
|
486
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
487
|
-
const serverRoot = await installOnServer(fixture);
|
|
488
|
-
assert.equal(runServer(fixture, serverRoot, 'start', {}, 'unexpected').status, 2);
|
|
489
|
-
const alternateRoot = join(fixture.root, 'alternate');
|
|
490
|
-
await cp(join(fixture.root, 'deploy'), alternateRoot, { recursive: true });
|
|
491
|
-
assert.equal(runServer(fixture, alternateRoot, 'status').status, 0);
|
|
134
|
+
test.after(async () => {
|
|
135
|
+
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('build 使用本地基础镜像并生成一个含双镜像归档的压缩包', async () => {
|
|
139
|
+
const item = await fixture();
|
|
140
|
+
const result = await buildProject(item);
|
|
141
|
+
assert.match(result.stdout, /delivery archive created/);
|
|
142
|
+
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
143
|
+
const extracted = await extractArchive(item);
|
|
144
|
+
assert.deepEqual((await readdir(extracted)).sort(), ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'].sort());
|
|
145
|
+
const validation = await run(process.execPath, [validator, extracted]);
|
|
146
|
+
assert.match(validation.stdout, /valid unified Docker delivery package/);
|
|
147
|
+
const log = await readFile(item.log, 'utf8');
|
|
148
|
+
assert.match(log, /image inspect nginx:stable/);
|
|
149
|
+
assert.match(log, /image inspect node:24-alpine3\.24/);
|
|
150
|
+
assert.equal((log.match(/build --pull=false/g) ?? []).length, 2);
|
|
151
|
+
assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
|
|
152
|
+
assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
|
|
153
|
+
assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('缺少本地基础镜像时在 build 前失败', async () => {
|
|
157
|
+
const item = await fixture();
|
|
158
|
+
await assert.rejects(buildProject(item, { MISSING_IMAGE: 'nginx:stable' }), /required local frontend base image is missing/);
|
|
159
|
+
const log = await readFile(item.log, 'utf8');
|
|
160
|
+
assert.match(log, /^image inspect nginx:stable$/m);
|
|
161
|
+
assert.doesNotMatch(log, /^build /m);
|
|
162
|
+
assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('构建失败保留上一份成功交付包且不暴露半成品', async () => {
|
|
166
|
+
const item = await fixture();
|
|
167
|
+
await buildProject(item);
|
|
168
|
+
const before = createHash('sha256').update(await readFile(item.archive)).digest('hex');
|
|
169
|
+
await assert.rejects(buildProject(item, { FAIL_BUILD: 'backend' }));
|
|
170
|
+
const after = createHash('sha256').update(await readFile(item.archive)).digest('hex');
|
|
171
|
+
assert.equal(after, before);
|
|
172
|
+
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('服务器无参数执行时从脚本目录整体加载并部署前后端', async () => {
|
|
176
|
+
const item = await fixture();
|
|
177
|
+
await buildProject(item);
|
|
178
|
+
const extracted = await extractArchive(item);
|
|
179
|
+
await writeFile(item.log, '');
|
|
180
|
+
await run(join(extracted, 'deploy.sh'), [], { cwd: tmpdir(), env: item.environment });
|
|
181
|
+
const log = await readFile(item.log, 'utf8');
|
|
182
|
+
assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
|
|
183
|
+
assert.match(log, /compose --env-file .* --project-name example_project -f .* up -d --force-recreate --no-build --pull never --wait frontend backend/);
|
|
184
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=success/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Docker', async () => {
|
|
188
|
+
const item = await fixture();
|
|
189
|
+
await buildProject(item);
|
|
190
|
+
const extracted = await extractArchive(item);
|
|
191
|
+
await writeFile(item.log, '');
|
|
192
|
+
await run(join(extracted, 'deploy.sh'), ['stop'], { env: item.environment });
|
|
193
|
+
await run(join(extracted, 'deploy.sh'), ['status'], { env: item.environment });
|
|
194
|
+
const beforeInvalid = await readFile(item.log, 'utf8');
|
|
195
|
+
assert.match(beforeInvalid, /stop frontend backend/);
|
|
196
|
+
assert.match(beforeInvalid, /ps frontend backend/);
|
|
197
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), ['frontend'], { env: item.environment }), /usage:/);
|
|
198
|
+
assert.equal(await readFile(item.log, 'utf8'), beforeInvalid);
|
|
199
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=stop result=success/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('整栈健康等待失败返回非零并记录失败', async () => {
|
|
203
|
+
const item = await fixture();
|
|
204
|
+
await buildProject(item);
|
|
205
|
+
const extracted = await extractArchive(item);
|
|
206
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, FAIL_COMPOSE_UP: '1' } }));
|
|
207
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('validator 拒绝数据库容器和不完整远程数据库配置', async () => {
|
|
211
|
+
const item = await fixture();
|
|
212
|
+
await buildProject(item);
|
|
213
|
+
const databasePackage = await extractArchive(item);
|
|
214
|
+
await writeFile(join(databasePackage, 'docker-compose.yml'), `${await readFile(join(databasePackage, 'docker-compose.yml'), 'utf8')}\n database:\n image: postgres:18\n`);
|
|
215
|
+
await rewriteManifest(databasePackage);
|
|
216
|
+
await assert.rejects(run(process.execPath, [validator, databasePackage]), /Compose must define only frontend and backend|contains a database/);
|
|
217
|
+
|
|
218
|
+
const envPackage = await extractArchive(item);
|
|
219
|
+
await writeFile(join(envPackage, '.env'), envText.replace('DB_URL=db.internal', 'DB_URL='), { mode: 0o600 });
|
|
220
|
+
await rewriteManifest(envPackage);
|
|
221
|
+
await assert.rejects(run(process.execPath, [validator, envPackage]), /Root \.env is missing DB_URL/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('validator 拒绝清单篡改和额外包内容', async () => {
|
|
225
|
+
const item = await fixture();
|
|
226
|
+
await buildProject(item);
|
|
227
|
+
const extracted = await extractArchive(item);
|
|
228
|
+
await writeFile(join(extracted, 'unexpected.txt'), 'unexpected\n');
|
|
229
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /Unexpected package entry/);
|
|
230
|
+
await rm(join(extracted, 'unexpected.txt'));
|
|
231
|
+
await writeFile(join(extracted, 'docker-compose.yml'), 'tampered\n');
|
|
232
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /Compose must define only frontend and backend|Checksum mismatch/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('build 在任何 Docker 操作前拒绝环境文件命令语法', async () => {
|
|
236
|
+
const item = await fixture();
|
|
237
|
+
await writeFile(join(item.root, '.env'), envText.replace('DB_PASSWORD=secret', 'DB_PASSWORD=$(id)'), { mode: 0o600 });
|
|
238
|
+
await assert.rejects(buildProject(item), /executable syntax is forbidden in \.env: DB_PASSWORD/);
|
|
239
|
+
await assert.rejects(stat(item.log), { code: 'ENOENT' });
|
|
492
240
|
});
|