@wdyy/skills 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +30 -55
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +103 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +140 -153
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -85
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +102 -174
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +203 -455
- package/.well-known/skills/wdyy-deployment-standard/templates/{Dockerfile.template → backend.Dockerfile.template} +5 -9
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +207 -647
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +33 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -24
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -8
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
- package/README.md +4 -2
- package/lib/wdyy-cli.js +4 -2
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -127
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -25
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -1,108 +1,120 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
1
2
|
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
7
9
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const script = join(dirname(fileURLToPath(import.meta.url)), 'generate-deployment-files.mjs');
|
|
12
|
+
const created = [];
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
const realEnv = `# 前端
|
|
15
|
+
FRONTEND_URL=0.0.0.0
|
|
16
|
+
FRONTEND_PORT=5173
|
|
17
|
+
# 后端
|
|
18
|
+
BACKEND_URL=0.0.0.0
|
|
19
|
+
BACKEND_PORT=3000
|
|
20
|
+
# 数据库
|
|
21
|
+
DB_URL=db.internal
|
|
22
|
+
DB_PORT=5432
|
|
23
|
+
DB_USER=app
|
|
24
|
+
DB_PASSWORD=secret
|
|
25
|
+
DB_NAME=app
|
|
26
|
+
DB_SCHEMA=public
|
|
27
|
+
# API
|
|
28
|
+
API_URL=
|
|
29
|
+
# 部署
|
|
30
|
+
`;
|
|
31
|
+
const exampleEnv = `# 前端
|
|
32
|
+
FRONTEND_URL=0.0.0.0
|
|
33
|
+
FRONTEND_PORT=5173
|
|
34
|
+
# 后端
|
|
35
|
+
BACKEND_URL=0.0.0.0
|
|
36
|
+
BACKEND_PORT=3000
|
|
37
|
+
# 数据库
|
|
38
|
+
DB_URL=
|
|
39
|
+
DB_PORT=5432
|
|
40
|
+
DB_USER=
|
|
41
|
+
DB_PASSWORD=
|
|
42
|
+
DB_NAME=
|
|
43
|
+
DB_SCHEMA=
|
|
44
|
+
# API
|
|
45
|
+
API_URL=
|
|
46
|
+
# 部署
|
|
47
|
+
`;
|
|
15
48
|
|
|
16
|
-
async function
|
|
17
|
-
const root = await mkdtemp(join(tmpdir(), '
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
await writeFile(join(root, '.env'), initializedEnvironment, { mode: 0o600 });
|
|
22
|
-
await writeFile(join(root, '.env.example'), initializedEnvironment);
|
|
49
|
+
async function fixture(env = realEnv, example = exampleEnv) {
|
|
50
|
+
const root = await mkdtemp(join(tmpdir(), 'wdyy-deploy-generator-'));
|
|
51
|
+
created.push(root);
|
|
52
|
+
await writeFile(join(root, '.env'), env, { mode: 0o600 });
|
|
53
|
+
await writeFile(join(root, '.env.example'), example, { mode: 0o644 });
|
|
23
54
|
return root;
|
|
24
55
|
}
|
|
25
56
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
57
|
+
async function invoke(root, ...args) {
|
|
58
|
+
return run(process.execPath, [script, '--target', root, ...args]);
|
|
28
59
|
}
|
|
29
60
|
|
|
30
|
-
test(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
assert.match(
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
assert.
|
|
42
|
-
assert.
|
|
43
|
-
assert.match(
|
|
44
|
-
assert.
|
|
45
|
-
|
|
61
|
+
test.after(async () => {
|
|
62
|
+
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('生成统一 Docker 交付文件并通过确定性检查', async () => {
|
|
66
|
+
const root = await fixture();
|
|
67
|
+
const result = await invoke(root, '--write');
|
|
68
|
+
assert.match(result.stdout, /generated unified Docker delivery files/);
|
|
69
|
+
for (const path of ['deploy.sh', '.dockerignore', 'src/frontend/Dockerfile', 'src/backend/Dockerfile', 'scripts/deployment/docker-compose.yml', 'scripts/deployment/frontend-container.conf.template']) {
|
|
70
|
+
assert.ok((await stat(join(root, path))).isFile(), path);
|
|
71
|
+
}
|
|
72
|
+
assert.equal((await stat(join(root, 'deploy.sh'))).mode & 0o777, 0o755);
|
|
73
|
+
assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
|
|
74
|
+
assert.match(await readFile(join(root, '.env'), 'utf8'), /FRONTEND_BASE_IMAGE=nginx:stable/);
|
|
75
|
+
assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-alpine3\.24/);
|
|
76
|
+
const check = await invoke(root, '--check');
|
|
77
|
+
assert.match(check.stdout, /verified unified Docker delivery files/);
|
|
46
78
|
});
|
|
47
79
|
|
|
48
|
-
test('
|
|
49
|
-
const root = await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
assert.
|
|
54
|
-
assert.match(
|
|
80
|
+
test('扩充部署段时完整保留真实环境值', async () => {
|
|
81
|
+
const root = await fixture();
|
|
82
|
+
await invoke(root, '--write');
|
|
83
|
+
const actual = await readFile(join(root, '.env'), 'utf8');
|
|
84
|
+
assert.match(actual, /DB_URL=db\.internal/);
|
|
85
|
+
assert.match(actual, /DB_PASSWORD=secret/);
|
|
86
|
+
assert.match(actual, /PROJECT_NAME=\nDOCKER_BIND_IP=\n/);
|
|
55
87
|
});
|
|
56
88
|
|
|
57
89
|
test('非标准环境文件被拒绝且不修改', async () => {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
assert.
|
|
64
|
-
assert.match(generated.stderr, /section order/);
|
|
65
|
-
assert.equal(await readFile(envPath, 'utf8'), before);
|
|
90
|
+
const invalid = realEnv.replace('# 数据库\n', '');
|
|
91
|
+
const root = await fixture(invalid);
|
|
92
|
+
const before = await readFile(join(root, '.env'));
|
|
93
|
+
await assert.rejects(invoke(root, '--write'), /section order|inside a standard section/);
|
|
94
|
+
assert.deepEqual(await readFile(join(root, '.env')), before);
|
|
95
|
+
await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
|
|
66
96
|
});
|
|
67
97
|
|
|
68
|
-
test('
|
|
69
|
-
const root = await
|
|
70
|
-
|
|
71
|
-
await
|
|
72
|
-
const envBefore = await readFile(join(root, '.env'), 'utf8');
|
|
73
|
-
const exampleBefore = await readFile(examplePath, 'utf8');
|
|
74
|
-
const generated = run(root, '--write');
|
|
75
|
-
assert.notEqual(generated.status, 0);
|
|
76
|
-
assert.match(generated.stderr, /variable names must remain consistent/);
|
|
77
|
-
assert.equal(await readFile(join(root, '.env'), 'utf8'), envBefore);
|
|
78
|
-
assert.equal(await readFile(examplePath, 'utf8'), exampleBefore);
|
|
98
|
+
test('环境变量集合不一致时拒绝修改', async () => {
|
|
99
|
+
const root = await fixture(realEnv, exampleEnv.replace('API_URL=', 'API_URL=\nAPI_TOKEN='));
|
|
100
|
+
await assert.rejects(invoke(root, '--write'), /variable names must remain consistent/);
|
|
101
|
+
await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
|
|
79
102
|
});
|
|
80
103
|
|
|
81
|
-
test('
|
|
82
|
-
const root = await
|
|
83
|
-
|
|
84
|
-
await writeFile(join(root, 'deploy.sh'), '
|
|
104
|
+
test('差异生成文件默认拒绝覆盖,force 后恢复一致', async () => {
|
|
105
|
+
const root = await fixture();
|
|
106
|
+
await invoke(root, '--write');
|
|
107
|
+
await writeFile(join(root, 'deploy.sh'), '#!/bin/sh\nexit 9\n');
|
|
85
108
|
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
86
|
-
|
|
87
|
-
assert.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const forced = run(root, '--write', '--force');
|
|
91
|
-
assert.equal(forced.status, 0, forced.stderr);
|
|
92
|
-
assert.equal(run(root, '--check').status, 0);
|
|
109
|
+
await assert.rejects(invoke(root, '--write'), /Refusing to overwrite differing deployment files/);
|
|
110
|
+
assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '#!/bin/sh\nexit 9\n');
|
|
111
|
+
await invoke(root, '--write', '--force');
|
|
112
|
+
await invoke(root, '--check');
|
|
93
113
|
});
|
|
94
114
|
|
|
95
|
-
test('
|
|
96
|
-
const root = await
|
|
97
|
-
|
|
98
|
-
assert.
|
|
99
|
-
assert.
|
|
100
|
-
|
|
101
|
-
const duplicateTarget = spawnSync(
|
|
102
|
-
process.execPath,
|
|
103
|
-
[script, '--target', root, '--target', root, '--check'],
|
|
104
|
-
{ encoding: 'utf8' },
|
|
105
|
-
);
|
|
106
|
-
assert.notEqual(duplicateTarget.status, 0);
|
|
107
|
-
assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
|
|
115
|
+
test('非法参数和旧部署配置被明确拒绝', async () => {
|
|
116
|
+
const root = await fixture(realEnv.replace('# 部署\n', '# 部署\nRELEASE_VERSION=old\n'));
|
|
117
|
+
await assert.rejects(invoke(root, '--write'), /removed deployment configuration: RELEASE_VERSION/);
|
|
118
|
+
await assert.rejects(run(process.execPath, [script, '--target', root, '--write', '--check']), /Exactly one/);
|
|
119
|
+
await assert.rejects(run(process.execPath, [script, '--target', root, '--check', '--force']), /Usage/);
|
|
108
120
|
});
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
3
4
|
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
|
4
|
-
import { basename, join,
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const [deploy, nginx, compose] = await Promise.all([
|
|
12
|
-
readFile(deployPath, 'utf8'),
|
|
13
|
-
readFile(nginxPath, 'utf8'),
|
|
14
|
-
readFile(composePath, 'utf8'),
|
|
15
|
-
]);
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
const [packageArgument] = process.argv.slice(2);
|
|
10
|
+
if (!packageArgument || process.argv.length !== 3) throw new Error('Usage: validate-deployment-package.mjs <extracted-package-root>');
|
|
11
|
+
const packageRoot = resolve(packageArgument);
|
|
12
|
+
const expectedFiles = ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'];
|
|
16
13
|
|
|
17
|
-
function
|
|
18
|
-
if (!
|
|
14
|
+
function assert(condition, message) {
|
|
15
|
+
if (!condition) throw new Error(message);
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
function parseDotenv(text) {
|
|
@@ -26,176 +23,107 @@ function parseDotenv(text) {
|
|
|
26
23
|
if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
|
|
27
24
|
if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
|
|
28
25
|
let value = match[2];
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
throw new Error(`Unclosed root .env quote: ${match[1]}`);
|
|
33
|
-
}
|
|
26
|
+
if (value.includes('$(') || value.includes('`')) throw new Error(`Executable syntax is forbidden in root .env: ${match[1]}`);
|
|
27
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
28
|
+
else if (value.startsWith('"') || value.startsWith("'")) throw new Error(`Unclosed root .env quote: ${match[1]}`);
|
|
34
29
|
values.set(match[1], value);
|
|
35
30
|
}
|
|
36
31
|
return values;
|
|
37
32
|
}
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
for (const command of commands.filter((item) => item !== 'rollback')) {
|
|
44
|
-
requirePattern(
|
|
45
|
-
deploy,
|
|
46
|
-
new RegExp(`^ ${command}\\) \\[\\[ "\\$#" -eq 1 \\]\\] \\|\\| usage;`, 'm'),
|
|
47
|
-
`${command} must reject extra arguments`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
requirePattern(deploy, /^ rollback\) \[\[ "\$#" -eq 2 \]\] \|\| usage;/m, 'rollback must require exactly one version');
|
|
51
|
-
|
|
52
|
-
const requiredDeploySemantics = [
|
|
53
|
-
/release_ledger next/,
|
|
54
|
-
/pnpm test/,
|
|
55
|
-
/pnpm lint/,
|
|
56
|
-
/pnpm typecheck/,
|
|
57
|
-
/pnpm build/,
|
|
58
|
-
/frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/,
|
|
59
|
-
/backend_image="\$\{PROJECT_NAME\}_backend:\$version"/,
|
|
60
|
-
/docker save -o "\$version_root\/frontend-image\.tar"/,
|
|
61
|
-
/docker save -o "\$version_root\/backend-image\.tar"/,
|
|
62
|
-
/load_dotenv "\$DEPLOY_ROOT\/\.env"/,
|
|
63
|
-
/endpoint_url "\$DOCKER_BIND_IP"/,
|
|
64
|
-
/flock -x 9/,
|
|
65
|
-
/Nginx reload failed; old project configuration restored/,
|
|
66
|
-
/docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
|
|
67
|
-
/\$1 == frontend \|\| \$1 == backend/,
|
|
68
|
-
];
|
|
69
|
-
for (const pattern of requiredDeploySemantics) requirePattern(deploy, pattern, `Missing deployment behavior: ${pattern}`);
|
|
70
|
-
if (/\bsource\s+["']?[^\n]*(?:\.env|ACTIVE_STATE|PREVIOUS_STATE)/.test(deploy)) {
|
|
71
|
-
throw new Error('Environment and state files must be parsed as data, not sourced');
|
|
34
|
+
async function requireRegular(path, label) {
|
|
35
|
+
const info = await lstat(path);
|
|
36
|
+
assert(info.isFile() && !info.isSymbolicLink(), `${label} must be a regular file`);
|
|
37
|
+
return info;
|
|
72
38
|
}
|
|
73
|
-
if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
|
|
74
|
-
if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
|
|
75
|
-
if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
|
|
76
|
-
if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${nginx}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
|
|
77
39
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)/.test(compose)) throw new Error('Configurable or nested log directories are forbidden');
|
|
87
|
-
if (/container_name:/.test(compose)) throw new Error('container_name would weaken Compose project isolation');
|
|
88
|
-
if (/FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|_HEALTH_URL|_VERSION_URL/.test(compose)) {
|
|
89
|
-
throw new Error('Compose contains legacy duplicate URL or port configuration');
|
|
90
|
-
}
|
|
91
|
-
for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL']) {
|
|
92
|
-
if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
|
|
96
|
-
if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
|
|
97
|
-
}
|
|
98
|
-
if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx)) {
|
|
99
|
-
throw new Error('Nginx template must expose deterministic project and port markers');
|
|
100
|
-
}
|
|
101
|
-
if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
|
|
102
|
-
|
|
103
|
-
const rootEnvPath = join(packageRoot, '.env');
|
|
104
|
-
const rootEnvStat = await stat(rootEnvPath);
|
|
105
|
-
if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) {
|
|
106
|
-
throw new Error('Root .env must be a private owner-readable file');
|
|
107
|
-
}
|
|
108
|
-
const rootEnvText = await readFile(rootEnvPath, 'utf8');
|
|
109
|
-
const env = parseDotenv(rootEnvText);
|
|
110
|
-
const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
111
|
-
if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
|
|
112
|
-
const requiredEnv = [
|
|
113
|
-
'FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT',
|
|
114
|
-
'PROJECT_NAME', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
|
|
115
|
-
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
116
|
-
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
|
|
117
|
-
];
|
|
118
|
-
for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
|
|
119
|
-
for (const key of env.keys()) {
|
|
120
|
-
if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
|
|
121
|
-
throw new Error(`Root .env contains legacy duplicate configuration: ${key}`);
|
|
40
|
+
const rootInfo = await stat(packageRoot);
|
|
41
|
+
assert(rootInfo.isDirectory(), 'Package root must be a directory');
|
|
42
|
+
const entries = await readdir(packageRoot, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.name === 'logs') {
|
|
45
|
+
assert(entry.isDirectory() && !entry.isSymbolicLink(), 'logs must be a regular directory');
|
|
46
|
+
} else {
|
|
47
|
+
assert(expectedFiles.includes(entry.name) && entry.isFile() && !entry.isSymbolicLink(), `Unexpected package entry: ${entry.name}`);
|
|
122
48
|
}
|
|
123
49
|
}
|
|
124
|
-
|
|
125
|
-
for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
|
|
126
|
-
if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
|
|
127
|
-
}
|
|
128
|
-
if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
|
|
129
|
-
const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
|
|
130
|
-
for (const key of allPortKeys) {
|
|
131
|
-
const port = Number(env.get(key));
|
|
132
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
|
|
133
|
-
}
|
|
134
|
-
const hostPortKeys = ['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
|
|
135
|
-
const hostPorts = hostPortKeys.map((key) => Number(env.get(key)));
|
|
136
|
-
if (hostPorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(hostPorts).size !== hostPorts.length) {
|
|
137
|
-
throw new Error('Project and host blue-green ports must be valid and unique');
|
|
138
|
-
}
|
|
50
|
+
for (const name of expectedFiles) await requireRegular(join(packageRoot, name), name);
|
|
139
51
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.blue-green.yml')) throw new Error('Compose path must be inside the selected version');
|
|
149
|
-
|
|
150
|
-
const versionRoot = join(packageRoot, version);
|
|
151
|
-
const requiredPaths = ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.blue-green.yml', 'nginx/site.conf'];
|
|
152
|
-
for (const path of requiredPaths) {
|
|
153
|
-
const info = await stat(join(versionRoot, path));
|
|
154
|
-
if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
|
|
155
|
-
}
|
|
156
|
-
await assertMissing(join(versionRoot, 'database'), 'Release package must not contain database migration files');
|
|
157
|
-
|
|
158
|
-
const versionFiles = (await listFiles(versionRoot)).sort();
|
|
159
|
-
for (const path of versionFiles) {
|
|
160
|
-
const name = basename(path);
|
|
161
|
-
if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) {
|
|
162
|
-
throw new Error(`Secret file must not enter version directory: ${path}`);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
|
|
166
|
-
const entries = new Map();
|
|
167
|
-
for (const line of manifest.trimEnd().split('\n')) {
|
|
168
|
-
const match = line.match(/^([0-9a-f]{64}) ([^/].*)$/);
|
|
169
|
-
if (!match || match[2].split('/').includes('..')) throw new Error(`Invalid manifest entry: ${line}`);
|
|
170
|
-
if (entries.has(match[2])) throw new Error(`Duplicate manifest entry: ${match[2]}`);
|
|
171
|
-
entries.set(match[2], match[1]);
|
|
172
|
-
}
|
|
173
|
-
assertDeepEqual([...entries.keys()].sort(), versionFiles.filter((path) => path !== 'manifest.sha256'), 'Manifest coverage is incomplete');
|
|
174
|
-
for (const [path, expected] of entries) {
|
|
175
|
-
const actual = createHash('sha256').update(await readFile(join(versionRoot, path))).digest('hex');
|
|
176
|
-
if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
process.stdout.write(`valid dual-image deployment package ${version}\n`);
|
|
180
|
-
|
|
181
|
-
function assertDeepEqual(actual, expected, message) {
|
|
182
|
-
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
183
|
-
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function assertMissing(path, message) {
|
|
188
|
-
try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
189
|
-
throw new Error(message);
|
|
52
|
+
const envInfo = await requireRegular(join(packageRoot, '.env'), '.env');
|
|
53
|
+
assert((envInfo.mode & 0o077) === 0 && (envInfo.mode & 0o400) !== 0, 'Root .env must be owner-readable with no group or other permissions');
|
|
54
|
+
const envText = await readFile(join(packageRoot, '.env'), 'utf8');
|
|
55
|
+
const env = parseDotenv(envText);
|
|
56
|
+
const sections = [...envText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
57
|
+
assert(sections.join('|') === '前端|后端|数据库|API|部署', 'Root .env must use the five standard sections in order');
|
|
58
|
+
for (const key of ['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']) {
|
|
59
|
+
assert(env.get(key), `Root .env is missing ${key}`);
|
|
190
60
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
const path = join(directory, entry.name);
|
|
196
|
-
if (entry.isSymbolicLink()) throw new Error(`Release package must not contain symbolic links: ${relative(root, path)}`);
|
|
197
|
-
if (entry.isDirectory()) result.push(...await listFiles(root, path));
|
|
198
|
-
else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
|
|
199
|
-
}
|
|
200
|
-
return result;
|
|
61
|
+
assert(/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME')), 'Invalid PROJECT_NAME');
|
|
62
|
+
for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT']) {
|
|
63
|
+
const port = Number(env.get(key));
|
|
64
|
+
assert(Number.isInteger(port) && port >= 1 && port <= 65535, `${key} must be a valid port`);
|
|
201
65
|
}
|
|
66
|
+
assert(env.get('FRONTEND_PORT') !== env.get('BACKEND_PORT'), 'Frontend and backend ports must differ');
|
|
67
|
+
for (const key of env.keys()) {
|
|
68
|
+
assert(!['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE', 'RELEASE_VERSION'].includes(key) && !/_(?:HEALTH|VERSION)_URL$/.test(key), `Root .env contains removed configuration: ${key}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deploy = await readFile(join(packageRoot, 'deploy.sh'), 'utf8');
|
|
72
|
+
const compose = await readFile(join(packageRoot, 'docker-compose.yml'), 'utf8');
|
|
73
|
+
for (const [pattern, message] of [
|
|
74
|
+
[/if \[\[ "\$#" -eq 0 \]\]; then server_deploy/, 'No-argument execution must deploy the complete stack'],
|
|
75
|
+
[/"\$1" == build/, 'Missing local build command'],
|
|
76
|
+
[/"\$1" == stop/, 'Missing stop command'],
|
|
77
|
+
[/"\$1" == status/, 'Missing status command'],
|
|
78
|
+
[/docker image inspect "\$FRONTEND_BASE_IMAGE"/, 'Frontend local base image must be checked'],
|
|
79
|
+
[/docker image inspect "\$BACKEND_BASE_IMAGE"/, 'Backend local base image must be checked'],
|
|
80
|
+
[/docker build --pull=false/g, 'Docker builds must disable pull'],
|
|
81
|
+
[/docker save -o "\$package_root\/frontend-image\.tar"/, 'Frontend image must be exported separately'],
|
|
82
|
+
[/docker save -o "\$package_root\/backend-image\.tar"/, 'Backend image must be exported separately'],
|
|
83
|
+
[/compose up -d --force-recreate --no-build --pull never --wait frontend backend/, 'Deploy must recreate and wait for both services without build or pull'],
|
|
84
|
+
[/compose stop frontend backend/, 'Stop must target both services'],
|
|
85
|
+
[/compose ps frontend backend/, 'Status must inspect both services'],
|
|
86
|
+
]) assert(pattern.test(deploy), message);
|
|
87
|
+
assert((deploy.match(/docker build --pull=false/g) ?? []).length === 2, 'Exactly two Docker builds must disable pull');
|
|
88
|
+
assert(!/\bdocker\s+pull\b/.test(deploy), 'docker pull is forbidden');
|
|
89
|
+
assert(!/\bsource\s+[^\n]*\.env|\beval\b/.test(deploy), 'Environment files must be parsed as data');
|
|
90
|
+
assert(!/server_menu|运行或替换|read_release_pointer|release_ledger|AGENTS\.md/.test(deploy), 'Legacy menu or release ledger logic is forbidden');
|
|
91
|
+
assert(!/\bpsql\b|\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy), 'Database, upload or source-pull commands are forbidden');
|
|
92
|
+
assert(!/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`), 'Fixed server roots are forbidden');
|
|
93
|
+
|
|
94
|
+
const serviceNames = [...compose.matchAll(/^ ([a-z][a-z0-9_-]*):$/gm)].map((match) => match[1]);
|
|
95
|
+
assert(JSON.stringify(serviceNames) === JSON.stringify(['frontend', 'backend']), `Compose must define only frontend and backend; got ${serviceNames.join(', ')}`);
|
|
96
|
+
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"'), 'Compose frontend image is not fixed');
|
|
97
|
+
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"'), 'Compose backend image is not fixed');
|
|
98
|
+
assert((compose.match(/env_file: \.\/\.env/g) ?? []).length === 2, 'Both services must use root .env');
|
|
99
|
+
assert((compose.match(/^\s*- \.\/logs:\/app\/logs\s*$/gm) ?? []).length === 2, 'Both services must mount root logs');
|
|
100
|
+
assert(compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"'), 'Frontend port mapping is invalid');
|
|
101
|
+
assert(compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"'), 'Backend port mapping is invalid');
|
|
102
|
+
assert(!/container_name:|\b(?:postgres|database|db):\s*$|postgres:\d|DATABASE_MIGRATION|MIGRATIONS_SOURCE|RELEASE_VERSION|blue|green/i.test(compose), 'Compose contains a database, migration, release or blue-green configuration');
|
|
103
|
+
|
|
104
|
+
const manifestText = await readFile(join(packageRoot, 'manifest.sha256'), 'utf8');
|
|
105
|
+
const manifestEntries = new Map();
|
|
106
|
+
for (const line of manifestText.trimEnd().split('\n')) {
|
|
107
|
+
const match = line.match(/^([0-9a-f]{64}) ([^/]+)$/);
|
|
108
|
+
assert(match, `Invalid manifest entry: ${line}`);
|
|
109
|
+
assert(!manifestEntries.has(match[2]), `Duplicate manifest entry: ${match[2]}`);
|
|
110
|
+
manifestEntries.set(match[2], match[1]);
|
|
111
|
+
}
|
|
112
|
+
const expectedManifestFiles = expectedFiles.filter((name) => name !== 'manifest.sha256').sort();
|
|
113
|
+
assert(JSON.stringify([...manifestEntries.keys()].sort()) === JSON.stringify(expectedManifestFiles), 'Manifest coverage is incomplete');
|
|
114
|
+
for (const [name, expectedHash] of manifestEntries) {
|
|
115
|
+
const actualHash = createHash('sha256').update(await readFile(join(packageRoot, name))).digest('hex');
|
|
116
|
+
assert(actualHash === expectedHash, `Checksum mismatch: ${name}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function assertImageArchive(name, expectedTag) {
|
|
120
|
+
const archive = join(packageRoot, name);
|
|
121
|
+
const { stdout } = await run('tar', ['-xOf', archive, 'manifest.json'], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 });
|
|
122
|
+
const parsed = JSON.parse(stdout);
|
|
123
|
+
assert(Array.isArray(parsed) && parsed.length === 1, `${name} must contain exactly one image manifest`);
|
|
124
|
+
assert(JSON.stringify(parsed[0].RepoTags) === JSON.stringify([expectedTag]), `${name} must contain only ${expectedTag}`);
|
|
125
|
+
}
|
|
126
|
+
await assertImageArchive('frontend-image.tar', `${env.get('PROJECT_NAME')}_frontend:latest`);
|
|
127
|
+
await assertImageArchive('backend-image.tar', `${env.get('PROJECT_NAME')}_backend:latest`);
|
|
128
|
+
|
|
129
|
+
process.stdout.write(`valid unified Docker delivery package for ${env.get('PROJECT_NAME')}\n`);
|