@wdyy/skills 0.1.12 → 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 -46
- 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 +138 -155
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -97
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +93 -116
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +185 -311
- 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 +210 -256
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +11 -13
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -19
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +0 -6
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
- package/README.md +2 -2
- package/lib/wdyy-cli.js +2 -1
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -99
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -1,39 +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
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { chmod, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
6
|
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
6
8
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
8
|
-
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
9
10
|
|
|
11
|
+
const run = promisify(execFile);
|
|
10
12
|
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const
|
|
12
|
-
const templatesRoot = join(skillRoot, 'templates');
|
|
13
|
+
const generator = join(scriptsRoot, 'generate-deployment-files.mjs');
|
|
13
14
|
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
14
|
-
const
|
|
15
|
-
const composeTemplate = join(templatesRoot, 'docker-compose.yml');
|
|
16
|
-
const temporaryDirectories = [];
|
|
15
|
+
const created = [];
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 = `# 前端
|
|
30
39
|
FRONTEND_URL=0.0.0.0
|
|
31
40
|
FRONTEND_PORT=5173
|
|
32
|
-
|
|
33
41
|
# 后端
|
|
34
42
|
BACKEND_URL=0.0.0.0
|
|
35
43
|
BACKEND_PORT=3000
|
|
36
|
-
|
|
37
44
|
# 数据库
|
|
38
45
|
DB_URL=
|
|
39
46
|
DB_PORT=5432
|
|
@@ -41,326 +48,193 @@ DB_USER=
|
|
|
41
48
|
DB_PASSWORD=
|
|
42
49
|
DB_NAME=
|
|
43
50
|
DB_SCHEMA=
|
|
44
|
-
|
|
45
51
|
# API
|
|
46
|
-
|
|
52
|
+
API_URL=
|
|
47
53
|
# 部署
|
|
48
|
-
PROJECT_NAME=
|
|
49
|
-
DOCKER_BIND_IP=
|
|
54
|
+
PROJECT_NAME=
|
|
55
|
+
DOCKER_BIND_IP=
|
|
50
56
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
51
57
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
52
|
-
|
|
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);
|
|
53
71
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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);
|
|
58
77
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
mkdir(join(root, 'scripts/deployment'), { recursive: true }),
|
|
69
|
-
]);
|
|
70
|
-
await Promise.all([
|
|
71
|
-
copyFile(deployTemplate, join(root, 'deploy.sh')),
|
|
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')),
|
|
77
|
-
writeFile(join(root, 'AGENTS.md'), agents),
|
|
78
|
-
writeFile(join(root, '.env'), envText(extraEnv)),
|
|
79
|
-
writeFile(commandLog, ''),
|
|
80
|
-
]);
|
|
81
|
-
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
82
|
-
await chmod(join(root, '.env'), 0o600);
|
|
83
|
-
|
|
84
|
-
await writeExecutable(join(fakeBin, 'pnpm'), `#!/usr/bin/env bash
|
|
85
|
-
set -euo pipefail
|
|
86
|
-
printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
|
|
87
|
-
[[ "\${FAIL_GATE:-}" != "\${1:-}" ]] || exit 9
|
|
88
|
-
if [[ "\${1:-}" == build ]]; then mkdir -p src/frontend/dist; printf '<div id="app"></div>\n' > src/frontend/dist/index.html; fi
|
|
89
|
-
`);
|
|
90
|
-
await writeExecutable(join(fakeBin, 'docker'), `#!/usr/bin/env bash
|
|
91
|
-
set -euo pipefail
|
|
92
|
-
printf 'docker %s\n' "$*" >> "$COMMAND_LOG"
|
|
93
|
-
case "\${1:-}" in
|
|
94
|
-
build) exit 0 ;;
|
|
95
|
-
image)
|
|
96
|
-
[[ "\${2:-}" == inspect ]] || exit 10
|
|
97
|
-
tag="\${!#}"; printf '%s\n' "\${tag##*:}"
|
|
98
|
-
;;
|
|
99
|
-
save)
|
|
100
|
-
shift; output=; tag=
|
|
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"
|
|
104
|
-
;;
|
|
105
|
-
load) exit 0 ;;
|
|
106
|
-
compose) exit 0 ;;
|
|
107
|
-
*) echo "unexpected docker command: $*" >&2; exit 10 ;;
|
|
108
|
-
esac
|
|
109
|
-
`);
|
|
110
|
-
await writeExecutable(join(fakeBin, 'curl'), `#!/usr/bin/env bash
|
|
111
|
-
set -euo pipefail
|
|
112
|
-
url="\${!#}"
|
|
113
|
-
if [[ "\${FAIL_HEALTH:-}" == frontend && "$url" == *:5173/* ]]; then exit 26; fi
|
|
114
|
-
if [[ "\${FAIL_HEALTH:-}" == backend && "$url" == *:3000/* ]]; then exit 27; fi
|
|
115
|
-
if [[ "$url" == */version ]]; then printf '%s' "\${EXPECTED_VERSION:?}"; else printf 'ok'; fi
|
|
116
|
-
`);
|
|
117
|
-
return { root, fakeBin, commandLog };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function run(fixture, args, { input, extraEnv = {}, cwd = fixture.root } = {}) {
|
|
121
|
-
return spawnSync('bash', args, {
|
|
122
|
-
cwd,
|
|
123
|
-
encoding: 'utf8',
|
|
124
|
-
input,
|
|
125
|
-
env: { ...process.env, PATH: `${fixture.fakeBin}:${process.env.PATH}`, COMMAND_LOG: fixture.commandLog, ...extraEnv },
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function runBuild(fixture, extraEnv = {}, ...extraArgs) {
|
|
130
|
-
return run(fixture, ['deploy.sh', 'build', ...extraArgs], { extraEnv });
|
|
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);
|
|
131
87
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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') };
|
|
135
111
|
}
|
|
136
112
|
|
|
137
|
-
function
|
|
138
|
-
return
|
|
139
|
-
validator,
|
|
140
|
-
join(root, 'deploy/deploy.sh'),
|
|
141
|
-
join(root, `deploy/${version}/docker/docker-compose.yml`),
|
|
142
|
-
join(root, 'deploy'),
|
|
143
|
-
], { encoding: 'utf8' });
|
|
113
|
+
async function buildProject(item, extraEnvironment = {}) {
|
|
114
|
+
return run(join(item.root, 'deploy.sh'), ['build'], { cwd: item.root, env: { ...item.environment, ...extraEnvironment } });
|
|
144
115
|
}
|
|
145
116
|
|
|
146
|
-
async function
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
await
|
|
150
|
-
return
|
|
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;
|
|
151
122
|
}
|
|
152
123
|
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
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
|
-
}
|
|
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}`);
|
|
169
130
|
}
|
|
170
|
-
await
|
|
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`);
|
|
131
|
+
await writeFile(join(root, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
176
132
|
}
|
|
177
133
|
|
|
178
|
-
test(
|
|
179
|
-
|
|
180
|
-
const result = runBuild(fixture);
|
|
181
|
-
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
182
|
-
const version = await releaseVersion(fixture.root);
|
|
183
|
-
assert.deepEqual((await readdir(join(fixture.root, 'deploy'))).sort(), ['.env', version, 'deploy.sh', 'release.env'].sort());
|
|
184
|
-
await Promise.all([
|
|
185
|
-
access(join(fixture.root, `deploy/${version}/frontend-image.tar`)),
|
|
186
|
-
access(join(fixture.root, `deploy/${version}/backend-image.tar`)),
|
|
187
|
-
]);
|
|
188
|
-
const validation = runValidator(fixture.root, version);
|
|
189
|
-
assert.equal(validation.status, 0, validation.stderr);
|
|
190
|
-
assert.match(validation.stdout, /valid direct dual-image deployment package/);
|
|
191
|
-
const log = await readFile(fixture.commandLog, 'utf8');
|
|
192
|
-
assert.match(log, new RegExp(`--tag fixture_frontend:${version}`));
|
|
193
|
-
assert.match(log, new RegExp(`--tag fixture_backend:${version}`));
|
|
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/);
|
|
134
|
+
test.after(async () => {
|
|
135
|
+
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
197
136
|
});
|
|
198
137
|
|
|
199
|
-
test('
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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/);
|
|
209
154
|
});
|
|
210
155
|
|
|
211
|
-
test('
|
|
212
|
-
const
|
|
213
|
-
assert.
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
assert.equal(
|
|
218
|
-
assert.equal(await releaseVersion(fixture.root), version);
|
|
219
|
-
assert.equal(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), agents);
|
|
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);
|
|
220
163
|
});
|
|
221
164
|
|
|
222
|
-
test('
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
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']);
|
|
231
173
|
});
|
|
232
174
|
|
|
233
|
-
test('
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
await
|
|
239
|
-
|
|
240
|
-
assert.
|
|
241
|
-
assert.match(
|
|
242
|
-
|
|
243
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
244
|
-
version = await releaseVersion(fixture.root);
|
|
245
|
-
await symlink('../outside', join(fixture.root, `deploy/${version}/linked-file`));
|
|
246
|
-
validation = runValidator(fixture.root, version);
|
|
247
|
-
assert.notEqual(validation.status, 0);
|
|
248
|
-
assert.match(validation.stderr, /symbolic links/);
|
|
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/);
|
|
249
185
|
});
|
|
250
186
|
|
|
251
|
-
test('
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
await
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
assert.match(
|
|
261
|
-
assert.
|
|
262
|
-
assert.
|
|
263
|
-
assert.
|
|
264
|
-
assert.match(await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8'), new RegExp(`version=${version} operation=run-or-replace-frontend result=success`));
|
|
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/);
|
|
265
200
|
});
|
|
266
201
|
|
|
267
|
-
test('
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
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/);
|
|
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/);
|
|
293
208
|
});
|
|
294
209
|
|
|
295
|
-
test('
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
assert.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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/);
|
|
306
222
|
});
|
|
307
223
|
|
|
308
|
-
test('
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
await writeFile(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
assert.equal(extra.status, 2);
|
|
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/);
|
|
318
233
|
});
|
|
319
234
|
|
|
320
|
-
test('
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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);
|
|
330
|
-
const serverRoot = await installOnServer(fixture);
|
|
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/);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test('服务器部署根由脚本物理路径确定', async () => {
|
|
339
|
-
const fixture = await createFixture();
|
|
340
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
341
|
-
const version = await releaseVersion(fixture.root);
|
|
342
|
-
const serverRoot = await installOnServer(fixture);
|
|
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')));
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
test('validator 拒绝缺失镜像和宿主机 Nginx 文件', async () => {
|
|
351
|
-
const fixture = await createFixture();
|
|
352
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
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);
|
|
356
|
-
|
|
357
|
-
assert.equal(runBuild(fixture).status, 0);
|
|
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/);
|
|
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' });
|
|
366
240
|
});
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
ARG BACKEND_BASE_IMAGE
|
|
2
2
|
FROM ${BACKEND_BASE_IMAGE} AS build
|
|
3
3
|
WORKDIR /app
|
|
4
|
-
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
5
|
-
RUN corepack enable && pnpm install --frozen-lockfile
|
|
6
4
|
COPY . .
|
|
7
|
-
RUN pnpm
|
|
5
|
+
RUN corepack enable && pnpm install --frozen-lockfile
|
|
6
|
+
RUN pnpm --dir src/backend build
|
|
8
7
|
|
|
9
|
-
ARG BACKEND_BASE_IMAGE
|
|
10
8
|
FROM ${BACKEND_BASE_IMAGE} AS runtime
|
|
11
9
|
WORKDIR /app
|
|
12
|
-
ARG RELEASE_VERSION
|
|
13
10
|
ARG BACKEND_PORT
|
|
14
11
|
ENV NODE_ENV=production
|
|
15
|
-
ENV RELEASE_VERSION=${RELEASE_VERSION}
|
|
16
12
|
ENV PORT=${BACKEND_PORT}
|
|
17
|
-
LABEL org.opencontainers.image.
|
|
18
|
-
COPY --from=build /app/package.json ./
|
|
13
|
+
LABEL org.opencontainers.image.title="wdyy-backend"
|
|
14
|
+
COPY --from=build /app/package.json ./package.json
|
|
19
15
|
COPY --from=build /app/node_modules ./node_modules
|
|
20
|
-
COPY --from=build /app/dist ./dist
|
|
16
|
+
COPY --from=build /app/src/backend/dist ./dist
|
|
21
17
|
EXPOSE ${BACKEND_PORT}
|
|
22
18
|
CMD ["node", "dist/main.js"]
|