@wdyy/skills 0.1.12 → 0.1.14
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 +37 -47
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +110 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +161 -155
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +133 -95
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +109 -116
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +309 -298
- package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +17 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +231 -252
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +18 -13
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +3 -19
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +7 -4
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +12 -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/templates/Dockerfile.template +0 -22
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -1,39 +1,51 @@
|
|
|
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 {
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { gunzip } from 'node:zlib';
|
|
9
11
|
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
const unzip = promisify(gunzip);
|
|
10
14
|
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const
|
|
12
|
-
const templatesRoot = join(skillRoot, 'templates');
|
|
15
|
+
const generator = join(scriptsRoot, 'generate-deployment-files.mjs');
|
|
13
16
|
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
14
|
-
const
|
|
15
|
-
const composeTemplate = join(templatesRoot, 'docker-compose.yml');
|
|
16
|
-
const temporaryDirectories = [];
|
|
17
|
+
const created = [];
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
const envText = `# 前端
|
|
20
|
+
FRONTEND_URL=0.0.0.0
|
|
21
|
+
FRONTEND_PORT=5173
|
|
22
|
+
# 后端
|
|
23
|
+
BACKEND_URL=0.0.0.0
|
|
24
|
+
BACKEND_PORT=3000
|
|
25
|
+
# 数据库
|
|
26
|
+
DB_URL=db.internal
|
|
27
|
+
DB_PORT=5432
|
|
28
|
+
DB_USER=app
|
|
29
|
+
DB_PASSWORD=secret
|
|
30
|
+
DB_NAME=app
|
|
31
|
+
DB_SCHEMA=public
|
|
32
|
+
# API
|
|
33
|
+
API_URL=
|
|
34
|
+
API_PREFIX=api
|
|
35
|
+
VITE_API_BASE_URL=/api
|
|
36
|
+
# 部署
|
|
37
|
+
PROJECT_NAME=example_project
|
|
38
|
+
DOCKER_BIND_IP=0.0.0.0
|
|
39
|
+
DOCKER_PLATFORM=linux/amd64
|
|
40
|
+
FRONTEND_BASE_IMAGE=nginx:stable
|
|
41
|
+
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
42
|
+
`;
|
|
43
|
+
const exampleText = `# 前端
|
|
30
44
|
FRONTEND_URL=0.0.0.0
|
|
31
45
|
FRONTEND_PORT=5173
|
|
32
|
-
|
|
33
46
|
# 后端
|
|
34
47
|
BACKEND_URL=0.0.0.0
|
|
35
48
|
BACKEND_PORT=3000
|
|
36
|
-
|
|
37
49
|
# 数据库
|
|
38
50
|
DB_URL=
|
|
39
51
|
DB_PORT=5432
|
|
@@ -41,326 +53,325 @@ DB_USER=
|
|
|
41
53
|
DB_PASSWORD=
|
|
42
54
|
DB_NAME=
|
|
43
55
|
DB_SCHEMA=
|
|
44
|
-
|
|
45
56
|
# API
|
|
46
|
-
|
|
57
|
+
API_URL=
|
|
58
|
+
API_PREFIX=api
|
|
59
|
+
VITE_API_BASE_URL=/api
|
|
47
60
|
# 部署
|
|
48
|
-
PROJECT_NAME=
|
|
49
|
-
DOCKER_BIND_IP=
|
|
61
|
+
PROJECT_NAME=
|
|
62
|
+
DOCKER_BIND_IP=
|
|
63
|
+
DOCKER_PLATFORM=linux/amd64
|
|
50
64
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
51
65
|
BACKEND_BASE_IMAGE=node:24-alpine3.24
|
|
52
|
-
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const fakeDocker = `#!/usr/bin/env node
|
|
69
|
+
const fs = require('fs');
|
|
70
|
+
const os = require('os');
|
|
71
|
+
const path = require('path');
|
|
72
|
+
const cp = require('child_process');
|
|
73
|
+
const args = process.argv.slice(2);
|
|
74
|
+
if (process.env.DOCKER_LOG) fs.appendFileSync(process.env.DOCKER_LOG, args.join(' ') + '\\n');
|
|
75
|
+
if (args[0] === 'version') {
|
|
76
|
+
if (process.env.FAIL_SERVER_PLATFORM_READ === '1') process.exit(1);
|
|
77
|
+
process.stdout.write((process.env.SERVER_PLATFORM || 'linux/amd64') + '\\n');
|
|
78
|
+
process.exit(0);
|
|
53
79
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
if (args[0] === 'image' && args[1] === 'inspect') {
|
|
81
|
+
const image = args[args.length - 1];
|
|
82
|
+
if (process.env.MISSING_IMAGE === image) process.exit(1);
|
|
83
|
+
if (args.includes('--format')) process.stdout.write((process.env.IMAGE_PLATFORM || 'linux/amd64') + '\\n');
|
|
84
|
+
process.exit(0);
|
|
58
85
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await Promise.all([
|
|
65
|
-
mkdir(fakeBin, { recursive: true }),
|
|
66
|
-
mkdir(join(root, 'src/frontend'), { recursive: true }),
|
|
67
|
-
mkdir(join(root, 'src/backend'), { recursive: true }),
|
|
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 };
|
|
86
|
+
if (args[0] === 'build') {
|
|
87
|
+
const tagIndex = args.indexOf('--tag');
|
|
88
|
+
const tag = tagIndex >= 0 ? args[tagIndex + 1] : '';
|
|
89
|
+
if (process.env.FAIL_BUILD && tag.includes(process.env.FAIL_BUILD)) process.exit(1);
|
|
90
|
+
process.exit(0);
|
|
118
91
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
92
|
+
if (args[0] === 'save') {
|
|
93
|
+
const output = args[args.indexOf('-o') + 1];
|
|
94
|
+
const tag = args[args.length - 1];
|
|
95
|
+
const [imageOs, imageArchitecture] = (process.env.IMAGE_PLATFORM || 'linux/amd64').split('/');
|
|
96
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-image-'));
|
|
97
|
+
fs.writeFileSync(path.join(temporary, 'manifest.json'), JSON.stringify([{Config:'config.json',RepoTags:[tag],Layers:[]}]) + '\\n');
|
|
98
|
+
fs.writeFileSync(path.join(temporary, 'config.json'), JSON.stringify({architecture:imageArchitecture,os:imageOs}) + '\\n');
|
|
99
|
+
const result = cp.spawnSync('tar', ['-cf', output, '-C', temporary, 'manifest.json', 'config.json']);
|
|
100
|
+
fs.rmSync(temporary, {recursive:true, force:true});
|
|
101
|
+
process.exit(result.status || 0);
|
|
102
|
+
}
|
|
103
|
+
if (args[0] === 'compose' && args.includes('up') && process.env.FAIL_COMPOSE_UP === '1') process.exit(1);
|
|
104
|
+
process.exit(0);
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
async function fixture() {
|
|
108
|
+
const root = await mkdtemp(join(tmpdir(), 'wdyy-unified-delivery-'));
|
|
109
|
+
created.push(root);
|
|
110
|
+
await mkdir(join(root, 'src/frontend'), { recursive: true });
|
|
111
|
+
await mkdir(join(root, 'src/backend'), { recursive: true });
|
|
112
|
+
await mkdir(join(root, 'deploy'), { recursive: true });
|
|
113
|
+
await writeFile(join(root, '.env'), envText, { mode: 0o600 });
|
|
114
|
+
await writeFile(join(root, '.env.example'), exampleText, { mode: 0o644 });
|
|
115
|
+
await writeFile(join(root, 'package.json'), '{}\n');
|
|
116
|
+
await writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
|
|
117
|
+
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - src/*\n');
|
|
118
|
+
await run(process.execPath, [generator, '--target', root, '--write']);
|
|
119
|
+
const bin = join(root, 'fake-bin');
|
|
120
|
+
await mkdir(bin);
|
|
121
|
+
await writeFile(join(bin, 'docker'), fakeDocker, { mode: 0o755 });
|
|
122
|
+
await chmod(join(bin, 'docker'), 0o755);
|
|
123
|
+
const log = join(root, 'docker.log');
|
|
124
|
+
const environment = { ...process.env, PATH: `${bin}:${process.env.PATH}`, DOCKER_LOG: log };
|
|
125
|
+
return { root, log, environment, archive: join(root, 'deploy/example_project-docker.tar.gz') };
|
|
127
126
|
}
|
|
128
127
|
|
|
129
|
-
function
|
|
130
|
-
return run(
|
|
128
|
+
async function buildProject(item, extraEnvironment = {}) {
|
|
129
|
+
return run(join(item.root, 'deploy.sh'), ['build'], { cwd: item.root, env: { ...item.environment, ...extraEnvironment } });
|
|
131
130
|
}
|
|
132
131
|
|
|
133
|
-
async function
|
|
134
|
-
|
|
132
|
+
async function extractArchive(item) {
|
|
133
|
+
const extracted = await mkdtemp(join(tmpdir(), 'wdyy-unified-package-'));
|
|
134
|
+
created.push(extracted);
|
|
135
|
+
await run('tar', ['-xzf', item.archive, '-C', extracted]);
|
|
136
|
+
return extracted;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
join(root,
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
async function rewriteManifest(root) {
|
|
140
|
+
const names = ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar'];
|
|
141
|
+
const lines = [];
|
|
142
|
+
for (const name of names) {
|
|
143
|
+
const hash = createHash('sha256').update(await readFile(join(root, name))).digest('hex');
|
|
144
|
+
lines.push(`${hash} ${name}`);
|
|
145
|
+
}
|
|
146
|
+
await writeFile(join(root, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
async function
|
|
147
|
-
const
|
|
148
|
-
await
|
|
149
|
-
await
|
|
150
|
-
return serverRoot;
|
|
149
|
+
async function setTargetPlatform(root, platform) {
|
|
150
|
+
const environmentPath = join(root, '.env');
|
|
151
|
+
const environment = await readFile(environmentPath, 'utf8');
|
|
152
|
+
await writeFile(environmentPath, environment.replace(/^DOCKER_PLATFORM=.*$/m, `DOCKER_PLATFORM=${platform}`), { mode: 0o600 });
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
155
|
+
async function readImageArchivePlatform(archive) {
|
|
156
|
+
const { stdout: manifestText } = await run('tar', ['-xOf', archive, 'manifest.json']);
|
|
157
|
+
const [{ Config: configName }] = JSON.parse(manifestText);
|
|
158
|
+
const { stdout: configText } = await run('tar', ['-xOf', archive, configName]);
|
|
159
|
+
const config = JSON.parse(configText);
|
|
160
|
+
return `${config.os}/${config.architecture}`;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
async function
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return `${createHash('sha256').update(await readFile(path)).digest('hex')} ${relativePath}`;
|
|
174
|
-
}));
|
|
175
|
-
await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
163
|
+
async function rewriteImageArchivePlatform(archive, platform) {
|
|
164
|
+
const temporary = await mkdtemp(join(tmpdir(), 'wdyy-image-platform-'));
|
|
165
|
+
created.push(temporary);
|
|
166
|
+
await run('tar', ['-xf', archive, '-C', temporary]);
|
|
167
|
+
const manifest = JSON.parse(await readFile(join(temporary, 'manifest.json'), 'utf8'));
|
|
168
|
+
const configName = manifest[0].Config;
|
|
169
|
+
const config = JSON.parse(await readFile(join(temporary, configName), 'utf8'));
|
|
170
|
+
const [os, architecture] = platform.split('/');
|
|
171
|
+
config.os = os;
|
|
172
|
+
config.architecture = architecture;
|
|
173
|
+
await writeFile(join(temporary, configName), `${JSON.stringify(config)}\n`);
|
|
174
|
+
await run('tar', ['-cf', archive, '-C', temporary, 'manifest.json', configName]);
|
|
176
175
|
}
|
|
177
176
|
|
|
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/);
|
|
177
|
+
test.after(async () => {
|
|
178
|
+
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
197
179
|
});
|
|
198
180
|
|
|
199
|
-
test('
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
assert.
|
|
181
|
+
test('build 使用本地基础镜像并生成一个含双镜像归档的压缩包', async () => {
|
|
182
|
+
const item = await fixture();
|
|
183
|
+
const result = await buildProject(item);
|
|
184
|
+
assert.match(result.stdout, /delivery archive created/);
|
|
185
|
+
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
186
|
+
const uncompressed = await unzip(await readFile(item.archive));
|
|
187
|
+
assert.equal(uncompressed.includes(Buffer.from('./._.env')), false);
|
|
188
|
+
const listing = await run('tar', ['-tzf', item.archive]);
|
|
189
|
+
assert.doesNotMatch(listing.stdout, /(^|\/)\._[^/]*(?:\n|$)/m);
|
|
190
|
+
assert.doesNotMatch(listing.stderr, /LIBARCHIVE\.xattr|unknown extended header/i);
|
|
191
|
+
const extracted = await extractArchive(item);
|
|
192
|
+
assert.deepEqual((await readdir(extracted)).sort(), ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'].sort());
|
|
193
|
+
const validation = await run(process.execPath, [validator, extracted]);
|
|
194
|
+
assert.match(validation.stdout, /valid unified Docker delivery package/);
|
|
195
|
+
const log = await readFile(item.log, 'utf8');
|
|
196
|
+
assert.match(log, /image inspect nginx:stable/);
|
|
197
|
+
assert.match(log, /image inspect node:24-alpine3\.24/);
|
|
198
|
+
assert.equal((log.match(/build --pull=false --platform linux\/amd64/g) ?? []).length, 2);
|
|
199
|
+
assert.match(log, /build --pull=false --platform linux\/amd64 .*--build-arg VITE_API_BASE_URL=\/api .*example_project_frontend:latest/);
|
|
200
|
+
assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
|
|
201
|
+
assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
|
|
202
|
+
assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
|
|
209
203
|
});
|
|
210
204
|
|
|
211
|
-
test('
|
|
212
|
-
const
|
|
213
|
-
assert.
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
assert.equal(failed.status, 9);
|
|
218
|
-
assert.equal(await releaseVersion(fixture.root), version);
|
|
219
|
-
assert.equal(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), agents);
|
|
205
|
+
test('基础镜像平台不匹配时在 build 前失败', async () => {
|
|
206
|
+
const item = await fixture();
|
|
207
|
+
await assert.rejects(buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' }), /image platform mismatch for nginx:stable: expected linux\/amd64, got linux\/arm64/);
|
|
208
|
+
const log = await readFile(item.log, 'utf8');
|
|
209
|
+
assert.doesNotMatch(log, /^build /m);
|
|
210
|
+
assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
|
|
220
211
|
});
|
|
221
212
|
|
|
222
|
-
test('
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
213
|
+
test('linux/arm64 目标在不同构建机平台上生成并部署单架构交付包', async () => {
|
|
214
|
+
const item = await fixture();
|
|
215
|
+
await setTargetPlatform(item.root, 'linux/arm64');
|
|
216
|
+
await buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' });
|
|
217
|
+
const buildLog = await readFile(item.log, 'utf8');
|
|
218
|
+
assert.equal((buildLog.match(/build --pull=false --platform linux\/arm64/g) ?? []).length, 2);
|
|
219
|
+
const extracted = await extractArchive(item);
|
|
220
|
+
assert.equal(await readImageArchivePlatform(join(extracted, 'frontend-image.tar')), 'linux/arm64');
|
|
221
|
+
assert.equal(await readImageArchivePlatform(join(extracted, 'backend-image.tar')), 'linux/arm64');
|
|
222
|
+
const validation = await run(process.execPath, [validator, extracted]);
|
|
223
|
+
assert.match(validation.stdout, /valid unified Docker delivery package/);
|
|
224
|
+
await writeFile(item.log, '');
|
|
225
|
+
await run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, IMAGE_PLATFORM: 'linux/arm64', SERVER_PLATFORM: 'linux/arm64' } });
|
|
226
|
+
const deployLog = await readFile(item.log, 'utf8');
|
|
227
|
+
assert.match(deployLog, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
|
|
228
|
+
assert.equal((deployLog.match(/^load -i /gm) ?? []).length, 2);
|
|
231
229
|
});
|
|
232
230
|
|
|
233
|
-
test('
|
|
234
|
-
const
|
|
235
|
-
assert.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
assert.notEqual(validation.status, 0);
|
|
241
|
-
assert.match(validation.stderr, /fallback|logs/);
|
|
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/);
|
|
231
|
+
test('缺少本地基础镜像时在 build 前失败', async () => {
|
|
232
|
+
const item = await fixture();
|
|
233
|
+
await assert.rejects(buildProject(item, { MISSING_IMAGE: 'nginx:stable' }), /required local frontend base image is missing/);
|
|
234
|
+
const log = await readFile(item.log, 'utf8');
|
|
235
|
+
assert.match(log, /^image inspect nginx:stable$/m);
|
|
236
|
+
assert.doesNotMatch(log, /^build /m);
|
|
237
|
+
assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
|
|
249
238
|
});
|
|
250
239
|
|
|
251
|
-
test('
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
await
|
|
257
|
-
|
|
258
|
-
assert.
|
|
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`));
|
|
240
|
+
test('构建失败保留上一份成功交付包且不暴露半成品', async () => {
|
|
241
|
+
const item = await fixture();
|
|
242
|
+
await buildProject(item);
|
|
243
|
+
const before = createHash('sha256').update(await readFile(item.archive)).digest('hex');
|
|
244
|
+
await assert.rejects(buildProject(item, { FAIL_BUILD: 'backend' }));
|
|
245
|
+
const after = createHash('sha256').update(await readFile(item.archive)).digest('hex');
|
|
246
|
+
assert.equal(after, before);
|
|
247
|
+
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
265
248
|
});
|
|
266
249
|
|
|
267
|
-
test('
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
250
|
+
test('服务器无参数执行时从脚本目录整体加载并部署前后端', async () => {
|
|
251
|
+
const item = await fixture();
|
|
252
|
+
await buildProject(item);
|
|
253
|
+
const extracted = await extractArchive(item);
|
|
254
|
+
await writeFile(item.log, '');
|
|
255
|
+
await run(join(extracted, 'deploy.sh'), [], { cwd: tmpdir(), env: item.environment });
|
|
256
|
+
const log = await readFile(item.log, 'utf8');
|
|
257
|
+
const versionIndex = log.indexOf('version --format {{.Server.Os}}/{{.Server.Arch}}');
|
|
258
|
+
const firstLoadIndex = log.indexOf('load -i ');
|
|
259
|
+
assert(versionIndex >= 0 && firstLoadIndex > versionIndex);
|
|
260
|
+
assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
|
|
261
|
+
assert.match(log, /compose --env-file .* --project-name example_project -f .* up -d --force-recreate --no-build --pull never --wait frontend backend/);
|
|
262
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=success/);
|
|
263
|
+
});
|
|
272
264
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
265
|
+
test('服务器平台无法读取、不受支持或不匹配时在加载镜像前失败', async () => {
|
|
266
|
+
const item = await fixture();
|
|
267
|
+
await buildProject(item);
|
|
268
|
+
const cases = [
|
|
269
|
+
[{ FAIL_SERVER_PLATFORM_READ: '1' }, /Docker Server platform is unavailable/],
|
|
270
|
+
[{ SERVER_PLATFORM: 'windows/amd64' }, /unsupported Docker Server platform: windows\/amd64/],
|
|
271
|
+
[{ SERVER_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/amd64, got linux\/arm64/],
|
|
272
|
+
];
|
|
273
|
+
for (const [extraEnvironment, expectedError] of cases) {
|
|
274
|
+
const extracted = await extractArchive(item);
|
|
275
|
+
await writeFile(item.log, '');
|
|
276
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, ...extraEnvironment } }), expectedError);
|
|
277
|
+
const log = await readFile(item.log, 'utf8');
|
|
278
|
+
assert.match(log, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
|
|
279
|
+
assert.doesNotMatch(log, /^load -i /m);
|
|
280
|
+
assert.doesNotMatch(log, /^compose .* (?:up|stop) /m);
|
|
281
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
279
284
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
test('服务器在 Docker 操作前拒绝 AppleDouble 和上传压缩包残留', async () => {
|
|
286
|
+
const item = await fixture();
|
|
287
|
+
await buildProject(item);
|
|
288
|
+
const extracted = await extractArchive(item);
|
|
289
|
+
await writeFile(item.log, '');
|
|
290
|
+
await writeFile(join(extracted, '._deploy.sh'), 'AppleDouble\n');
|
|
291
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: \._deploy\.sh/);
|
|
292
|
+
assert.equal(await readFile(item.log, 'utf8'), '');
|
|
293
|
+
await rm(join(extracted, '._deploy.sh'));
|
|
294
|
+
await writeFile(join(extracted, 'example_project-docker.tar.gz'), 'uploaded archive\n');
|
|
295
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: example_project-docker\.tar\.gz/);
|
|
296
|
+
assert.equal(await readFile(item.log, 'utf8'), '');
|
|
297
|
+
});
|
|
284
298
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Docker', async () => {
|
|
300
|
+
const item = await fixture();
|
|
301
|
+
await buildProject(item);
|
|
302
|
+
const extracted = await extractArchive(item);
|
|
303
|
+
await writeFile(item.log, '');
|
|
304
|
+
await run(join(extracted, 'deploy.sh'), ['stop'], { env: item.environment });
|
|
305
|
+
await run(join(extracted, 'deploy.sh'), ['status'], { env: item.environment });
|
|
306
|
+
const beforeInvalid = await readFile(item.log, 'utf8');
|
|
307
|
+
assert.match(beforeInvalid, /stop frontend backend/);
|
|
308
|
+
assert.match(beforeInvalid, /ps frontend backend/);
|
|
309
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), ['frontend'], { env: item.environment }), /usage:/);
|
|
310
|
+
assert.equal(await readFile(item.log, 'utf8'), beforeInvalid);
|
|
311
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=stop result=success/);
|
|
293
312
|
});
|
|
294
313
|
|
|
295
|
-
test('
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
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/);
|
|
314
|
+
test('整栈健康等待失败返回非零并记录失败', async () => {
|
|
315
|
+
const item = await fixture();
|
|
316
|
+
await buildProject(item);
|
|
317
|
+
const extracted = await extractArchive(item);
|
|
318
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, FAIL_COMPOSE_UP: '1' } }));
|
|
319
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
306
320
|
});
|
|
307
321
|
|
|
308
|
-
test('
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
await writeFile(
|
|
313
|
-
|
|
314
|
-
assert.
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
322
|
+
test('validator 拒绝数据库容器和不完整远程数据库配置', async () => {
|
|
323
|
+
const item = await fixture();
|
|
324
|
+
await buildProject(item);
|
|
325
|
+
const databasePackage = await extractArchive(item);
|
|
326
|
+
await writeFile(join(databasePackage, 'docker-compose.yml'), `${await readFile(join(databasePackage, 'docker-compose.yml'), 'utf8')}\n database:\n image: postgres:18\n`);
|
|
327
|
+
await rewriteManifest(databasePackage);
|
|
328
|
+
await assert.rejects(run(process.execPath, [validator, databasePackage]), /Compose must define only frontend and backend|contains a database/);
|
|
329
|
+
|
|
330
|
+
const envPackage = await extractArchive(item);
|
|
331
|
+
await writeFile(join(envPackage, '.env'), envText.replace('DB_URL=db.internal', 'DB_URL='), { mode: 0o600 });
|
|
332
|
+
await rewriteManifest(envPackage);
|
|
333
|
+
await assert.rejects(run(process.execPath, [validator, envPackage]), /Root \.env is missing DB_URL/);
|
|
318
334
|
});
|
|
319
335
|
|
|
320
|
-
test('
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
await
|
|
327
|
-
await
|
|
328
|
-
|
|
329
|
-
await
|
|
330
|
-
const
|
|
331
|
-
await writeFile(
|
|
332
|
-
|
|
333
|
-
assert.
|
|
334
|
-
assert.match(result.stderr, /exactly one expected tag/);
|
|
335
|
-
assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load/);
|
|
336
|
+
test('validator 拒绝冲突的 API 路径和缺失的代理注入', async () => {
|
|
337
|
+
const item = await fixture();
|
|
338
|
+
await buildProject(item);
|
|
339
|
+
|
|
340
|
+
const envPackage = await extractArchive(item);
|
|
341
|
+
await writeFile(join(envPackage, '.env'), envText.replace('VITE_API_BASE_URL=/api', 'VITE_API_BASE_URL=/service'), { mode: 0o600 });
|
|
342
|
+
await rewriteManifest(envPackage);
|
|
343
|
+
await assert.rejects(run(process.execPath, [validator, envPackage]), /VITE_API_BASE_URL must equal \/API_PREFIX/);
|
|
344
|
+
|
|
345
|
+
const composePackage = await extractArchive(item);
|
|
346
|
+
const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
|
|
347
|
+
await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"\n', ''));
|
|
348
|
+
await rewriteManifest(composePackage);
|
|
349
|
+
await assert.rejects(run(process.execPath, [validator, composePackage]), /Frontend API path is not passed to Nginx/);
|
|
336
350
|
});
|
|
337
351
|
|
|
338
|
-
test('
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
await
|
|
347
|
-
await assert.rejects(access(join(unrelated, 'logs/deploy.log')));
|
|
352
|
+
test('validator 拒绝清单篡改和额外包内容', async () => {
|
|
353
|
+
const item = await fixture();
|
|
354
|
+
await buildProject(item);
|
|
355
|
+
const extracted = await extractArchive(item);
|
|
356
|
+
await writeFile(join(extracted, 'unexpected.txt'), 'unexpected\n');
|
|
357
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /Unexpected package entry/);
|
|
358
|
+
await rm(join(extracted, 'unexpected.txt'));
|
|
359
|
+
await writeFile(join(extracted, 'docker-compose.yml'), 'tampered\n');
|
|
360
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /Compose must define only frontend and backend|Checksum mismatch/);
|
|
348
361
|
});
|
|
349
362
|
|
|
350
|
-
test('validator
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
await
|
|
355
|
-
|
|
363
|
+
test('validator 拒绝镜像归档平台与 DOCKER_PLATFORM 不一致', async () => {
|
|
364
|
+
const item = await fixture();
|
|
365
|
+
await buildProject(item);
|
|
366
|
+
const extracted = await extractArchive(item);
|
|
367
|
+
await rewriteImageArchivePlatform(join(extracted, 'backend-image.tar'), 'linux/arm64');
|
|
368
|
+
await rewriteManifest(extracted);
|
|
369
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /backend-image\.tar platform must equal DOCKER_PLATFORM/);
|
|
370
|
+
});
|
|
356
371
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
await
|
|
361
|
-
await
|
|
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/);
|
|
372
|
+
test('build 在任何 Docker 操作前拒绝环境文件命令语法', async () => {
|
|
373
|
+
const item = await fixture();
|
|
374
|
+
await writeFile(join(item.root, '.env'), envText.replace('DB_PASSWORD=secret', 'DB_PASSWORD=$(id)'), { mode: 0o600 });
|
|
375
|
+
await assert.rejects(buildProject(item), /executable syntax is forbidden in \.env: DB_PASSWORD/);
|
|
376
|
+
await assert.rejects(stat(item.log), { code: 'ENOENT' });
|
|
366
377
|
});
|