@wdyy/skills 0.1.6 → 0.1.7
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 +53 -61
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +92 -88
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +213 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +106 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +168 -147
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +485 -526
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +10 -8
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +598 -416
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +46 -12
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +2 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +30 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +24 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +7 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +17 -18
- package/README.md +2 -2
- package/lib/wdyy-cli.js +35 -9
- package/package.json +1 -1
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { afterEach, test } from 'node:test';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const script = new URL('./generate-deployment-files.mjs', import.meta.url).pathname;
|
|
9
|
+
const environmentTemplate = new URL('../templates/env.example.template', import.meta.url).pathname;
|
|
10
|
+
const temporaryDirectories = [];
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function projectRoot() {
|
|
17
|
+
const root = await mkdtemp(join(tmpdir(), 'deployment-generator-'));
|
|
18
|
+
temporaryDirectories.push(root);
|
|
19
|
+
const template = await readFile(environmentTemplate, 'utf8');
|
|
20
|
+
const initializedEnvironment = `${template.slice(0, template.indexOf('# 部署') + '# 部署'.length)}\n`;
|
|
21
|
+
await writeFile(join(root, '.env'), initializedEnvironment, { mode: 0o600 });
|
|
22
|
+
await writeFile(join(root, '.env.example'), initializedEnvironment);
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function run(root, ...args) {
|
|
27
|
+
return spawnSync(process.execPath, [script, '--target', root, ...args], { encoding: 'utf8' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('确定性生成全部标准文件并通过 check', async () => {
|
|
31
|
+
const root = await projectRoot();
|
|
32
|
+
const generated = run(root, '--write');
|
|
33
|
+
assert.equal(generated.status, 0, generated.stderr);
|
|
34
|
+
const checked = run(root, '--check');
|
|
35
|
+
assert.equal(checked.status, 0, checked.stderr);
|
|
36
|
+
assert.match(checked.stdout, /verified deterministic deployment files/);
|
|
37
|
+
assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /build\|start\|replace/);
|
|
38
|
+
const environment = await readFile(join(root, '.env'), 'utf8');
|
|
39
|
+
const example = await readFile(join(root, '.env.example'), 'utf8');
|
|
40
|
+
assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
|
|
41
|
+
assert.match(environment, /^# 部署\nPROJECT_NAME=\nSERVER_PROJECTS_ROOT=/m);
|
|
42
|
+
assert.equal(example, environment);
|
|
43
|
+
if (process.platform !== 'win32') assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('扩充部署段时保留 .env 的真实基础值', async () => {
|
|
47
|
+
const root = await projectRoot();
|
|
48
|
+
const envPath = join(root, '.env');
|
|
49
|
+
await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('DB_PASSWORD=', 'DB_PASSWORD=private-value'), { mode: 0o600 });
|
|
50
|
+
const generated = run(root, '--write');
|
|
51
|
+
assert.equal(generated.status, 0, generated.stderr);
|
|
52
|
+
assert.match(await readFile(envPath, 'utf8'), /^DB_PASSWORD=private-value$/m);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('非标准环境文件被拒绝且不修改', async () => {
|
|
56
|
+
const root = await projectRoot();
|
|
57
|
+
const envPath = join(root, '.env');
|
|
58
|
+
await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 数据库', '# 数据'), { mode: 0o600 });
|
|
59
|
+
const before = await readFile(envPath, 'utf8');
|
|
60
|
+
const generated = run(root, '--write');
|
|
61
|
+
assert.notEqual(generated.status, 0);
|
|
62
|
+
assert.match(generated.stderr, /section order/);
|
|
63
|
+
assert.equal(await readFile(envPath, 'utf8'), before);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('.env 与 .env.example 变量名不一致时拒绝修改', async () => {
|
|
67
|
+
const root = await projectRoot();
|
|
68
|
+
const examplePath = join(root, '.env.example');
|
|
69
|
+
await writeFile(examplePath, (await readFile(examplePath, 'utf8')).replace('# API\n', '# API\nAPI_URL=\n'));
|
|
70
|
+
const envBefore = await readFile(join(root, '.env'), 'utf8');
|
|
71
|
+
const exampleBefore = await readFile(examplePath, 'utf8');
|
|
72
|
+
const generated = run(root, '--write');
|
|
73
|
+
assert.notEqual(generated.status, 0);
|
|
74
|
+
assert.match(generated.stderr, /variable names must remain consistent/);
|
|
75
|
+
assert.equal(await readFile(join(root, '.env'), 'utf8'), envBefore);
|
|
76
|
+
assert.equal(await readFile(examplePath, 'utf8'), exampleBefore);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('差异文件默认拒绝覆盖,显式 force 后恢复一致', async () => {
|
|
80
|
+
const root = await projectRoot();
|
|
81
|
+
assert.equal(run(root, '--write').status, 0);
|
|
82
|
+
await writeFile(join(root, 'deploy.sh'), '# changed\n');
|
|
83
|
+
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
84
|
+
const refused = run(root, '--write');
|
|
85
|
+
assert.notEqual(refused.status, 0);
|
|
86
|
+
assert.match(refused.stderr, /Refusing to overwrite/);
|
|
87
|
+
assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '# changed\n');
|
|
88
|
+
const forced = run(root, '--write', '--force');
|
|
89
|
+
assert.equal(forced.status, 0, forced.stderr);
|
|
90
|
+
assert.equal(run(root, '--check').status, 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('重复或冲突参数被明确拒绝', async () => {
|
|
94
|
+
const root = await projectRoot();
|
|
95
|
+
const duplicateMode = run(root, '--write', '--check');
|
|
96
|
+
assert.notEqual(duplicateMode.status, 0);
|
|
97
|
+
assert.match(duplicateMode.stderr, /Exactly one of --write or --check is required/);
|
|
98
|
+
|
|
99
|
+
const duplicateTarget = spawnSync(
|
|
100
|
+
process.execPath,
|
|
101
|
+
[script, '--target', root, '--target', root, '--check'],
|
|
102
|
+
{ encoding: 'utf8' },
|
|
103
|
+
);
|
|
104
|
+
assert.notEqual(duplicateTarget.status, 0);
|
|
105
|
+
assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
|
|
106
|
+
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
|
4
4
|
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const [deployPath, nginxPath, composePath, packageRoot] = process.argv.slice(2);
|
|
7
7
|
if (!deployPath || !nginxPath || !composePath || !packageRoot) {
|
|
8
|
-
throw new Error('Pass deploy.sh, Nginx
|
|
8
|
+
throw new Error('Pass deploy.sh, Nginx template, Compose file and generated deploy directory paths');
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const [deploy, nginx, compose] = await Promise.all([
|
|
@@ -14,183 +14,204 @@ const [deploy, nginx, compose] = await Promise.all([
|
|
|
14
14
|
readFile(composePath, 'utf8'),
|
|
15
15
|
]);
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'start)',
|
|
20
|
-
'stop)',
|
|
21
|
-
'restart)',
|
|
22
|
-
'status)',
|
|
23
|
-
'rollback)',
|
|
24
|
-
'AGENTS.md',
|
|
25
|
-
'## 发布记录',
|
|
26
|
-
'| 版本号 | 构建时间 |',
|
|
27
|
-
'release.env',
|
|
28
|
-
'RELEASE_VERSION',
|
|
29
|
-
'SCRIPT_DIR',
|
|
30
|
-
'manifest.sha256',
|
|
31
|
-
'pnpm test',
|
|
32
|
-
'pnpm lint',
|
|
33
|
-
'pnpm typecheck',
|
|
34
|
-
'pnpm build',
|
|
35
|
-
'docker build',
|
|
36
|
-
'docker save',
|
|
37
|
-
'.dockerignore',
|
|
38
|
-
'database/migrations',
|
|
39
|
-
'scripts/apply-migrations.sh',
|
|
40
|
-
'HEALTH_URL',
|
|
41
|
-
'VERSION_URL',
|
|
42
|
-
'BLUE_HEALTH_URL',
|
|
43
|
-
'GREEN_HEALTH_URL',
|
|
44
|
-
'BLUE_VERSION_URL',
|
|
45
|
-
'GREEN_VERSION_URL',
|
|
46
|
-
'BLUE_UPSTREAM',
|
|
47
|
-
'GREEN_UPSTREAM',
|
|
48
|
-
'BACKEND_PROXY_URL',
|
|
49
|
-
];
|
|
50
|
-
const nginxRequirements = [
|
|
51
|
-
'__DEPLOY_ROOT__',
|
|
52
|
-
'__BACKEND_PROXY_URL__',
|
|
53
|
-
'location /releases/',
|
|
54
|
-
'/current',
|
|
55
|
-
];
|
|
56
|
-
const projectRootLogMount = '../../logs:/app/logs';
|
|
57
|
-
const composeRequirements = [
|
|
58
|
-
'backend-blue:',
|
|
59
|
-
'backend-green:',
|
|
60
|
-
'INSTANCE_ID',
|
|
61
|
-
'blue',
|
|
62
|
-
'green',
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
const missing = [
|
|
66
|
-
...deployRequirements.filter((value) => !deploy.includes(value)),
|
|
67
|
-
...nginxRequirements.filter((value) => !nginx.includes(value)),
|
|
68
|
-
...composeRequirements.filter((value) => !compose.includes(value)),
|
|
69
|
-
];
|
|
70
|
-
if (missing.length) {
|
|
71
|
-
throw new Error(`Missing deployment requirements: ${missing.join(', ')}`);
|
|
17
|
+
function requirePattern(text, pattern, message) {
|
|
18
|
+
if (!pattern.test(text)) throw new Error(message);
|
|
72
19
|
}
|
|
73
20
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
throw new Error('deploy.sh must not upload artifacts; upload is a manual engineer action');
|
|
21
|
+
function parseDotenv(text) {
|
|
22
|
+
const values = new Map();
|
|
23
|
+
for (const [index, raw] of text.replaceAll('\r\n', '\n').split('\n').entries()) {
|
|
24
|
+
if (!raw || /^\s*#/.test(raw)) continue;
|
|
25
|
+
const match = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
26
|
+
if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
|
|
27
|
+
if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
|
|
28
|
+
let value = match[2];
|
|
29
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
30
|
+
value = value.slice(1, -1);
|
|
31
|
+
} else if (value.startsWith('"') || value.startsWith("'")) {
|
|
32
|
+
throw new Error(`Unclosed root .env quote: ${match[1]}`);
|
|
33
|
+
}
|
|
34
|
+
values.set(match[1], value);
|
|
35
|
+
}
|
|
36
|
+
return values;
|
|
91
37
|
}
|
|
92
38
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
39
|
+
const commands = ['build', 'start', 'replace', 'restart', 'stop', 'remove', 'status', 'rollback'];
|
|
40
|
+
for (const command of commands) {
|
|
41
|
+
requirePattern(deploy, new RegExp(`^ ${command}\\)`, 'm'), `Missing deployment command: ${command}`);
|
|
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`,
|
|
97
48
|
);
|
|
98
49
|
}
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
logTargetMounts.length !== 2
|
|
102
|
-
|| logTargetMounts.some((mount) => !mount.includes(projectRootLogMount))
|
|
103
|
-
) {
|
|
104
|
-
throw new Error('Blue and green must both mount project-root ../../logs directly to /app/logs');
|
|
105
|
-
}
|
|
50
|
+
requirePattern(deploy, /^ rollback\) \[\[ "\$#" -eq 2 \]\] \|\| usage;/m, 'rollback must require exactly one version');
|
|
106
51
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
/server deploy root must be exactly/,
|
|
65
|
+
/assert_project_ports_isolated/,
|
|
66
|
+
/flock -x 9/,
|
|
67
|
+
/Nginx reload failed; old project configuration restored/,
|
|
68
|
+
/database migration was not executed/,
|
|
69
|
+
/docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
|
|
70
|
+
/\$1 == frontend \|\| \$1 == backend/,
|
|
71
|
+
];
|
|
72
|
+
for (const pattern of requiredDeploySemantics) requirePattern(deploy, pattern, `Missing deployment behavior: ${pattern}`);
|
|
73
|
+
if (/\bsource\s+["']?[^\n]*(?:\.env|ACTIVE_STATE|PREVIOUS_STATE)/.test(deploy)) {
|
|
74
|
+
throw new Error('Environment and state files must be parsed as data, not sourced');
|
|
75
|
+
}
|
|
76
|
+
if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
|
|
77
|
+
if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
|
|
78
|
+
if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
|
|
79
|
+
if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${nginx}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
|
|
80
|
+
|
|
81
|
+
const serviceNames = [...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]);
|
|
82
|
+
assertDeepEqual(serviceNames, ['frontend-blue', 'backend-blue', 'frontend-green', 'backend-green'], 'Compose must define exactly four ordered blue-green services');
|
|
83
|
+
if (compose.includes(':-') || compose.includes('not-configured')) throw new Error('Compose image fallback is forbidden');
|
|
84
|
+
if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both frontend services must require FRONTEND_IMAGE');
|
|
85
|
+
if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both backend services must require BACKEND_IMAGE');
|
|
86
|
+
if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 4) throw new Error('All four services must use root .env');
|
|
87
|
+
const logMounts = compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? [];
|
|
88
|
+
if (logMounts.length !== 4) throw new Error('All four services must mount flat project-root logs');
|
|
89
|
+
if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)/.test(compose)) throw new Error('Configurable or nested log directories are forbidden');
|
|
90
|
+
if (/container_name:/.test(compose)) throw new Error('container_name would weaken Compose project isolation');
|
|
91
|
+
if (/FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|_HEALTH_URL|_VERSION_URL/.test(compose)) {
|
|
92
|
+
throw new Error('Compose contains legacy duplicate URL or port configuration');
|
|
93
|
+
}
|
|
94
|
+
for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL']) {
|
|
95
|
+
if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
|
|
112
96
|
}
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
if (JSON.stringify(rootEntries) !== JSON.stringify(expectedRootEntries)) {
|
|
117
|
-
throw new Error(`Generated deploy directory must contain only deploy.sh, release.env and ${version}`);
|
|
97
|
+
|
|
98
|
+
for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DOCKER_HOST_PORTS__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
|
|
99
|
+
if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
|
|
118
100
|
}
|
|
119
|
-
if (
|
|
120
|
-
throw new Error('
|
|
101
|
+
if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx) || !/^# wdyy-docker-ports: __DOCKER_HOST_PORTS__$/m.test(nginx)) {
|
|
102
|
+
throw new Error('Nginx template must expose deterministic project and all port markers');
|
|
121
103
|
}
|
|
104
|
+
if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
|
|
122
105
|
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
'
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
106
|
+
const rootEnvPath = join(packageRoot, '.env');
|
|
107
|
+
const rootEnvStat = await stat(rootEnvPath);
|
|
108
|
+
if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) {
|
|
109
|
+
throw new Error('Root .env must be a private owner-readable file');
|
|
110
|
+
}
|
|
111
|
+
const rootEnvText = await readFile(rootEnvPath, 'utf8');
|
|
112
|
+
const env = parseDotenv(rootEnvText);
|
|
113
|
+
const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
114
|
+
if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
|
|
115
|
+
const requiredEnv = [
|
|
116
|
+
'FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT',
|
|
117
|
+
'PROJECT_NAME', 'SERVER_PROJECTS_ROOT', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
|
|
118
|
+
'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
|
|
119
|
+
'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE', 'DATABASE_MIGRATION_MODE',
|
|
132
120
|
];
|
|
133
|
-
for (const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
throw new Error(`Missing generated release package path: ${requiredPath}`);
|
|
121
|
+
for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
|
|
122
|
+
for (const key of env.keys()) {
|
|
123
|
+
if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
|
|
124
|
+
throw new Error(`Root .env contains legacy duplicate configuration: ${key}`);
|
|
138
125
|
}
|
|
139
126
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
throw new Error(
|
|
127
|
+
if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
|
|
128
|
+
if (!/^\/.+[^/]$/.test(env.get('SERVER_PROJECTS_ROOT'))) throw new Error('Invalid SERVER_PROJECTS_ROOT');
|
|
129
|
+
for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
|
|
130
|
+
if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
|
|
131
|
+
}
|
|
132
|
+
for (const key of ['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
133
|
+
if (!/@sha256:[0-9a-f]{64}$/.test(env.get(key))) throw new Error(`${key} must be pinned by digest`);
|
|
144
134
|
}
|
|
145
|
-
const
|
|
146
|
-
if (!
|
|
147
|
-
|
|
135
|
+
const migrationMode = env.get('DATABASE_MIGRATION_MODE');
|
|
136
|
+
if (!['manual', 'none'].includes(migrationMode)) throw new Error('DATABASE_MIGRATION_MODE must be manual or none');
|
|
137
|
+
if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
|
|
138
|
+
const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
|
|
139
|
+
for (const key of allPortKeys) {
|
|
140
|
+
const port = Number(env.get(key));
|
|
141
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
|
|
142
|
+
}
|
|
143
|
+
const hostPortKeys = ['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
|
|
144
|
+
const hostPorts = hostPortKeys.map((key) => Number(env.get(key)));
|
|
145
|
+
if (hostPorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(hostPorts).size !== hostPorts.length) {
|
|
146
|
+
throw new Error('Project and host blue-green ports must be valid and unique');
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
149
|
+
const releaseEnv = await readFile(join(packageRoot, 'release.env'), 'utf8');
|
|
150
|
+
const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
|
|
151
|
+
if (!releaseMatch) throw new Error('release.env must contain exactly one release version');
|
|
152
|
+
const version = releaseMatch[1];
|
|
153
|
+
const expectedRootEntries = ['.env', version, 'deploy.sh', 'release.env'].sort();
|
|
154
|
+
assertDeepEqual((await readdir(packageRoot)).sort(), expectedRootEntries, 'Generated deploy root has unexpected entries');
|
|
155
|
+
if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) throw new Error('deploy.sh must be the package root script');
|
|
156
|
+
if (resolve(nginxPath) !== resolve(packageRoot, version, 'nginx/site.conf')) throw new Error('Nginx path must be inside the selected version');
|
|
157
|
+
if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.blue-green.yml')) throw new Error('Compose path must be inside the selected version');
|
|
158
|
+
|
|
159
|
+
const versionRoot = join(packageRoot, version);
|
|
160
|
+
const requiredPaths = ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.blue-green.yml', 'nginx/site.conf'];
|
|
161
|
+
for (const path of requiredPaths) {
|
|
162
|
+
const info = await stat(join(versionRoot, path));
|
|
163
|
+
if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
|
|
164
|
+
}
|
|
165
|
+
if (migrationMode === 'manual') {
|
|
166
|
+
const runner = await stat(join(versionRoot, 'database/apply-migrations.sh'));
|
|
167
|
+
if (!runner.isFile() || (runner.mode & 0o111) === 0) throw new Error('Manual migration runner must be executable');
|
|
168
|
+
const migrations = await readdir(join(versionRoot, 'database/migrations'));
|
|
169
|
+
if (!migrations.some((item) => /^V\d{3}__[a-z0-9]+(?:_[a-z0-9]+)*\.sql$/.test(item))) throw new Error('Manual migration files are missing');
|
|
170
|
+
} else {
|
|
171
|
+
await assertMissing(join(versionRoot, 'database'), 'none mode must not package database files');
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
const versionFiles = (await listFiles(versionRoot)).sort();
|
|
164
175
|
for (const path of versionFiles) {
|
|
165
176
|
const name = basename(path);
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|| name.startsWith('.env.')
|
|
169
|
-
|| /\.(?:pem|key)$/.test(name)
|
|
170
|
-
|| /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)
|
|
171
|
-
) {
|
|
172
|
-
throw new Error(`Production secret file must not be packaged: ${path}`);
|
|
177
|
+
if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) {
|
|
178
|
+
throw new Error(`Secret file must not enter version directory: ${path}`);
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
|
-
|
|
176
|
-
const manifestPath = join(versionRoot, 'manifest.sha256');
|
|
177
|
-
const manifest = await readFile(manifestPath, 'utf8');
|
|
181
|
+
const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
|
|
178
182
|
const entries = new Map();
|
|
179
183
|
for (const line of manifest.trimEnd().split('\n')) {
|
|
180
184
|
const match = line.match(/^([0-9a-f]{64}) ([^/].*)$/);
|
|
181
|
-
if (!match || match[2].split('/').includes('..')) {
|
|
182
|
-
throw new Error(`Invalid manifest entry: ${line}`);
|
|
183
|
-
}
|
|
185
|
+
if (!match || match[2].split('/').includes('..')) throw new Error(`Invalid manifest entry: ${line}`);
|
|
184
186
|
if (entries.has(match[2])) throw new Error(`Duplicate manifest entry: ${match[2]}`);
|
|
185
187
|
entries.set(match[2], match[1]);
|
|
186
188
|
}
|
|
187
|
-
|
|
188
|
-
if (JSON.stringify([...entries.keys()].sort()) !== JSON.stringify(expectedManifestFiles)) {
|
|
189
|
-
throw new Error('manifest.sha256 must cover every generated release file exactly once');
|
|
190
|
-
}
|
|
189
|
+
assertDeepEqual([...entries.keys()].sort(), versionFiles.filter((path) => path !== 'manifest.sha256'), 'Manifest coverage is incomplete');
|
|
191
190
|
for (const [path, expected] of entries) {
|
|
192
191
|
const actual = createHash('sha256').update(await readFile(join(versionRoot, path))).digest('hex');
|
|
193
192
|
if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
|
|
194
193
|
}
|
|
195
194
|
|
|
196
|
-
process.stdout.write(`valid deployment package ${version}\n`);
|
|
195
|
+
process.stdout.write(`valid dual-image deployment package ${version}\n`);
|
|
196
|
+
|
|
197
|
+
function assertDeepEqual(actual, expected, message) {
|
|
198
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
199
|
+
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function assertMissing(path, message) {
|
|
204
|
+
try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
205
|
+
throw new Error(message);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function listFiles(root, directory = root) {
|
|
209
|
+
const result = [];
|
|
210
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
211
|
+
const path = join(directory, entry.name);
|
|
212
|
+
if (entry.isSymbolicLink()) throw new Error(`Release package must not contain symbolic links: ${relative(root, path)}`);
|
|
213
|
+
if (entry.isDirectory()) result.push(...await listFiles(root, path));
|
|
214
|
+
else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|