@wdyy/skills 0.1.13 → 0.1.15
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-api-standard/reference/api-his-sql.md +38 -0
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +20 -4
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +25 -14
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +44 -8
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +148 -2
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +39 -4
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +287 -6
- package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +4 -5
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +89 -9
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +12 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +5 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +9 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +3 -0
- package/README.md +2 -2
- package/lib/wdyy-cli.js +2 -2
- package/package.json +1 -1
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -7,8 +7,10 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
+
import { gunzip } from 'node:zlib';
|
|
10
11
|
|
|
11
12
|
const run = promisify(execFile);
|
|
13
|
+
const unzip = promisify(gunzip);
|
|
12
14
|
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
|
|
13
15
|
const generator = join(scriptsRoot, 'generate-deployment-files.mjs');
|
|
14
16
|
const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
|
|
@@ -29,11 +31,15 @@ DB_NAME=app
|
|
|
29
31
|
DB_SCHEMA=public
|
|
30
32
|
# API
|
|
31
33
|
API_URL=
|
|
34
|
+
API_PREFIX=api
|
|
35
|
+
VITE_API_BASE_URL=/api
|
|
32
36
|
# 部署
|
|
33
37
|
PROJECT_NAME=example_project
|
|
34
38
|
DOCKER_BIND_IP=0.0.0.0
|
|
39
|
+
DOCKER_PLATFORM=linux/amd64
|
|
35
40
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
36
|
-
BACKEND_BASE_IMAGE=node:24-
|
|
41
|
+
BACKEND_BASE_IMAGE=node:24-bookworm-slim
|
|
42
|
+
TZ=Asia/Shanghai
|
|
37
43
|
`;
|
|
38
44
|
const exampleText = `# 前端
|
|
39
45
|
FRONTEND_URL=0.0.0.0
|
|
@@ -50,11 +56,15 @@ DB_NAME=
|
|
|
50
56
|
DB_SCHEMA=
|
|
51
57
|
# API
|
|
52
58
|
API_URL=
|
|
59
|
+
API_PREFIX=api
|
|
60
|
+
VITE_API_BASE_URL=/api
|
|
53
61
|
# 部署
|
|
54
62
|
PROJECT_NAME=
|
|
55
63
|
DOCKER_BIND_IP=
|
|
64
|
+
DOCKER_PLATFORM=linux/amd64
|
|
56
65
|
FRONTEND_BASE_IMAGE=nginx:stable
|
|
57
|
-
BACKEND_BASE_IMAGE=node:24-
|
|
66
|
+
BACKEND_BASE_IMAGE=node:24-bookworm-slim
|
|
67
|
+
TZ=Asia/Shanghai
|
|
58
68
|
`;
|
|
59
69
|
|
|
60
70
|
const fakeDocker = `#!/usr/bin/env node
|
|
@@ -64,9 +74,15 @@ const path = require('path');
|
|
|
64
74
|
const cp = require('child_process');
|
|
65
75
|
const args = process.argv.slice(2);
|
|
66
76
|
if (process.env.DOCKER_LOG) fs.appendFileSync(process.env.DOCKER_LOG, args.join(' ') + '\\n');
|
|
77
|
+
if (args[0] === 'version') {
|
|
78
|
+
if (process.env.FAIL_SERVER_PLATFORM_READ === '1') process.exit(1);
|
|
79
|
+
process.stdout.write((process.env.SERVER_PLATFORM || 'linux/amd64') + '\\n');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
67
82
|
if (args[0] === 'image' && args[1] === 'inspect') {
|
|
68
83
|
const image = args[args.length - 1];
|
|
69
84
|
if (process.env.MISSING_IMAGE === image) process.exit(1);
|
|
85
|
+
if (args.includes('--format')) process.stdout.write((process.env.IMAGE_PLATFORM || 'linux/amd64') + '\\n');
|
|
70
86
|
process.exit(0);
|
|
71
87
|
}
|
|
72
88
|
if (args[0] === 'build') {
|
|
@@ -75,12 +91,37 @@ if (args[0] === 'build') {
|
|
|
75
91
|
if (process.env.FAIL_BUILD && tag.includes(process.env.FAIL_BUILD)) process.exit(1);
|
|
76
92
|
process.exit(0);
|
|
77
93
|
}
|
|
94
|
+
if (args[0] === 'run') {
|
|
95
|
+
const image = args[args.indexOf('-c') - 1];
|
|
96
|
+
if (process.env.FAIL_IMAGE_TIME && image.includes(process.env.FAIL_IMAGE_TIME)) process.exit(1);
|
|
97
|
+
process.stdout.write((process.env.IMAGE_TIME || '19 CST +0800') + '\\n');
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
if (args[0] === 'exec') {
|
|
101
|
+
const name = args[1];
|
|
102
|
+
if (process.env.FAIL_CONTAINER_TIME && name.includes(process.env.FAIL_CONTAINER_TIME)) process.exit(1);
|
|
103
|
+
process.stdout.write((process.env.CONTAINER_TIME || '19 CST +0800') + '\\n');
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
if (args[0] === 'container' && args[1] === 'ls') {
|
|
107
|
+
const filter = args[args.indexOf('--filter') + 1] || '';
|
|
108
|
+
const name = filter.replace('name=^/', '').replace('$$', '').replace('$', '');
|
|
109
|
+
if (process.env.EXISTING_CONTAINER_NAME === name) process.stdout.write(name + '\\n');
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
if (args[0] === 'container' && args[1] === 'inspect') {
|
|
113
|
+
const name = args[args.length - 1];
|
|
114
|
+
if (process.env.EXISTING_CONTAINER_NAME !== name) process.exit(1);
|
|
115
|
+
process.stdout.write((process.env.CONTAINER_PROJECT || '') + '|' + (process.env.CONTAINER_SERVICE || '') + '\\n');
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
78
118
|
if (args[0] === 'save') {
|
|
79
119
|
const output = args[args.indexOf('-o') + 1];
|
|
80
120
|
const tag = args[args.length - 1];
|
|
121
|
+
const [imageOs, imageArchitecture] = (process.env.IMAGE_PLATFORM || 'linux/amd64').split('/');
|
|
81
122
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-image-'));
|
|
82
123
|
fs.writeFileSync(path.join(temporary, 'manifest.json'), JSON.stringify([{Config:'config.json',RepoTags:[tag],Layers:[]}]) + '\\n');
|
|
83
|
-
fs.writeFileSync(path.join(temporary, 'config.json'),
|
|
124
|
+
fs.writeFileSync(path.join(temporary, 'config.json'), JSON.stringify({architecture:imageArchitecture,os:imageOs}) + '\\n');
|
|
84
125
|
const result = cp.spawnSync('tar', ['-cf', output, '-C', temporary, 'manifest.json', 'config.json']);
|
|
85
126
|
fs.rmSync(temporary, {recursive:true, force:true});
|
|
86
127
|
process.exit(result.status || 0);
|
|
@@ -88,6 +129,9 @@ if (args[0] === 'save') {
|
|
|
88
129
|
if (args[0] === 'compose' && args.includes('up') && process.env.FAIL_COMPOSE_UP === '1') process.exit(1);
|
|
89
130
|
process.exit(0);
|
|
90
131
|
`;
|
|
132
|
+
const fakeUname = `#!/usr/bin/env node
|
|
133
|
+
process.stdout.write((process.env.HOST_KERNEL || 'Linux') + '\\n');
|
|
134
|
+
`;
|
|
91
135
|
|
|
92
136
|
async function fixture() {
|
|
93
137
|
const root = await mkdtemp(join(tmpdir(), 'wdyy-unified-delivery-'));
|
|
@@ -104,7 +148,9 @@ async function fixture() {
|
|
|
104
148
|
const bin = join(root, 'fake-bin');
|
|
105
149
|
await mkdir(bin);
|
|
106
150
|
await writeFile(join(bin, 'docker'), fakeDocker, { mode: 0o755 });
|
|
151
|
+
await writeFile(join(bin, 'uname'), fakeUname, { mode: 0o755 });
|
|
107
152
|
await chmod(join(bin, 'docker'), 0o755);
|
|
153
|
+
await chmod(join(bin, 'uname'), 0o755);
|
|
108
154
|
const log = join(root, 'docker.log');
|
|
109
155
|
const environment = { ...process.env, PATH: `${bin}:${process.env.PATH}`, DOCKER_LOG: log };
|
|
110
156
|
return { root, log, environment, archive: join(root, 'deploy/example_project-docker.tar.gz') };
|
|
@@ -131,6 +177,34 @@ async function rewriteManifest(root) {
|
|
|
131
177
|
await writeFile(join(root, 'manifest.sha256'), `${lines.join('\n')}\n`);
|
|
132
178
|
}
|
|
133
179
|
|
|
180
|
+
async function setTargetPlatform(root, platform) {
|
|
181
|
+
const environmentPath = join(root, '.env');
|
|
182
|
+
const environment = await readFile(environmentPath, 'utf8');
|
|
183
|
+
await writeFile(environmentPath, environment.replace(/^DOCKER_PLATFORM=.*$/m, `DOCKER_PLATFORM=${platform}`), { mode: 0o600 });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readImageArchivePlatform(archive) {
|
|
187
|
+
const { stdout: manifestText } = await run('tar', ['-xOf', archive, 'manifest.json']);
|
|
188
|
+
const [{ Config: configName }] = JSON.parse(manifestText);
|
|
189
|
+
const { stdout: configText } = await run('tar', ['-xOf', archive, configName]);
|
|
190
|
+
const config = JSON.parse(configText);
|
|
191
|
+
return `${config.os}/${config.architecture}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function rewriteImageArchivePlatform(archive, platform) {
|
|
195
|
+
const temporary = await mkdtemp(join(tmpdir(), 'wdyy-image-platform-'));
|
|
196
|
+
created.push(temporary);
|
|
197
|
+
await run('tar', ['-xf', archive, '-C', temporary]);
|
|
198
|
+
const manifest = JSON.parse(await readFile(join(temporary, 'manifest.json'), 'utf8'));
|
|
199
|
+
const configName = manifest[0].Config;
|
|
200
|
+
const config = JSON.parse(await readFile(join(temporary, configName), 'utf8'));
|
|
201
|
+
const [os, architecture] = platform.split('/');
|
|
202
|
+
config.os = os;
|
|
203
|
+
config.architecture = architecture;
|
|
204
|
+
await writeFile(join(temporary, configName), `${JSON.stringify(config)}\n`);
|
|
205
|
+
await run('tar', ['-cf', archive, '-C', temporary, 'manifest.json', configName]);
|
|
206
|
+
}
|
|
207
|
+
|
|
134
208
|
test.after(async () => {
|
|
135
209
|
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
136
210
|
});
|
|
@@ -140,19 +214,64 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
|
|
|
140
214
|
const result = await buildProject(item);
|
|
141
215
|
assert.match(result.stdout, /delivery archive created/);
|
|
142
216
|
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
217
|
+
const uncompressed = await unzip(await readFile(item.archive));
|
|
218
|
+
assert.equal(uncompressed.includes(Buffer.from('./._.env')), false);
|
|
219
|
+
const listing = await run('tar', ['-tzf', item.archive]);
|
|
220
|
+
assert.doesNotMatch(listing.stdout, /(^|\/)\._[^/]*(?:\n|$)/m);
|
|
221
|
+
assert.doesNotMatch(listing.stderr, /LIBARCHIVE\.xattr|unknown extended header/i);
|
|
143
222
|
const extracted = await extractArchive(item);
|
|
144
223
|
assert.deepEqual((await readdir(extracted)).sort(), ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'].sort());
|
|
145
224
|
const validation = await run(process.execPath, [validator, extracted]);
|
|
146
225
|
assert.match(validation.stdout, /valid unified Docker delivery package/);
|
|
226
|
+
const compose = await readFile(join(extracted, 'docker-compose.yml'), 'utf8');
|
|
227
|
+
assert.equal((compose.match(/TZ: "\$\{TZ:\?TZ is required\}"/g) ?? []).length, 2);
|
|
228
|
+
assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_frontend"/);
|
|
229
|
+
assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_backend"/);
|
|
230
|
+
assert.equal((compose.match(/restart: unless-stopped/g) ?? []).length, 2);
|
|
147
231
|
const log = await readFile(item.log, 'utf8');
|
|
148
232
|
assert.match(log, /image inspect nginx:stable/);
|
|
149
|
-
assert.match(log, /image inspect node:24-
|
|
150
|
-
assert.equal((log.match(/build --pull=false/g) ?? []).length, 2);
|
|
233
|
+
assert.match(log, /image inspect node:24-bookworm-slim/);
|
|
234
|
+
assert.equal((log.match(/build --pull=false --platform linux\/amd64/g) ?? []).length, 2);
|
|
235
|
+
assert.match(log, /build --pull=false --platform linux\/amd64 .*--build-arg VITE_API_BASE_URL=\/api .*example_project_frontend:latest/);
|
|
236
|
+
assert.equal((log.match(/^run --rm --pull=never --platform linux\/amd64 /gm) ?? []).length, 2);
|
|
151
237
|
assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
|
|
152
238
|
assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
|
|
153
239
|
assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
|
|
154
240
|
});
|
|
155
241
|
|
|
242
|
+
test('基础镜像平台不匹配时在 build 前失败', async () => {
|
|
243
|
+
const item = await fixture();
|
|
244
|
+
await assert.rejects(buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' }), /image platform mismatch for nginx:stable: expected linux\/amd64, got linux\/arm64/);
|
|
245
|
+
const log = await readFile(item.log, 'utf8');
|
|
246
|
+
assert.doesNotMatch(log, /^build /m);
|
|
247
|
+
assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('macOS arm64 与 Windows WSL2 使用 Linux engine 生成并部署单架构交付包', async () => {
|
|
251
|
+
const item = await fixture();
|
|
252
|
+
await setTargetPlatform(item.root, 'linux/arm64');
|
|
253
|
+
await buildProject(item, { HOST_KERNEL: 'Darwin', IMAGE_PLATFORM: 'linux/arm64' });
|
|
254
|
+
const buildLog = await readFile(item.log, 'utf8');
|
|
255
|
+
assert.equal((buildLog.match(/build --pull=false --platform linux\/arm64/g) ?? []).length, 2);
|
|
256
|
+
const extracted = await extractArchive(item);
|
|
257
|
+
assert.equal(await readImageArchivePlatform(join(extracted, 'frontend-image.tar')), 'linux/arm64');
|
|
258
|
+
assert.equal(await readImageArchivePlatform(join(extracted, 'backend-image.tar')), 'linux/arm64');
|
|
259
|
+
const validation = await run(process.execPath, [validator, extracted]);
|
|
260
|
+
assert.match(validation.stdout, /valid unified Docker delivery package/);
|
|
261
|
+
await writeFile(item.log, '');
|
|
262
|
+
await run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, HOST_KERNEL: 'Linux', WSL_DISTRO_NAME: 'Ubuntu', IMAGE_PLATFORM: 'linux/arm64', SERVER_PLATFORM: 'linux/arm64' } });
|
|
263
|
+
const deployLog = await readFile(item.log, 'utf8');
|
|
264
|
+
assert.match(deployLog, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
|
|
265
|
+
assert.equal((deployLog.match(/^load -i /gm) ?? []).length, 2);
|
|
266
|
+
assert.equal((deployLog.match(/^exec example_project_(?:frontend|backend) /gm) ?? []).length, 2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('原生 Windows shell 在任何 Docker 操作前被拒绝', async () => {
|
|
270
|
+
const item = await fixture();
|
|
271
|
+
await assert.rejects(buildProject(item, { HOST_KERNEL: 'Windows_NT' }), /unsupported execution host: Windows_NT/);
|
|
272
|
+
await assert.rejects(stat(item.log), { code: 'ENOENT' });
|
|
273
|
+
});
|
|
274
|
+
|
|
156
275
|
test('缺少本地基础镜像时在 build 前失败', async () => {
|
|
157
276
|
const item = await fixture();
|
|
158
277
|
await assert.rejects(buildProject(item, { MISSING_IMAGE: 'nginx:stable' }), /required local frontend base image is missing/);
|
|
@@ -172,6 +291,20 @@ test('构建失败保留上一份成功交付包且不暴露半成品', async ()
|
|
|
172
291
|
assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
|
|
173
292
|
});
|
|
174
293
|
|
|
294
|
+
test('镜像时间探测失败时保留上一份成功交付包', async () => {
|
|
295
|
+
const item = await fixture();
|
|
296
|
+
await buildProject(item);
|
|
297
|
+
const before = createHash('sha256').update(await readFile(item.archive)).digest('hex');
|
|
298
|
+
await assert.rejects(buildProject(item, { FAIL_IMAGE_TIME: 'frontend' }), /unable to verify image CST time: example_project_frontend:latest/);
|
|
299
|
+
const after = createHash('sha256').update(await readFile(item.archive)).digest('hex');
|
|
300
|
+
assert.equal(after, before);
|
|
301
|
+
const log = await readFile(item.log, 'utf8');
|
|
302
|
+
const lastSave = log.lastIndexOf('save -o ');
|
|
303
|
+
const lastRun = log.lastIndexOf('run --rm --pull=never');
|
|
304
|
+
assert(lastRun > lastSave);
|
|
305
|
+
assert.doesNotMatch(log.slice(lastRun), /^save -o /m);
|
|
306
|
+
});
|
|
307
|
+
|
|
175
308
|
test('服务器无参数执行时从脚本目录整体加载并部署前后端', async () => {
|
|
176
309
|
const item = await fixture();
|
|
177
310
|
await buildProject(item);
|
|
@@ -179,9 +312,68 @@ test('服务器无参数执行时从脚本目录整体加载并部署前后端',
|
|
|
179
312
|
await writeFile(item.log, '');
|
|
180
313
|
await run(join(extracted, 'deploy.sh'), [], { cwd: tmpdir(), env: item.environment });
|
|
181
314
|
const log = await readFile(item.log, 'utf8');
|
|
315
|
+
const versionIndex = log.indexOf('version --format {{.Server.Os}}/{{.Server.Arch}}');
|
|
316
|
+
const firstLoadIndex = log.indexOf('load -i ');
|
|
317
|
+
const ownershipIndex = log.indexOf('container ls --all --filter name=^/example_project_frontend$');
|
|
318
|
+
assert(versionIndex >= 0 && ownershipIndex > versionIndex && firstLoadIndex > ownershipIndex);
|
|
182
319
|
assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
|
|
183
320
|
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(
|
|
321
|
+
assert.equal((log.match(/^exec example_project_(?:frontend|backend) /gm) ?? []).length, 2);
|
|
322
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /CST \+0800 operation=deploy result=success/);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('服务器平台无法读取、不受支持或不匹配时在加载镜像前失败', async () => {
|
|
326
|
+
const item = await fixture();
|
|
327
|
+
await buildProject(item);
|
|
328
|
+
const cases = [
|
|
329
|
+
[{ FAIL_SERVER_PLATFORM_READ: '1' }, /Docker Server platform is unavailable/],
|
|
330
|
+
[{ SERVER_PLATFORM: 'windows/amd64' }, /Windows container mode is unsupported; switch Docker Desktop to Linux containers/],
|
|
331
|
+
[{ SERVER_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/amd64, got linux\/arm64/],
|
|
332
|
+
];
|
|
333
|
+
for (const [extraEnvironment, expectedError] of cases) {
|
|
334
|
+
const extracted = await extractArchive(item);
|
|
335
|
+
await writeFile(item.log, '');
|
|
336
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, ...extraEnvironment } }), expectedError);
|
|
337
|
+
const log = await readFile(item.log, 'utf8');
|
|
338
|
+
assert.match(log, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
|
|
339
|
+
assert.doesNotMatch(log, /^load -i /m);
|
|
340
|
+
assert.doesNotMatch(log, /^compose .* (?:up|stop) /m);
|
|
341
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('固定容器名冲突在镜像加载前失败,当前项目所有权允许继续', async () => {
|
|
346
|
+
const item = await fixture();
|
|
347
|
+
await buildProject(item);
|
|
348
|
+
const conflictPackage = await extractArchive(item);
|
|
349
|
+
await writeFile(item.log, '');
|
|
350
|
+
await assert.rejects(run(join(conflictPackage, 'deploy.sh'), [], {
|
|
351
|
+
env: { ...item.environment, EXISTING_CONTAINER_NAME: 'example_project_frontend', CONTAINER_PROJECT: 'other_project', CONTAINER_SERVICE: 'frontend' }
|
|
352
|
+
}), /container name conflict: example_project_frontend belongs to other_project\|frontend/);
|
|
353
|
+
const conflictLog = await readFile(item.log, 'utf8');
|
|
354
|
+
assert.match(conflictLog, /^container inspect .*example_project_frontend$/m);
|
|
355
|
+
assert.doesNotMatch(conflictLog, /^load -i /m);
|
|
356
|
+
|
|
357
|
+
const ownedPackage = await extractArchive(item);
|
|
358
|
+
await writeFile(item.log, '');
|
|
359
|
+
await run(join(ownedPackage, 'deploy.sh'), [], {
|
|
360
|
+
env: { ...item.environment, EXISTING_CONTAINER_NAME: 'example_project_frontend', CONTAINER_PROJECT: 'example_project', CONTAINER_SERVICE: 'frontend' }
|
|
361
|
+
});
|
|
362
|
+
assert.equal(((await readFile(item.log, 'utf8')).match(/^load -i /gm) ?? []).length, 2);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('服务器在 Docker 操作前拒绝 AppleDouble 和上传压缩包残留', async () => {
|
|
366
|
+
const item = await fixture();
|
|
367
|
+
await buildProject(item);
|
|
368
|
+
const extracted = await extractArchive(item);
|
|
369
|
+
await writeFile(item.log, '');
|
|
370
|
+
await writeFile(join(extracted, '._deploy.sh'), 'AppleDouble\n');
|
|
371
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: \._deploy\.sh/);
|
|
372
|
+
assert.equal(await readFile(item.log, 'utf8'), '');
|
|
373
|
+
await rm(join(extracted, '._deploy.sh'));
|
|
374
|
+
await writeFile(join(extracted, 'example_project-docker.tar.gz'), 'uploaded archive\n');
|
|
375
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: example_project-docker\.tar\.gz/);
|
|
376
|
+
assert.equal(await readFile(item.log, 'utf8'), '');
|
|
185
377
|
});
|
|
186
378
|
|
|
187
379
|
test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Docker', async () => {
|
|
@@ -194,6 +386,7 @@ test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Doc
|
|
|
194
386
|
const beforeInvalid = await readFile(item.log, 'utf8');
|
|
195
387
|
assert.match(beforeInvalid, /stop frontend backend/);
|
|
196
388
|
assert.match(beforeInvalid, /ps frontend backend/);
|
|
389
|
+
assert.doesNotMatch(beforeInvalid, /^\s*(?:start|restart)\b|^compose .* up /m);
|
|
197
390
|
await assert.rejects(run(join(extracted, 'deploy.sh'), ['frontend'], { env: item.environment }), /usage:/);
|
|
198
391
|
assert.equal(await readFile(item.log, 'utf8'), beforeInvalid);
|
|
199
392
|
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=stop result=success/);
|
|
@@ -207,6 +400,19 @@ test('整栈健康等待失败返回非零并记录失败', async () => {
|
|
|
207
400
|
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
208
401
|
});
|
|
209
402
|
|
|
403
|
+
test('运行容器时间探测失败在健康等待后明确失败并记录容器', async () => {
|
|
404
|
+
const item = await fixture();
|
|
405
|
+
await buildProject(item);
|
|
406
|
+
const extracted = await extractArchive(item);
|
|
407
|
+
await writeFile(item.log, '');
|
|
408
|
+
await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, FAIL_CONTAINER_TIME: 'backend' } }), /unable to verify running container CST time: example_project_backend/);
|
|
409
|
+
const log = await readFile(item.log, 'utf8');
|
|
410
|
+
const composeIndex = log.indexOf(' up -d --force-recreate');
|
|
411
|
+
const execIndex = log.indexOf('exec example_project_backend');
|
|
412
|
+
assert(composeIndex >= 0 && execIndex > composeIndex);
|
|
413
|
+
assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
|
|
414
|
+
});
|
|
415
|
+
|
|
210
416
|
test('validator 拒绝数据库容器和不完整远程数据库配置', async () => {
|
|
211
417
|
const item = await fixture();
|
|
212
418
|
await buildProject(item);
|
|
@@ -221,6 +427,72 @@ test('validator 拒绝数据库容器和不完整远程数据库配置', async (
|
|
|
221
427
|
await assert.rejects(run(process.execPath, [validator, envPackage]), /Root \.env is missing DB_URL/);
|
|
222
428
|
});
|
|
223
429
|
|
|
430
|
+
test('缺失或非 Asia/Shanghai TZ 在 Docker 状态变化前被拒绝', async () => {
|
|
431
|
+
const item = await fixture();
|
|
432
|
+
const environmentPath = join(item.root, '.env');
|
|
433
|
+
await writeFile(environmentPath, envText.replace('TZ=Asia/Shanghai', 'TZ=../Shanghai'), { mode: 0o600 });
|
|
434
|
+
await assert.rejects(buildProject(item), /TZ must equal Asia\/Shanghai for CST \(UTC\+8\)/);
|
|
435
|
+
await assert.rejects(stat(item.log), { code: 'ENOENT' });
|
|
436
|
+
|
|
437
|
+
await writeFile(environmentPath, envText, { mode: 0o600 });
|
|
438
|
+
await buildProject(item);
|
|
439
|
+
|
|
440
|
+
const missingPackage = await extractArchive(item);
|
|
441
|
+
await writeFile(join(missingPackage, '.env'), envText.replace('TZ=Asia/Shanghai\n', ''), { mode: 0o600 });
|
|
442
|
+
await rewriteManifest(missingPackage);
|
|
443
|
+
await assert.rejects(run(process.execPath, [validator, missingPackage]), /Root \.env is missing TZ/);
|
|
444
|
+
|
|
445
|
+
const invalidPackage = await extractArchive(item);
|
|
446
|
+
await writeFile(join(invalidPackage, '.env'), envText.replace('TZ=Asia/Shanghai', 'TZ=UTC'), { mode: 0o600 });
|
|
447
|
+
await rewriteManifest(invalidPackage);
|
|
448
|
+
await assert.rejects(run(process.execPath, [validator, invalidPackage]), /Invalid TZ; expected Asia\/Shanghai/);
|
|
449
|
+
|
|
450
|
+
const composePackage = await extractArchive(item);
|
|
451
|
+
const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
|
|
452
|
+
await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' TZ: "${TZ:?TZ is required}"\n', ''));
|
|
453
|
+
await rewriteManifest(composePackage);
|
|
454
|
+
await assert.rejects(run(process.execPath, [validator, composePackage]), /Both services must explicitly use the same required TZ/);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('validator 拒绝容器名、重启策略或端口单一来源被破坏', async () => {
|
|
458
|
+
const item = await fixture();
|
|
459
|
+
await buildProject(item);
|
|
460
|
+
|
|
461
|
+
const namePackage = await extractArchive(item);
|
|
462
|
+
let compose = await readFile(join(namePackage, 'docker-compose.yml'), 'utf8');
|
|
463
|
+
await writeFile(join(namePackage, 'docker-compose.yml'), compose.replace('container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend"', 'container_name: unrelated_frontend'));
|
|
464
|
+
await rewriteManifest(namePackage);
|
|
465
|
+
await assert.rejects(run(process.execPath, [validator, namePackage]), /frontend container name must equal its image repository/);
|
|
466
|
+
|
|
467
|
+
const restartPackage = await extractArchive(item);
|
|
468
|
+
compose = await readFile(join(restartPackage, 'docker-compose.yml'), 'utf8');
|
|
469
|
+
await writeFile(join(restartPackage, 'docker-compose.yml'), compose.replace('restart: unless-stopped', 'restart: always'));
|
|
470
|
+
await rewriteManifest(restartPackage);
|
|
471
|
+
await assert.rejects(run(process.execPath, [validator, restartPackage]), /Both services must use restart: unless-stopped/);
|
|
472
|
+
|
|
473
|
+
const portPackage = await extractArchive(item);
|
|
474
|
+
compose = await readFile(join(portPackage, 'docker-compose.yml'), 'utf8');
|
|
475
|
+
await writeFile(join(portPackage, 'docker-compose.yml'), compose.replace('${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}', '${DOCKER_BIND_IP}:${FRONTEND_PORT}:80'));
|
|
476
|
+
await rewriteManifest(portPackage);
|
|
477
|
+
await assert.rejects(run(process.execPath, [validator, portPackage]), /Frontend port mapping is invalid/);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('validator 拒绝冲突的 API 路径和缺失的代理注入', async () => {
|
|
481
|
+
const item = await fixture();
|
|
482
|
+
await buildProject(item);
|
|
483
|
+
|
|
484
|
+
const envPackage = await extractArchive(item);
|
|
485
|
+
await writeFile(join(envPackage, '.env'), envText.replace('VITE_API_BASE_URL=/api', 'VITE_API_BASE_URL=/service'), { mode: 0o600 });
|
|
486
|
+
await rewriteManifest(envPackage);
|
|
487
|
+
await assert.rejects(run(process.execPath, [validator, envPackage]), /VITE_API_BASE_URL must equal \/API_PREFIX/);
|
|
488
|
+
|
|
489
|
+
const composePackage = await extractArchive(item);
|
|
490
|
+
const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
|
|
491
|
+
await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"\n', ''));
|
|
492
|
+
await rewriteManifest(composePackage);
|
|
493
|
+
await assert.rejects(run(process.execPath, [validator, composePackage]), /Frontend API path is not passed to Nginx/);
|
|
494
|
+
});
|
|
495
|
+
|
|
224
496
|
test('validator 拒绝清单篡改和额外包内容', async () => {
|
|
225
497
|
const item = await fixture();
|
|
226
498
|
await buildProject(item);
|
|
@@ -232,6 +504,15 @@ test('validator 拒绝清单篡改和额外包内容', async () => {
|
|
|
232
504
|
await assert.rejects(run(process.execPath, [validator, extracted]), /Compose must define only frontend and backend|Checksum mismatch/);
|
|
233
505
|
});
|
|
234
506
|
|
|
507
|
+
test('validator 拒绝镜像归档平台与 DOCKER_PLATFORM 不一致', async () => {
|
|
508
|
+
const item = await fixture();
|
|
509
|
+
await buildProject(item);
|
|
510
|
+
const extracted = await extractArchive(item);
|
|
511
|
+
await rewriteImageArchivePlatform(join(extracted, 'backend-image.tar'), 'linux/arm64');
|
|
512
|
+
await rewriteManifest(extracted);
|
|
513
|
+
await assert.rejects(run(process.execPath, [validator, extracted]), /backend-image\.tar platform must equal DOCKER_PLATFORM/);
|
|
514
|
+
});
|
|
515
|
+
|
|
235
516
|
test('build 在任何 Docker 操作前拒绝环境文件命令语法', async () => {
|
|
236
517
|
const item = await fixture();
|
|
237
518
|
await writeFile(join(item.root, '.env'), envText.replace('DB_PASSWORD=secret', 'DB_PASSWORD=$(id)'), { mode: 0o600 });
|
|
@@ -3,7 +3,8 @@ FROM ${BACKEND_BASE_IMAGE} AS build
|
|
|
3
3
|
WORKDIR /app
|
|
4
4
|
COPY . .
|
|
5
5
|
RUN corepack enable && pnpm install --frozen-lockfile
|
|
6
|
-
RUN pnpm --
|
|
6
|
+
RUN pnpm --filter ./src/backend build
|
|
7
|
+
RUN pnpm --filter ./src/backend deploy --prod --legacy /runtime
|
|
7
8
|
|
|
8
9
|
FROM ${BACKEND_BASE_IMAGE} AS runtime
|
|
9
10
|
WORKDIR /app
|
|
@@ -11,8 +12,6 @@ ARG BACKEND_PORT
|
|
|
11
12
|
ENV NODE_ENV=production
|
|
12
13
|
ENV PORT=${BACKEND_PORT}
|
|
13
14
|
LABEL org.opencontainers.image.title="wdyy-backend"
|
|
14
|
-
COPY --from=build /
|
|
15
|
-
COPY --from=build /app/node_modules ./node_modules
|
|
16
|
-
COPY --from=build /app/src/backend/dist ./dist
|
|
15
|
+
COPY --from=build /runtime ./src/backend/
|
|
17
16
|
EXPOSE ${BACKEND_PORT}
|
|
18
|
-
CMD ["node", "dist/main.js"]
|
|
17
|
+
CMD ["node", "src/backend/dist/main.js"]
|
|
@@ -53,15 +53,28 @@ validate_env_permissions() {
|
|
|
53
53
|
[[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
assert_execution_host() {
|
|
57
|
+
local kernel
|
|
58
|
+
kernel="$(uname -s 2>/dev/null)" || fail "execution host OS is unavailable"
|
|
59
|
+
case "$kernel" in
|
|
60
|
+
Darwin|Linux) ;;
|
|
61
|
+
*) fail "unsupported execution host: $kernel; use Linux, macOS, or Windows Docker Desktop through WSL2" ;;
|
|
62
|
+
esac
|
|
63
|
+
if [[ "$kernel" == Linux ]] && { [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; }; then
|
|
64
|
+
[[ ! "$SCRIPT_DIR" =~ ^/mnt/[A-Za-z](/|$) ]] || fail "Windows Docker Desktop execution must use the WSL2 Linux filesystem, not $SCRIPT_DIR"
|
|
65
|
+
fi
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
reset_configuration() {
|
|
57
69
|
unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
|
|
70
|
+
unset API_PREFIX VITE_API_BASE_URL
|
|
58
71
|
unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
|
|
59
|
-
unset PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
|
|
72
|
+
unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE TZ
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
is_configuration_key() {
|
|
63
76
|
case "$1" in
|
|
64
|
-
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
|
|
77
|
+
FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|API_PREFIX|VITE_API_BASE_URL|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|DOCKER_PLATFORM|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|TZ)
|
|
65
78
|
return 0 ;;
|
|
66
79
|
*) return 1 ;;
|
|
67
80
|
esac
|
|
@@ -105,23 +118,74 @@ load_dotenv() {
|
|
|
105
118
|
|
|
106
119
|
validate_configuration() {
|
|
107
120
|
local key value
|
|
108
|
-
for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
|
|
121
|
+
for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT API_PREFIX VITE_API_BASE_URL DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE TZ; do
|
|
109
122
|
value="${!key:-}"
|
|
110
123
|
[[ -n "$value" ]] || fail "required configuration is missing: $key"
|
|
111
124
|
done
|
|
112
125
|
[[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
|
|
113
126
|
[[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
|
|
114
127
|
[[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
|
|
128
|
+
[[ "$API_PREFIX" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]] || fail "API_PREFIX must be one safe path segment"
|
|
129
|
+
[[ "$VITE_API_BASE_URL" == "/$API_PREFIX" ]] || fail "VITE_API_BASE_URL must equal /$API_PREFIX"
|
|
115
130
|
[[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
|
|
131
|
+
[[ "$DOCKER_PLATFORM" =~ ^linux/(amd64|arm64)$ ]] || fail "DOCKER_PLATFORM must be linux/amd64 or linux/arm64"
|
|
116
132
|
[[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
|
|
117
133
|
[[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
|
|
118
134
|
[[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
|
|
135
|
+
[[ "$TZ" == Asia/Shanghai ]] || fail "TZ must equal Asia/Shanghai for CST (UTC+8)"
|
|
119
136
|
assert_port FRONTEND_PORT "$FRONTEND_PORT"
|
|
120
137
|
assert_port BACKEND_PORT "$BACKEND_PORT"
|
|
121
138
|
assert_port DB_PORT "$DB_PORT"
|
|
122
139
|
[[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
|
|
123
140
|
}
|
|
124
141
|
|
|
142
|
+
assert_image_platform() {
|
|
143
|
+
local image="$1" actual
|
|
144
|
+
actual="$(docker image inspect --platform "$DOCKER_PLATFORM" --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
|
|
145
|
+
[[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM, got $actual"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
assert_server_platform() {
|
|
149
|
+
local actual
|
|
150
|
+
actual="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)" || fail "Docker Server platform is unavailable"
|
|
151
|
+
[[ -n "$actual" ]] || fail "Docker Server platform is empty"
|
|
152
|
+
[[ "$actual" != windows/* ]] || fail "Windows container mode is unsupported; switch Docker Desktop to Linux containers"
|
|
153
|
+
[[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
|
|
154
|
+
[[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
assert_container_ownership() {
|
|
158
|
+
local name="$1" expected_service="$2" matches ownership
|
|
159
|
+
matches="$(docker container ls --all --filter "name=^/${name}$" --format '{{.Names}}' 2>/dev/null)" || fail "unable to inspect existing container name: $name"
|
|
160
|
+
[[ -n "$matches" ]] || return 0
|
|
161
|
+
[[ "$matches" == "$name" ]] || fail "container name lookup returned unexpected results for $name: $matches"
|
|
162
|
+
ownership="$(docker container inspect --format '{{ index .Config.Labels "com.docker.compose.project" }}|{{ index .Config.Labels "com.docker.compose.service" }}' "$name" 2>/dev/null)" || fail "unable to inspect container ownership: $name"
|
|
163
|
+
[[ "$ownership" == "$PROJECT_NAME|$expected_service" ]] || fail "container name conflict: $name belongs to ${ownership:-unknown}; expected $PROJECT_NAME|$expected_service"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
assert_stack_container_ownership() {
|
|
167
|
+
assert_container_ownership "${PROJECT_NAME}_frontend" frontend
|
|
168
|
+
assert_container_ownership "${PROJECT_NAME}_backend" backend
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
assert_cst_time_value() {
|
|
172
|
+
local label="$1" actual="$2"
|
|
173
|
+
actual="${actual%$'\r'}"
|
|
174
|
+
[[ "$actual" =~ ^([01][0-9]|2[0-3])[[:space:]]CST[[:space:]]\+0800$ ]] || fail "$label time must use 24-hour CST +0800; got ${actual:-empty}"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
assert_image_cst_time() {
|
|
178
|
+
local image="$1" actual
|
|
179
|
+
actual="$(docker run --rm --pull=never --platform "$DOCKER_PLATFORM" --entrypoint sh --env TZ=Asia/Shanghai "$image" -c 'LC_ALL=C date "+%H %Z %z"' 2>/dev/null)" || fail "unable to verify image CST time: $image"
|
|
180
|
+
assert_cst_time_value "image $image" "$actual"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
assert_container_cst_time() {
|
|
184
|
+
local name="$1" actual
|
|
185
|
+
actual="$(docker exec "$name" sh -c 'LC_ALL=C date "+%H %Z %z"' 2>/dev/null)" || fail "unable to verify running container CST time: $name"
|
|
186
|
+
assert_cst_time_value "container $name" "$actual"
|
|
187
|
+
}
|
|
188
|
+
|
|
125
189
|
validate_dockerignore() {
|
|
126
190
|
local file="$1" pattern line
|
|
127
191
|
[[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
|
|
@@ -206,8 +270,9 @@ ensure_deploy_output_scope() {
|
|
|
206
270
|
build_release() {
|
|
207
271
|
local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
|
|
208
272
|
local frontend_image backend_image package_root deploy_dir archive_name candidate
|
|
209
|
-
for command in docker tar install find sort grep sed awk mktemp; do require_command "$command"; done
|
|
273
|
+
for command in docker tar install find sort grep sed awk mktemp uname; do require_command "$command"; done
|
|
210
274
|
select_checksum_tool
|
|
275
|
+
assert_execution_host
|
|
211
276
|
load_dotenv "$project_root/.env"
|
|
212
277
|
validate_configuration
|
|
213
278
|
frontend_dockerfile="$project_root/src/frontend/Dockerfile"
|
|
@@ -220,14 +285,20 @@ build_release() {
|
|
|
220
285
|
validate_dockerignore "$dockerignore_file"
|
|
221
286
|
docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
|
|
222
287
|
docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
|
|
288
|
+
assert_image_platform "$FRONTEND_BASE_IMAGE"
|
|
289
|
+
assert_image_platform "$BACKEND_BASE_IMAGE"
|
|
223
290
|
frontend_image="${PROJECT_NAME}_frontend:latest"
|
|
224
291
|
backend_image="${PROJECT_NAME}_backend:latest"
|
|
225
292
|
acquire_build_lock
|
|
226
293
|
trap cleanup_build EXIT
|
|
227
|
-
docker build --pull=false --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
|
|
228
|
-
docker build --pull=false --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
|
|
294
|
+
docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "VITE_API_BASE_URL=$VITE_API_BASE_URL" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
|
|
295
|
+
docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
|
|
229
296
|
docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
|
|
230
297
|
docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
|
|
298
|
+
assert_image_platform "$frontend_image"
|
|
299
|
+
assert_image_platform "$backend_image"
|
|
300
|
+
assert_image_cst_time "$frontend_image"
|
|
301
|
+
assert_image_cst_time "$backend_image"
|
|
231
302
|
STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
|
|
232
303
|
package_root="$STAGING_ROOT/package"
|
|
233
304
|
mkdir -p "$package_root"
|
|
@@ -245,7 +316,7 @@ build_release() {
|
|
|
245
316
|
archive_name="${PROJECT_NAME}-docker.tar.gz"
|
|
246
317
|
ensure_deploy_output_scope "$deploy_dir" "$archive_name"
|
|
247
318
|
candidate="$STAGING_ROOT/$archive_name"
|
|
248
|
-
tar -czf "$candidate" -C "$package_root" .
|
|
319
|
+
env COPYFILE_DISABLE=1 tar --no-xattrs -czf "$candidate" -C "$package_root" .
|
|
249
320
|
tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
|
|
250
321
|
mv -f "$candidate" "$deploy_dir/$archive_name"
|
|
251
322
|
rm -rf -- "$STAGING_ROOT"
|
|
@@ -258,8 +329,9 @@ build_release() {
|
|
|
258
329
|
|
|
259
330
|
initialize_server_context() {
|
|
260
331
|
DEPLOY_ROOT="$SCRIPT_DIR"
|
|
261
|
-
for command in docker tar find sort grep sed awk; do require_command "$command"; done
|
|
332
|
+
for command in docker tar find sort grep sed awk uname; do require_command "$command"; done
|
|
262
333
|
select_checksum_tool
|
|
334
|
+
assert_execution_host
|
|
263
335
|
load_dotenv "$DEPLOY_ROOT/.env"
|
|
264
336
|
validate_configuration
|
|
265
337
|
[[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
|
|
@@ -273,7 +345,7 @@ compose() {
|
|
|
273
345
|
record_operation() {
|
|
274
346
|
local result="$1" timestamp
|
|
275
347
|
[[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
|
|
276
|
-
timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
|
|
348
|
+
timestamp="$(TZ=Asia/Shanghai LC_ALL=C date '+%Y-%m-%d %H:%M:%S %Z %z')"
|
|
277
349
|
printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
|
|
278
350
|
}
|
|
279
351
|
|
|
@@ -283,11 +355,17 @@ deploy_stack() {
|
|
|
283
355
|
verify_manifest "$DEPLOY_ROOT"
|
|
284
356
|
archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
|
|
285
357
|
archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
|
|
358
|
+
assert_server_platform
|
|
359
|
+
assert_stack_container_ownership
|
|
286
360
|
docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
|
|
287
361
|
docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
|
|
362
|
+
assert_image_platform "${PROJECT_NAME}_frontend:latest"
|
|
363
|
+
assert_image_platform "${PROJECT_NAME}_backend:latest"
|
|
288
364
|
docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
|
|
289
365
|
docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
|
|
290
366
|
compose up -d --force-recreate --no-build --pull never --wait frontend backend
|
|
367
|
+
assert_container_cst_time "${PROJECT_NAME}_frontend"
|
|
368
|
+
assert_container_cst_time "${PROJECT_NAME}_backend"
|
|
291
369
|
}
|
|
292
370
|
|
|
293
371
|
run_logged_operation() {
|
|
@@ -307,6 +385,8 @@ server_deploy() {
|
|
|
307
385
|
|
|
308
386
|
server_stop() {
|
|
309
387
|
initialize_server_context
|
|
388
|
+
assert_server_platform
|
|
389
|
+
assert_stack_container_ownership
|
|
310
390
|
run_logged_operation stop compose stop frontend backend
|
|
311
391
|
}
|
|
312
392
|
|