@weotro/dx 0.1.19 → 0.2.0
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/README.md +77 -718
- package/bin/dx.js +100 -173
- package/example/.env.development +1 -0
- package/example/README.md +24 -0
- package/example/dx/scripts/build +2 -0
- package/example/dx/scripts/db +2 -0
- package/example/dx/scripts/deploy +2 -0
- package/example/dx/scripts/setup +2 -0
- package/example/dx/scripts/start +12 -0
- package/example/dx/scripts/test +2 -0
- package/lib/env.js +40 -257
- package/lib/logger.js +5 -238
- package/lib/setup.js +72 -0
- package/package.json +27 -13
- package/skills/ask_3th/SKILL.md +2 -0
- package/skills/delegate_3th/SKILL.md +4 -2
- package/skills/delegate_3th/scripts/delegate_3th.py +37 -41
- package/skills/delegate_3th/scripts/test_delegate_3th.py +43 -17
- package/bin/dx-with-version-env.js +0 -8
- package/lib/artifact-deploy/artifact-builder.js +0 -144
- package/lib/artifact-deploy/config.js +0 -180
- package/lib/artifact-deploy/remote-script.js +0 -301
- package/lib/artifact-deploy/remote-transport.js +0 -86
- package/lib/artifact-deploy.js +0 -70
- package/lib/backend-artifact-deploy/artifact-builder.js +0 -267
- package/lib/backend-artifact-deploy/config.js +0 -218
- package/lib/backend-artifact-deploy/path-utils.js +0 -18
- package/lib/backend-artifact-deploy/remote-phases.js +0 -14
- package/lib/backend-artifact-deploy/remote-result.js +0 -44
- package/lib/backend-artifact-deploy/remote-script.js +0 -507
- package/lib/backend-artifact-deploy/remote-transport.js +0 -123
- package/lib/backend-artifact-deploy/rollback.js +0 -5
- package/lib/backend-artifact-deploy/runtime-package.js +0 -46
- package/lib/backend-artifact-deploy.js +0 -91
- package/lib/backend-package.js +0 -674
- package/lib/cli/args.js +0 -38
- package/lib/cli/command-result.js +0 -1
- package/lib/cli/commands/contracts.js +0 -60
- package/lib/cli/commands/core.js +0 -533
- package/lib/cli/commands/db.js +0 -231
- package/lib/cli/commands/deploy.js +0 -175
- package/lib/cli/commands/env.js +0 -120
- package/lib/cli/commands/export.js +0 -39
- package/lib/cli/commands/package.js +0 -22
- package/lib/cli/commands/release.js +0 -55
- package/lib/cli/commands/stack.js +0 -427
- package/lib/cli/commands/start.js +0 -58
- package/lib/cli/commands/worktree.js +0 -145
- package/lib/cli/dx-cli.js +0 -1072
- package/lib/cli/flags.js +0 -123
- package/lib/cli/help-model.js +0 -222
- package/lib/cli/help-renderer.js +0 -137
- package/lib/cli/help-schema.js +0 -552
- package/lib/cli/help.js +0 -141
- package/lib/cli/index.js +0 -4
- package/lib/cli/nx-command.js +0 -13
- package/lib/confirm.js +0 -213
- package/lib/env-policy.js +0 -134
- package/lib/env-profile.js +0 -531
- package/lib/exec.js +0 -692
- package/lib/nx-ignore.js +0 -45
- package/lib/run-with-version-env.js +0 -163
- package/lib/sdk-build.js +0 -424
- package/lib/start-dev.js +0 -401
- package/lib/telegram-webhook.js +0 -431
- package/lib/validate-env.js +0 -317
- package/lib/vercel-deploy.js +0 -549
- package/lib/worktree.js +0 -1052
- package/skills/delegate_3th/scripts/__pycache__/delegate_3th.cpython-314.pyc +0 -0
- package/skills/delegate_3th/scripts/__pycache__/delegate_cc.cpython-314.pyc +0 -0
- package/skills/delegate_3th/scripts/__pycache__/test_delegate_cc.cpython-314.pyc +0 -0
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
3
|
-
import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
4
|
-
import { basename, dirname, join } from 'node:path'
|
|
5
|
-
import { promisify } from 'node:util'
|
|
6
|
-
import { execManager } from '../exec.js'
|
|
7
|
-
import { resolveWithinBase } from '../backend-artifact-deploy/path-utils.js'
|
|
8
|
-
|
|
9
|
-
const execFileAsync = promisify(execFile)
|
|
10
|
-
const tarEnv = {
|
|
11
|
-
...process.env,
|
|
12
|
-
COPYFILE_DISABLE: '1',
|
|
13
|
-
COPY_EXTENDED_ATTRIBUTES_DISABLE: '1',
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function assertSafeNamePart(value, label) {
|
|
17
|
-
const text = String(value || '').trim()
|
|
18
|
-
if (!text || text.includes('/') || text.includes('\\') || text.includes('..')) {
|
|
19
|
-
throw new Error(`${label} 越界,已拒绝: ${text}`)
|
|
20
|
-
}
|
|
21
|
-
return text
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function defaultNowTag() {
|
|
25
|
-
const now = new Date()
|
|
26
|
-
const pad = value => String(value).padStart(2, '0')
|
|
27
|
-
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function defaultRunBuild(build, environment) {
|
|
31
|
-
await execManager.executeCommand(build.command, {
|
|
32
|
-
app: build.app || undefined,
|
|
33
|
-
skipEnvValidation: !build.app,
|
|
34
|
-
flags: environment === 'production'
|
|
35
|
-
? { prod: true }
|
|
36
|
-
: environment === 'staging'
|
|
37
|
-
? { staging: true }
|
|
38
|
-
: { dev: true },
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function defaultReadVersion(config) {
|
|
43
|
-
if (config.artifact.version) return config.artifact.version
|
|
44
|
-
if (config.build.versionCommand) {
|
|
45
|
-
const { stdout } = await execFileAsync('bash', ['-lc', config.build.versionCommand], {
|
|
46
|
-
cwd: config.projectRoot,
|
|
47
|
-
env: process.env,
|
|
48
|
-
})
|
|
49
|
-
return String(stdout).trim()
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const parsed = JSON.parse(await readFile(config.build.versionFile, 'utf8'))
|
|
53
|
-
return String(parsed.version || '').trim()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function defaultStageFiles(sourceDir, stageDir) {
|
|
57
|
-
if (!existsSync(sourceDir)) throw new Error(`缺少待打包目录: ${sourceDir}`)
|
|
58
|
-
await rm(stageDir, { recursive: true, force: true })
|
|
59
|
-
await mkdir(stageDir, { recursive: true })
|
|
60
|
-
for (const entry of await readdir(sourceDir)) {
|
|
61
|
-
await cp(join(sourceDir, entry), join(stageDir, entry), { recursive: true })
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function defaultAssertNoEnvFiles(stageDir) {
|
|
66
|
-
const queue = ['.']
|
|
67
|
-
const envFiles = []
|
|
68
|
-
while (queue.length > 0) {
|
|
69
|
-
const relativeDir = queue.shift()
|
|
70
|
-
const currentDir = relativeDir === '.' ? stageDir : join(stageDir, relativeDir)
|
|
71
|
-
for (const entry of await readdir(currentDir, { withFileTypes: true })) {
|
|
72
|
-
const relativePath = relativeDir === '.' ? entry.name : join(relativeDir, entry.name)
|
|
73
|
-
if (entry.name.startsWith('.env')) envFiles.push(relativePath.replace(/\\/g, '/'))
|
|
74
|
-
if (entry.isDirectory()) queue.push(relativePath)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (envFiles.length > 0) throw new Error(`制品目录包含 .env* 文件: ${envFiles.join(', ')}`)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function createArtifactNames({ version, timeTag, bundleName, releaseName }) {
|
|
81
|
-
const safeVersion = assertSafeNamePart(version, 'version')
|
|
82
|
-
const safeTimeTag = assertSafeNamePart(timeTag, 'timeTag')
|
|
83
|
-
const safeBundleName = assertSafeNamePart(bundleName, 'bundleName')
|
|
84
|
-
const safeReleaseName = assertSafeNamePart(releaseName, 'releaseName')
|
|
85
|
-
const versionName = `${safeReleaseName}-v${safeVersion}-${safeTimeTag}`
|
|
86
|
-
const innerArchiveName = `${versionName}.tgz`
|
|
87
|
-
return {
|
|
88
|
-
versionName,
|
|
89
|
-
innerArchiveName,
|
|
90
|
-
checksumName: `${innerArchiveName}.sha256`,
|
|
91
|
-
bundleName: `${safeBundleName}-v${safeVersion}-${safeTimeTag}.tgz`,
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export async function buildArtifact(config, deps = {}) {
|
|
96
|
-
const runBuild = deps.runBuild || defaultRunBuild
|
|
97
|
-
const readVersion = deps.readVersion || defaultReadVersion
|
|
98
|
-
const nowTag = deps.nowTag || defaultNowTag
|
|
99
|
-
const stageFiles = deps.stageFiles || defaultStageFiles
|
|
100
|
-
const assertNoEnvFiles = deps.assertNoEnvFiles || defaultAssertNoEnvFiles
|
|
101
|
-
const version = await readVersion(config)
|
|
102
|
-
if (!version) throw new Error('无法解析制品版本')
|
|
103
|
-
|
|
104
|
-
const names = createArtifactNames({
|
|
105
|
-
version,
|
|
106
|
-
timeTag: nowTag(),
|
|
107
|
-
bundleName: config.artifact.bundleName,
|
|
108
|
-
releaseName: config.artifact.releaseName,
|
|
109
|
-
})
|
|
110
|
-
const outputDir = resolveWithinBase(config.artifact.outputDir, '.', 'artifact.outputDir')
|
|
111
|
-
const stageDir = resolveWithinBase(outputDir, names.versionName, 'stageDir')
|
|
112
|
-
const innerArchivePath = resolveWithinBase(outputDir, names.innerArchiveName, 'innerArchivePath')
|
|
113
|
-
const checksumPath = resolveWithinBase(outputDir, names.checksumName, 'checksumPath')
|
|
114
|
-
const bundlePath = resolveWithinBase(outputDir, names.bundleName, 'bundlePath')
|
|
115
|
-
|
|
116
|
-
await runBuild(config.build, config.environment)
|
|
117
|
-
await mkdir(outputDir, { recursive: true })
|
|
118
|
-
await stageFiles(config.build.sourceDir, stageDir)
|
|
119
|
-
await assertNoEnvFiles(stageDir)
|
|
120
|
-
await execFileAsync('tar', ['-czf', innerArchivePath, '.'], { cwd: stageDir, env: tarEnv })
|
|
121
|
-
|
|
122
|
-
const archiveName = basename(innerArchivePath)
|
|
123
|
-
let checksum
|
|
124
|
-
try {
|
|
125
|
-
checksum = await execFileAsync('sha256sum', [archiveName], { cwd: dirname(innerArchivePath) })
|
|
126
|
-
} catch {
|
|
127
|
-
checksum = await execFileAsync('shasum', ['-a', '256', archiveName], { cwd: dirname(innerArchivePath) })
|
|
128
|
-
}
|
|
129
|
-
await writeFile(checksumPath, checksum.stdout)
|
|
130
|
-
await execFileAsync('tar', ['-czf', bundlePath, archiveName, basename(checksumPath)], {
|
|
131
|
-
cwd: outputDir,
|
|
132
|
-
env: tarEnv,
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
version,
|
|
137
|
-
versionName: names.versionName,
|
|
138
|
-
innerArchiveName: names.innerArchiveName,
|
|
139
|
-
checksumName: names.checksumName,
|
|
140
|
-
bundlePath,
|
|
141
|
-
innerArchivePath,
|
|
142
|
-
checksumPath,
|
|
143
|
-
}
|
|
144
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { isAbsolute } from 'node:path'
|
|
2
|
-
import { resolveWithinBase } from '../backend-artifact-deploy/path-utils.js'
|
|
3
|
-
|
|
4
|
-
function requireString(value, fieldPath) {
|
|
5
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
6
|
-
throw new Error(`缺少必填配置: ${fieldPath}`)
|
|
7
|
-
}
|
|
8
|
-
return value.trim()
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function optionalString(value) {
|
|
12
|
-
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function requireSafeName(value, fieldPath) {
|
|
16
|
-
const name = requireString(value, fieldPath)
|
|
17
|
-
if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes('..')) {
|
|
18
|
-
throw new Error(`${fieldPath} 包含非法字符: ${name}`)
|
|
19
|
-
}
|
|
20
|
-
return name
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function requirePositiveInteger(value, fieldPath) {
|
|
24
|
-
const parsed = Number(value)
|
|
25
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
26
|
-
throw new Error(`缺少必填配置: ${fieldPath}`)
|
|
27
|
-
}
|
|
28
|
-
return parsed
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function resolveProjectPath(projectRoot, value, fieldPath) {
|
|
32
|
-
return resolveWithinBase(projectRoot, requireString(value, fieldPath), fieldPath)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function requireRemoteBaseDir(value, fieldPath) {
|
|
36
|
-
const baseDir = requireString(value, fieldPath)
|
|
37
|
-
if (!isAbsolute(baseDir)) throw new Error(`${fieldPath} 必须是绝对路径: ${baseDir}`)
|
|
38
|
-
if (!/^\/[A-Za-z0-9._/-]*$/.test(baseDir)) {
|
|
39
|
-
throw new Error(`${fieldPath} 包含非法字符: ${baseDir}`)
|
|
40
|
-
}
|
|
41
|
-
return baseDir.replace(/\/+$/, '') || '/'
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function resolveRemoteConfig(remoteConfig, environment) {
|
|
45
|
-
if (!remoteConfig || typeof remoteConfig !== 'object') return null
|
|
46
|
-
if (typeof remoteConfig.host === 'string') return remoteConfig
|
|
47
|
-
|
|
48
|
-
const selected = remoteConfig[environment]
|
|
49
|
-
if (!selected || typeof selected !== 'object') {
|
|
50
|
-
throw new Error(`缺少必填配置: remote.${environment}`)
|
|
51
|
-
}
|
|
52
|
-
return selected
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function resolveBuildCommand(buildConfig, environment) {
|
|
56
|
-
if (buildConfig?.commands && typeof buildConfig.commands === 'object') {
|
|
57
|
-
return requireString(buildConfig.commands[environment], `build.commands.${environment}`)
|
|
58
|
-
}
|
|
59
|
-
return requireString(buildConfig?.command, 'build.command')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function resolveHealthCheck(healthCheckConfig) {
|
|
63
|
-
if (healthCheckConfig == null) return null
|
|
64
|
-
const url = requireString(healthCheckConfig.url, 'verify.healthCheck.url')
|
|
65
|
-
try {
|
|
66
|
-
new URL(url)
|
|
67
|
-
} catch {
|
|
68
|
-
throw new Error('缺少必填配置: verify.healthCheck.url')
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
url,
|
|
73
|
-
timeoutSeconds: healthCheckConfig.timeoutSeconds == null
|
|
74
|
-
? 10
|
|
75
|
-
: requirePositiveInteger(healthCheckConfig.timeoutSeconds, 'verify.healthCheck.timeoutSeconds'),
|
|
76
|
-
maxWaitSeconds: healthCheckConfig.maxWaitSeconds == null
|
|
77
|
-
? 24
|
|
78
|
-
: requirePositiveInteger(healthCheckConfig.maxWaitSeconds, 'verify.healthCheck.maxWaitSeconds'),
|
|
79
|
-
retryIntervalSeconds: healthCheckConfig.retryIntervalSeconds == null
|
|
80
|
-
? 2
|
|
81
|
-
: requirePositiveInteger(
|
|
82
|
-
healthCheckConfig.retryIntervalSeconds,
|
|
83
|
-
'verify.healthCheck.retryIntervalSeconds',
|
|
84
|
-
),
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function resolveArtifactDeployConfig({ cli, target, targetConfig, environment, flags = {} }) {
|
|
89
|
-
const deployConfig = targetConfig?.artifactDeploy
|
|
90
|
-
if (!deployConfig || typeof deployConfig !== 'object') {
|
|
91
|
-
throw new Error('缺少必填配置: artifactDeploy')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const buildConfig = deployConfig.build || {}
|
|
95
|
-
const artifactConfig = deployConfig.artifact || {}
|
|
96
|
-
const startupConfig = deployConfig.startup || {}
|
|
97
|
-
const runConfig = deployConfig.deploy || {}
|
|
98
|
-
const verifyConfig = deployConfig.verify || {}
|
|
99
|
-
const buildOnly = Boolean(flags.buildOnly)
|
|
100
|
-
const remoteConfig = resolveRemoteConfig(deployConfig.remote, environment)
|
|
101
|
-
const startupMode = optionalString(startupConfig.mode) || 'command'
|
|
102
|
-
|
|
103
|
-
if (!['systemd', 'command'].includes(startupMode)) {
|
|
104
|
-
throw new Error('startup.mode 仅支持 systemd 或 command')
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const versionFile = optionalString(buildConfig.versionFile)
|
|
108
|
-
const versionCommand = optionalString(buildConfig.versionCommand)
|
|
109
|
-
const version = optionalString(artifactConfig.version)
|
|
110
|
-
if (!versionFile && !versionCommand && !version) {
|
|
111
|
-
throw new Error('缺少必填配置: build.versionFile、build.versionCommand 或 artifact.version')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const serviceName = optionalString(startupConfig.serviceName)
|
|
115
|
-
const startupCommand = optionalString(startupConfig.command)
|
|
116
|
-
if (serviceName && !/^[A-Za-z0-9_.@-]+$/.test(serviceName)) {
|
|
117
|
-
throw new Error(`startup.serviceName 包含非法字符: ${serviceName}`)
|
|
118
|
-
}
|
|
119
|
-
if (startupMode === 'systemd' && !serviceName) {
|
|
120
|
-
throw new Error('缺少必填配置: startup.serviceName')
|
|
121
|
-
}
|
|
122
|
-
if (startupMode === 'command' && !startupCommand) {
|
|
123
|
-
throw new Error('缺少必填配置: startup.command')
|
|
124
|
-
}
|
|
125
|
-
const healthCheck = resolveHealthCheck(verifyConfig.healthCheck)
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
projectRoot: cli.projectRoot,
|
|
129
|
-
target,
|
|
130
|
-
environment,
|
|
131
|
-
build: {
|
|
132
|
-
app: optionalString(buildConfig.app),
|
|
133
|
-
command: resolveBuildCommand(buildConfig, environment),
|
|
134
|
-
sourceDir: resolveProjectPath(cli.projectRoot, buildConfig.sourceDir, 'build.sourceDir'),
|
|
135
|
-
versionFile: versionFile
|
|
136
|
-
? resolveProjectPath(cli.projectRoot, versionFile, 'build.versionFile')
|
|
137
|
-
: null,
|
|
138
|
-
versionCommand,
|
|
139
|
-
},
|
|
140
|
-
artifact: {
|
|
141
|
-
outputDir: resolveProjectPath(cli.projectRoot, artifactConfig.outputDir, 'artifact.outputDir'),
|
|
142
|
-
bundleName: requireSafeName(artifactConfig.bundleName, 'artifact.bundleName'),
|
|
143
|
-
releaseName: requireSafeName(
|
|
144
|
-
optionalString(artifactConfig.releaseName) || String(target),
|
|
145
|
-
'artifact.releaseName',
|
|
146
|
-
),
|
|
147
|
-
version,
|
|
148
|
-
},
|
|
149
|
-
remote: buildOnly
|
|
150
|
-
? null
|
|
151
|
-
: {
|
|
152
|
-
host: requireString(remoteConfig?.host, 'remote.host'),
|
|
153
|
-
port: remoteConfig?.port == null ? 22 : requirePositiveInteger(remoteConfig.port, 'remote.port'),
|
|
154
|
-
user: requireString(remoteConfig?.user, 'remote.user'),
|
|
155
|
-
baseDir: requireRemoteBaseDir(remoteConfig?.baseDir, 'remote.baseDir'),
|
|
156
|
-
},
|
|
157
|
-
startup: {
|
|
158
|
-
mode: startupMode,
|
|
159
|
-
serviceName,
|
|
160
|
-
command: startupCommand,
|
|
161
|
-
rollbackCommand: optionalString(startupConfig.rollbackCommand),
|
|
162
|
-
},
|
|
163
|
-
deploy: {
|
|
164
|
-
keepReleases: runConfig.keepReleases == null
|
|
165
|
-
? 5
|
|
166
|
-
: requirePositiveInteger(runConfig.keepReleases, 'deploy.keepReleases'),
|
|
167
|
-
installCommand: optionalString(runConfig.installCommand),
|
|
168
|
-
},
|
|
169
|
-
verify: {
|
|
170
|
-
command: optionalString(verifyConfig.command),
|
|
171
|
-
maxWaitSeconds: verifyConfig.maxWaitSeconds == null
|
|
172
|
-
? healthCheck?.maxWaitSeconds || 24
|
|
173
|
-
: requirePositiveInteger(verifyConfig.maxWaitSeconds, 'verify.maxWaitSeconds'),
|
|
174
|
-
retryIntervalSeconds: verifyConfig.retryIntervalSeconds == null
|
|
175
|
-
? healthCheck?.retryIntervalSeconds || 2
|
|
176
|
-
: requirePositiveInteger(verifyConfig.retryIntervalSeconds, 'verify.retryIntervalSeconds'),
|
|
177
|
-
healthCheck,
|
|
178
|
-
},
|
|
179
|
-
}
|
|
180
|
-
}
|
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
function escapeShell(value) {
|
|
2
|
-
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
export function buildRemoteDeployScript(payload = {}) {
|
|
6
|
-
const remote = payload.remote || {}
|
|
7
|
-
const artifact = payload.artifact || {}
|
|
8
|
-
const startup = payload.startup || {}
|
|
9
|
-
const deploy = payload.deploy || {}
|
|
10
|
-
const verify = payload.verify || {}
|
|
11
|
-
const healthCheck = verify.healthCheck || null
|
|
12
|
-
const baseDir = String(remote.baseDir || '.')
|
|
13
|
-
const versionName = String(payload.versionName || 'unknown')
|
|
14
|
-
const releaseDir = `${baseDir}/releases/${versionName}`
|
|
15
|
-
const currentLink = `${baseDir}/current`
|
|
16
|
-
const serviceName = String(startup.serviceName || '')
|
|
17
|
-
const startupMode = String(startup.mode || 'command')
|
|
18
|
-
const startupCommand = String(
|
|
19
|
-
startup.command || (startupMode === 'systemd' ? `sudo systemctl restart ${serviceName}` : ''),
|
|
20
|
-
)
|
|
21
|
-
const rollbackCommand = String(startup.rollbackCommand || startupCommand)
|
|
22
|
-
const verifyCommand = String(
|
|
23
|
-
verify.command || (startupMode === 'systemd' ? `sudo systemctl is-active --quiet ${serviceName}` : ''),
|
|
24
|
-
)
|
|
25
|
-
const installCommand = String(deploy.installCommand || '')
|
|
26
|
-
const healthCheckUrl = String(healthCheck?.url || '')
|
|
27
|
-
const healthCheckTimeoutSeconds = Number(healthCheck?.timeoutSeconds || 10)
|
|
28
|
-
const healthCheckMaxWaitSeconds = Number(verify.maxWaitSeconds || healthCheck?.maxWaitSeconds || 24)
|
|
29
|
-
const retryIntervalSeconds = Number(
|
|
30
|
-
verify.retryIntervalSeconds || healthCheck?.retryIntervalSeconds || 2,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
return `#!/usr/bin/env bash
|
|
34
|
-
set -euo pipefail
|
|
35
|
-
|
|
36
|
-
APP_ROOT=${escapeShell(baseDir)}
|
|
37
|
-
ARCHIVE=${escapeShell(payload.uploadedBundlePath || '')}
|
|
38
|
-
RELEASE_DIR=${escapeShell(releaseDir)}
|
|
39
|
-
CURRENT_LINK=${escapeShell(currentLink)}
|
|
40
|
-
ENV_NAME=${escapeShell(payload.environment || 'production')}
|
|
41
|
-
VERSION_NAME=${escapeShell(versionName)}
|
|
42
|
-
INNER_ARCHIVE_NAME=${escapeShell(artifact.innerArchiveName || `${versionName}.tgz`)}
|
|
43
|
-
CHECKSUM_NAME=${escapeShell(artifact.checksumName || `${versionName}.tgz.sha256`)}
|
|
44
|
-
START_MODE=${escapeShell(startupMode)}
|
|
45
|
-
SERVICE_NAME=${escapeShell(serviceName)}
|
|
46
|
-
INSTALL_COMMAND=${escapeShell(installCommand)}
|
|
47
|
-
START_COMMAND=${escapeShell(startupCommand)}
|
|
48
|
-
ROLLBACK_COMMAND=${escapeShell(rollbackCommand)}
|
|
49
|
-
VERIFY_COMMAND=${escapeShell(verifyCommand)}
|
|
50
|
-
HEALTHCHECK_URL=${escapeShell(healthCheckUrl)}
|
|
51
|
-
HEALTHCHECK_TIMEOUT_SECONDS=${healthCheckTimeoutSeconds}
|
|
52
|
-
VERIFY_MAX_WAIT_SECONDS=${healthCheckMaxWaitSeconds}
|
|
53
|
-
VERIFY_RETRY_DELAY_SECONDS=${retryIntervalSeconds}
|
|
54
|
-
KEEP_RELEASES=${Number(deploy.keepReleases || 5)}
|
|
55
|
-
|
|
56
|
-
LOCK_FILE="$APP_ROOT/.deploy.lock"
|
|
57
|
-
LOCK_DIR="$APP_ROOT/.deploy.lock.d"
|
|
58
|
-
SHARED_DIR="$APP_ROOT/shared"
|
|
59
|
-
RELEASES_DIR="$APP_ROOT/releases"
|
|
60
|
-
UPLOADS_DIR="$APP_ROOT/uploads"
|
|
61
|
-
PREVIOUS_CURRENT_TARGET=""
|
|
62
|
-
BUNDLE_TEMP_DIR=""
|
|
63
|
-
CURRENT_PHASE="init"
|
|
64
|
-
RESULT_EMITTED=0
|
|
65
|
-
ROLLBACK_ATTEMPTED=false
|
|
66
|
-
ROLLBACK_SUCCEEDED=null
|
|
67
|
-
CURRENT_SWITCHED=0
|
|
68
|
-
|
|
69
|
-
json_escape() {
|
|
70
|
-
local value="\${1-}"
|
|
71
|
-
value="\${value//\\\\/\\\\\\\\}"
|
|
72
|
-
value="\${value//\"/\\\\\"}"
|
|
73
|
-
value="\${value//$'\\n'/\\\\n}"
|
|
74
|
-
value="\${value//$'\\r'/\\\\r}"
|
|
75
|
-
value="\${value//$'\\t'/\\\\t}"
|
|
76
|
-
printf '%s' "$value"
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
build_summary_json() {
|
|
80
|
-
local current_release="$1"
|
|
81
|
-
printf '{"releaseName":"%s","currentRelease":"%s","serviceName":"%s","startupMode":"%s","healthUrl":"%s"}' \\
|
|
82
|
-
"$(json_escape "$VERSION_NAME")" \\
|
|
83
|
-
"$(json_escape "$current_release")" \\
|
|
84
|
-
"$(json_escape "$SERVICE_NAME")" \\
|
|
85
|
-
"$(json_escape "$START_MODE")" \\
|
|
86
|
-
"$(json_escape "$HEALTHCHECK_URL")"
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
emit_result() {
|
|
90
|
-
local ok="$1"
|
|
91
|
-
local phase="$2"
|
|
92
|
-
local message="$3"
|
|
93
|
-
local summary_json="\${4:-null}"
|
|
94
|
-
if [[ "$RESULT_EMITTED" -eq 1 ]]; then return; fi
|
|
95
|
-
RESULT_EMITTED=1
|
|
96
|
-
message="\${message//\\\\/\\\\\\\\}"
|
|
97
|
-
message="\${message//\"/\\\\\"}"
|
|
98
|
-
message="\${message//$'\\n'/\\\\n}"
|
|
99
|
-
printf 'DX_REMOTE_RESULT={"ok":%s,"phase":"%s","message":"%s","rollbackAttempted":%s,"rollbackSucceeded":%s,"summary":%s}\\n' \\
|
|
100
|
-
"$ok" "$phase" "$message" "$ROLLBACK_ATTEMPTED" "$ROLLBACK_SUCCEEDED" "$summary_json"
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
cleanup() {
|
|
104
|
-
rm -rf "$BUNDLE_TEMP_DIR" 2>/dev/null || true
|
|
105
|
-
rmdir "$LOCK_DIR" 2>/dev/null || true
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
run_command_at() {
|
|
109
|
-
local cwd="$1"
|
|
110
|
-
local command="$2"
|
|
111
|
-
if [[ -z "$command" ]]; then return 0; fi
|
|
112
|
-
(
|
|
113
|
-
cd "$cwd"
|
|
114
|
-
DX_RELEASE_DIR="$cwd" \\
|
|
115
|
-
DX_CURRENT_LINK="$CURRENT_LINK" \\
|
|
116
|
-
DX_PREVIOUS_RELEASE="$PREVIOUS_CURRENT_TARGET" \\
|
|
117
|
-
DX_ENVIRONMENT="$ENV_NAME" \\
|
|
118
|
-
DX_SERVICE_NAME="$SERVICE_NAME" \\
|
|
119
|
-
bash -lc "$command"
|
|
120
|
-
)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
attempt_rollback() {
|
|
124
|
-
if [[ -z "$PREVIOUS_CURRENT_TARGET" || ! -d "$PREVIOUS_CURRENT_TARGET" ]]; then
|
|
125
|
-
return
|
|
126
|
-
fi
|
|
127
|
-
ROLLBACK_ATTEMPTED=true
|
|
128
|
-
if ln -sfn "$PREVIOUS_CURRENT_TARGET" "$CURRENT_LINK" && run_command_at "$CURRENT_LINK" "$ROLLBACK_COMMAND"; then
|
|
129
|
-
ROLLBACK_SUCCEEDED=true
|
|
130
|
-
else
|
|
131
|
-
ROLLBACK_SUCCEEDED=false
|
|
132
|
-
fi
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
fail_after_switch() {
|
|
136
|
-
local phase="$1"
|
|
137
|
-
local message="$2"
|
|
138
|
-
attempt_rollback
|
|
139
|
-
emit_result false "$phase" "$message"
|
|
140
|
-
exit 1
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
on_error() {
|
|
144
|
-
local code=$?
|
|
145
|
-
if [[ "$CURRENT_SWITCHED" -eq 1 && "$ROLLBACK_ATTEMPTED" == "false" ]]; then
|
|
146
|
-
attempt_rollback
|
|
147
|
-
fi
|
|
148
|
-
emit_result false "$CURRENT_PHASE" "phase failed (exit $code)"
|
|
149
|
-
exit "$code"
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
trap cleanup EXIT
|
|
153
|
-
trap on_error ERR
|
|
154
|
-
|
|
155
|
-
validate_path_within_base() {
|
|
156
|
-
local base="$1"
|
|
157
|
-
local target="$2"
|
|
158
|
-
case "$target" in
|
|
159
|
-
"$base"/*|"$base") ;;
|
|
160
|
-
*) echo "目标路径越界: $target" >&2; exit 1 ;;
|
|
161
|
-
esac
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
validate_archive_entries() {
|
|
165
|
-
local archive="$1"
|
|
166
|
-
local entry
|
|
167
|
-
local tar_line
|
|
168
|
-
local link_target
|
|
169
|
-
while IFS= read -r entry; do
|
|
170
|
-
if [[ "$entry" == /* || "$entry" =~ (^|/)\\.\\.(/|$) || "$entry" =~ \\.\\.\\\\ ]]; then
|
|
171
|
-
echo "包含可疑路径条目: $entry" >&2
|
|
172
|
-
exit 1
|
|
173
|
-
fi
|
|
174
|
-
done < <(tar -tzf "$archive")
|
|
175
|
-
while IFS= read -r tar_line; do
|
|
176
|
-
if [[ "$tar_line" == *" -> "* ]]; then
|
|
177
|
-
link_target="\${tar_line##* -> }"
|
|
178
|
-
if [[ "$link_target" == /* || "$link_target" =~ (^|/)\\.\\.(/|$) || "$link_target" =~ \\.\\.\\\\ ]]; then
|
|
179
|
-
echo "包含可疑链接目标: $link_target" >&2
|
|
180
|
-
exit 1
|
|
181
|
-
fi
|
|
182
|
-
fi
|
|
183
|
-
done < <(tar -tvzf "$archive")
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
sha256_check() {
|
|
187
|
-
local checksum_file="$1"
|
|
188
|
-
local expected actual file
|
|
189
|
-
expected="$(awk '{print $1}' "$checksum_file")"
|
|
190
|
-
file="$(basename "$(awk '{print $2}' "$checksum_file")")"
|
|
191
|
-
if command -v sha256sum >/dev/null 2>&1; then
|
|
192
|
-
actual="$(sha256sum "$file" | awk '{print $1}')"
|
|
193
|
-
else
|
|
194
|
-
actual="$(shasum -a 256 "$file" | awk '{print $1}')"
|
|
195
|
-
fi
|
|
196
|
-
[[ "$expected" == "$actual" ]]
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
retry_command() {
|
|
200
|
-
local command="$1"
|
|
201
|
-
local label="$2"
|
|
202
|
-
local started_at elapsed
|
|
203
|
-
if [[ -z "$command" ]]; then return 0; fi
|
|
204
|
-
started_at="$(date +%s)"
|
|
205
|
-
until run_command_at "$CURRENT_LINK" "$command"; do
|
|
206
|
-
elapsed=$(( $(date +%s) - started_at ))
|
|
207
|
-
if [[ "$elapsed" -ge "$VERIFY_MAX_WAIT_SECONDS" ]]; then
|
|
208
|
-
echo "$label failed within $VERIFY_MAX_WAIT_SECONDS seconds" >&2
|
|
209
|
-
return 1
|
|
210
|
-
fi
|
|
211
|
-
sleep "$VERIFY_RETRY_DELAY_SECONDS"
|
|
212
|
-
done
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
CURRENT_PHASE="lock"
|
|
216
|
-
echo "DX_REMOTE_PHASE=lock"
|
|
217
|
-
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$UPLOADS_DIR"
|
|
218
|
-
validate_path_within_base "$APP_ROOT" "$ARCHIVE"
|
|
219
|
-
validate_path_within_base "$APP_ROOT" "$RELEASE_DIR"
|
|
220
|
-
PREVIOUS_CURRENT_TARGET="$(readlink -f "$CURRENT_LINK" 2>/dev/null || true)"
|
|
221
|
-
if command -v flock >/dev/null 2>&1; then
|
|
222
|
-
exec 9>"$LOCK_FILE"
|
|
223
|
-
flock -n 9
|
|
224
|
-
else
|
|
225
|
-
mkdir "$LOCK_DIR"
|
|
226
|
-
fi
|
|
227
|
-
|
|
228
|
-
CURRENT_PHASE="extract"
|
|
229
|
-
echo "DX_REMOTE_PHASE=extract"
|
|
230
|
-
validate_archive_entries "$ARCHIVE"
|
|
231
|
-
BUNDLE_TEMP_DIR="$(mktemp -d "$APP_ROOT/.bundle-extract.XXXXXX")"
|
|
232
|
-
tar -xzf "$ARCHIVE" -C "$BUNDLE_TEMP_DIR"
|
|
233
|
-
INNER_ARCHIVE="$BUNDLE_TEMP_DIR/$INNER_ARCHIVE_NAME"
|
|
234
|
-
CHECKSUM_FILE="$BUNDLE_TEMP_DIR/$CHECKSUM_NAME"
|
|
235
|
-
if [[ ! -f "$INNER_ARCHIVE" || ! -f "$CHECKSUM_FILE" ]]; then
|
|
236
|
-
echo "制品包缺少 $INNER_ARCHIVE_NAME 或 $CHECKSUM_NAME" >&2
|
|
237
|
-
exit 1
|
|
238
|
-
fi
|
|
239
|
-
(cd "$BUNDLE_TEMP_DIR" && sha256_check "$CHECKSUM_NAME")
|
|
240
|
-
validate_archive_entries "$INNER_ARCHIVE"
|
|
241
|
-
rm -rf "$RELEASE_DIR"
|
|
242
|
-
mkdir -p "$RELEASE_DIR"
|
|
243
|
-
tar -xzf "$INNER_ARCHIVE" -C "$RELEASE_DIR" --strip-components=1
|
|
244
|
-
|
|
245
|
-
CURRENT_PHASE="install"
|
|
246
|
-
echo "DX_REMOTE_PHASE=install"
|
|
247
|
-
run_command_at "$RELEASE_DIR" "$INSTALL_COMMAND"
|
|
248
|
-
|
|
249
|
-
CURRENT_PHASE="switch-current"
|
|
250
|
-
echo "DX_REMOTE_PHASE=switch-current"
|
|
251
|
-
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
|
|
252
|
-
CURRENT_SWITCHED=1
|
|
253
|
-
|
|
254
|
-
CURRENT_PHASE="startup"
|
|
255
|
-
echo "DX_REMOTE_PHASE=startup"
|
|
256
|
-
if ! run_command_at "$CURRENT_LINK" "$START_COMMAND"; then
|
|
257
|
-
attempt_rollback
|
|
258
|
-
emit_result false "startup" "startup command failed"
|
|
259
|
-
exit 1
|
|
260
|
-
fi
|
|
261
|
-
|
|
262
|
-
CURRENT_PHASE="verify"
|
|
263
|
-
echo "DX_REMOTE_PHASE=verify"
|
|
264
|
-
current_release="$(readlink -f "$CURRENT_LINK")"
|
|
265
|
-
expected_release="$(readlink -f "$RELEASE_DIR")"
|
|
266
|
-
if [[ -z "$current_release" || "$current_release" != "$expected_release" ]]; then
|
|
267
|
-
echo "current 软链接未指向本次 release: expected=$expected_release actual=\${current_release:-<empty>}" >&2
|
|
268
|
-
fail_after_switch "verify" "current symlink verification failed"
|
|
269
|
-
fi
|
|
270
|
-
|
|
271
|
-
retry_command "$VERIFY_COMMAND" "verify command"
|
|
272
|
-
if [[ -n "$HEALTHCHECK_URL" ]]; then
|
|
273
|
-
command -v curl >/dev/null 2>&1
|
|
274
|
-
healthcheck_started_at="$(date +%s)"
|
|
275
|
-
until curl -fsS --max-time "$HEALTHCHECK_TIMEOUT_SECONDS" "$HEALTHCHECK_URL" >/dev/null; do
|
|
276
|
-
healthcheck_elapsed_seconds=$(( $(date +%s) - healthcheck_started_at ))
|
|
277
|
-
if [[ "$healthcheck_elapsed_seconds" -ge "$VERIFY_MAX_WAIT_SECONDS" ]]; then
|
|
278
|
-
echo "health check failed within $VERIFY_MAX_WAIT_SECONDS seconds: $HEALTHCHECK_URL" >&2
|
|
279
|
-
fail_after_switch "verify" "health check failed"
|
|
280
|
-
fi
|
|
281
|
-
sleep "$VERIFY_RETRY_DELAY_SECONDS"
|
|
282
|
-
done
|
|
283
|
-
fi
|
|
284
|
-
|
|
285
|
-
CURRENT_PHASE="cleanup"
|
|
286
|
-
echo "DX_REMOTE_PHASE=cleanup"
|
|
287
|
-
release_count=0
|
|
288
|
-
shopt -s nullglob
|
|
289
|
-
release_dirs=("$RELEASES_DIR"/*)
|
|
290
|
-
shopt -u nullglob
|
|
291
|
-
while IFS= read -r old_release; do
|
|
292
|
-
release_count=$((release_count + 1))
|
|
293
|
-
if [[ "$release_count" -gt "$KEEP_RELEASES" ]]; then rm -rf "$old_release"; fi
|
|
294
|
-
done < <(
|
|
295
|
-
if [[ "\${#release_dirs[@]}" -gt 0 ]]; then ls -1dt "\${release_dirs[@]}"; fi
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
summary_json="$(build_summary_json "$current_release")"
|
|
299
|
-
emit_result true "cleanup" "ok" "$summary_json"
|
|
300
|
-
`
|
|
301
|
-
}
|