@take-out/scripts 0.0.79 ā 0.0.81
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/package.json +4 -4
- package/src/helpers/github-tail.ts +249 -0
- package/src/helpers/multipass.ts +174 -0
- package/src/helpers/parse-env-file.ts +71 -0
- package/src/helpers/process-compose-env.ts +44 -0
- package/src/helpers/ssh.ts +23 -0
- package/src/helpers/uncloud-deploy.ts +140 -0
- package/src/helpers/uncloud.ts +187 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@take-out/scripts",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.81",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/run.ts",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@take-out/helpers": "0.0.
|
|
31
|
+
"@take-out/helpers": "0.0.79"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"vxrn": "
|
|
34
|
+
"vxrn": "^1.4.15"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
37
37
|
"vxrn": {
|
|
@@ -39,6 +39,6 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"vxrn": "1.4.
|
|
42
|
+
"vxrn": "1.4.15"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tail github actions ci logs for the latest run
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync, spawn } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
export interface GithubTailOptions {
|
|
8
|
+
/** workflow name to look for (default: 'CI') */
|
|
9
|
+
workflowName?: string
|
|
10
|
+
/** job name to tail (default: 'build-test-and-deploy') */
|
|
11
|
+
jobName?: string
|
|
12
|
+
/** only show failed step logs */
|
|
13
|
+
showOnlyFailed?: boolean
|
|
14
|
+
/** force watch mode even for completed jobs */
|
|
15
|
+
forceWatch?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getFailedStepLogs(jobId: string, stepName: string): string {
|
|
19
|
+
try {
|
|
20
|
+
const logs = execSync(
|
|
21
|
+
`gh run view --job ${jobId} --log 2>/dev/null | grep -A 100 -B 20 "${stepName}"`,
|
|
22
|
+
{
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
shell: '/bin/bash',
|
|
25
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
return logs
|
|
29
|
+
} catch {
|
|
30
|
+
try {
|
|
31
|
+
const logs = execSync(`gh run view --job ${jobId} --log-failed 2>/dev/null`, {
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
34
|
+
})
|
|
35
|
+
return logs
|
|
36
|
+
} catch {
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function watchJob(
|
|
43
|
+
runId: string,
|
|
44
|
+
jobId: string,
|
|
45
|
+
jobName: string,
|
|
46
|
+
showOnlyFailed: boolean
|
|
47
|
+
) {
|
|
48
|
+
try {
|
|
49
|
+
const jobsJson = execSync(`gh run view ${runId} --json jobs`, {
|
|
50
|
+
encoding: 'utf-8',
|
|
51
|
+
})
|
|
52
|
+
const { jobs } = JSON.parse(jobsJson)
|
|
53
|
+
const job = jobs.find((j: any) => j.name === jobName)
|
|
54
|
+
|
|
55
|
+
if (job && job.status === 'completed') {
|
|
56
|
+
console.info(`\nJob already completed with conclusion: ${job.conclusion}\n`)
|
|
57
|
+
|
|
58
|
+
if (job.conclusion === 'failure') {
|
|
59
|
+
const failedSteps =
|
|
60
|
+
job.steps?.filter((s: any) => s.conclusion === 'failure') || []
|
|
61
|
+
if (failedSteps.length > 0) {
|
|
62
|
+
console.info(`ā Failed step: ${failedSteps[0].name}\n`)
|
|
63
|
+
console.info('š Showing failure details...\n')
|
|
64
|
+
console.info('ā'.repeat(80))
|
|
65
|
+
|
|
66
|
+
const failureLogs = getFailedStepLogs(jobId, failedSteps[0].name)
|
|
67
|
+
if (failureLogs) {
|
|
68
|
+
console.info(failureLogs)
|
|
69
|
+
} else {
|
|
70
|
+
execSync(`gh run view --job ${jobId} --log-failed`, {
|
|
71
|
+
stdio: 'inherit',
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
execSync(`gh run view --job ${jobId} --log-failed`, {
|
|
76
|
+
stdio: 'inherit',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
if (!showOnlyFailed) {
|
|
81
|
+
execSync(`gh run view --job ${jobId} --log`, {
|
|
82
|
+
stdio: 'inherit',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
process.exit(job.conclusion === 'success' ? 0 : 1)
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// continue to watch mode
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.info('\nš” Watching CI logs...\n')
|
|
94
|
+
console.info('ā'.repeat(80))
|
|
95
|
+
|
|
96
|
+
const watchProcess = spawn('gh', ['run', 'watch', runId, '--exit-status'], {
|
|
97
|
+
stdio: 'inherit',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
watchProcess.on('exit', async (code) => {
|
|
101
|
+
if (code !== 0) {
|
|
102
|
+
console.info('\nā CI failed! Getting detailed logs...\n')
|
|
103
|
+
console.info('ā'.repeat(80))
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const jobsJson = execSync(`gh run view ${runId} --json jobs`, {
|
|
107
|
+
encoding: 'utf-8',
|
|
108
|
+
})
|
|
109
|
+
const { jobs } = JSON.parse(jobsJson)
|
|
110
|
+
const job = jobs.find((j: any) => j.name === jobName)
|
|
111
|
+
|
|
112
|
+
if (job) {
|
|
113
|
+
const failedSteps =
|
|
114
|
+
job.steps?.filter((s: any) => s.conclusion === 'failure') || []
|
|
115
|
+
if (failedSteps.length > 0) {
|
|
116
|
+
console.info(
|
|
117
|
+
`Failed steps: ${failedSteps.map((s: any) => s.name).join(', ')}\n`
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
for (const step of failedSteps) {
|
|
121
|
+
console.info(`\nāāā Logs for failed step: ${step.name} āāā\n`)
|
|
122
|
+
const stepLogs = getFailedStepLogs(jobId, step.name)
|
|
123
|
+
if (stepLogs) {
|
|
124
|
+
console.info(stepLogs)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.info('\nāāā Summary of all failed steps āāā\n')
|
|
131
|
+
execSync(`gh run view --job ${jobId} --log-failed`, {
|
|
132
|
+
stdio: 'inherit',
|
|
133
|
+
})
|
|
134
|
+
} catch {
|
|
135
|
+
console.error('Could not retrieve detailed failure logs')
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
process.exit(code || 0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
process.on('SIGINT', () => {
|
|
143
|
+
watchProcess.kill()
|
|
144
|
+
process.exit(0)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function githubTail(options: GithubTailOptions = {}) {
|
|
149
|
+
const {
|
|
150
|
+
workflowName = 'CI',
|
|
151
|
+
jobName = 'build-test-and-deploy',
|
|
152
|
+
showOnlyFailed = false,
|
|
153
|
+
forceWatch = false,
|
|
154
|
+
} = options
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
let latestCommit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim()
|
|
158
|
+
let shortCommit = latestCommit.substring(0, 7)
|
|
159
|
+
|
|
160
|
+
console.info(`Fetching CI logs for commit: ${shortCommit}`)
|
|
161
|
+
|
|
162
|
+
let runsJson = execSync(
|
|
163
|
+
`gh run list --commit ${latestCommit} --json databaseId,name,status,conclusion --limit 5`,
|
|
164
|
+
{ encoding: 'utf-8' }
|
|
165
|
+
)
|
|
166
|
+
let runs = JSON.parse(runsJson)
|
|
167
|
+
let ciRun = runs.find((r: any) => r.name === workflowName)
|
|
168
|
+
|
|
169
|
+
if (!ciRun) {
|
|
170
|
+
console.info('No CI run found for local commit, checking remote...')
|
|
171
|
+
|
|
172
|
+
latestCommit = execSync('git rev-parse origin/main', { encoding: 'utf-8' }).trim()
|
|
173
|
+
shortCommit = latestCommit.substring(0, 7)
|
|
174
|
+
|
|
175
|
+
console.info(`Fetching CI logs for remote commit: ${shortCommit}`)
|
|
176
|
+
|
|
177
|
+
runsJson = execSync(
|
|
178
|
+
`gh run list --commit ${latestCommit} --json databaseId,name,status,conclusion --limit 5`,
|
|
179
|
+
{ encoding: 'utf-8' }
|
|
180
|
+
)
|
|
181
|
+
runs = JSON.parse(runsJson)
|
|
182
|
+
ciRun = runs.find((r: any) => r.name === workflowName)
|
|
183
|
+
|
|
184
|
+
if (!ciRun) {
|
|
185
|
+
console.info('No CI run for remote HEAD, getting latest CI run...')
|
|
186
|
+
runsJson = execSync(
|
|
187
|
+
`gh run list --workflow=${workflowName} --json databaseId,name,status,conclusion,headSha --limit 1`,
|
|
188
|
+
{ encoding: 'utf-8' }
|
|
189
|
+
)
|
|
190
|
+
runs = JSON.parse(runsJson)
|
|
191
|
+
ciRun = runs[0]
|
|
192
|
+
|
|
193
|
+
if (ciRun) {
|
|
194
|
+
shortCommit = ciRun.headSha?.substring(0, 7) || 'unknown'
|
|
195
|
+
console.info(`Using latest CI run for commit: ${shortCommit}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!ciRun) {
|
|
201
|
+
console.error('No CI runs found')
|
|
202
|
+
process.exit(1)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.info(`CI Run: ${ciRun.status} (${ciRun.conclusion || 'in progress'})`)
|
|
206
|
+
|
|
207
|
+
const jobsJson = execSync(`gh run view ${ciRun.databaseId} --json jobs`, {
|
|
208
|
+
encoding: 'utf-8',
|
|
209
|
+
})
|
|
210
|
+
const { jobs } = JSON.parse(jobsJson)
|
|
211
|
+
const buildJob = jobs.find((j: any) => j.name === jobName)
|
|
212
|
+
|
|
213
|
+
if (!buildJob) {
|
|
214
|
+
console.error(`No ${jobName} job found`)
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.info(
|
|
219
|
+
`\nJob: ${buildJob.name} - ${buildJob.status} (${buildJob.conclusion || 'in progress'})`
|
|
220
|
+
)
|
|
221
|
+
console.info('ā'.repeat(80))
|
|
222
|
+
|
|
223
|
+
if (buildJob.status === 'completed' && !forceWatch) {
|
|
224
|
+
const logFlag =
|
|
225
|
+
showOnlyFailed || buildJob.conclusion === 'failure' ? '--log-failed' : '--log'
|
|
226
|
+
|
|
227
|
+
if (buildJob.conclusion === 'failure') {
|
|
228
|
+
console.info('\nā Job failed! Showing failed steps...\n')
|
|
229
|
+
} else if (showOnlyFailed) {
|
|
230
|
+
console.info('\nš Showing only failed steps...\n')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
execSync(`gh run view --job ${buildJob.databaseId} ${logFlag}`, {
|
|
234
|
+
stdio: 'inherit',
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
process.exit(buildJob.conclusion === 'success' ? 0 : 1)
|
|
238
|
+
} else {
|
|
239
|
+
await watchJob(ciRun.databaseId, buildJob.databaseId, jobName, showOnlyFailed)
|
|
240
|
+
}
|
|
241
|
+
} catch (error: any) {
|
|
242
|
+
if (error.status === 1 && error.stdout?.includes('No jobs in progress')) {
|
|
243
|
+
console.info('Job completed. Run again to see full logs.')
|
|
244
|
+
} else {
|
|
245
|
+
console.error('Error fetching CI logs:', error.message)
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { sleep } from '@take-out/helpers'
|
|
4
|
+
|
|
5
|
+
import { run } from './run'
|
|
6
|
+
|
|
7
|
+
export interface MultipassConfig {
|
|
8
|
+
vmName: string
|
|
9
|
+
sshKeyPath: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createMultipassConfig(
|
|
13
|
+
name: string,
|
|
14
|
+
sshKeyPath?: string
|
|
15
|
+
): MultipassConfig {
|
|
16
|
+
return {
|
|
17
|
+
vmName: name,
|
|
18
|
+
sshKeyPath: sshKeyPath || `${process.env.HOME}/.ssh/${name}`,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function checkMultipass(): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
await run('multipass --version', { silent: true })
|
|
25
|
+
console.info('ā
multipass installed')
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error('multipass not found - install: brew install multipass')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getVMIP(config: MultipassConfig): Promise<string | null> {
|
|
32
|
+
try {
|
|
33
|
+
const { stdout } = await run(`multipass info ${config.vmName} --format json`, {
|
|
34
|
+
silent: true,
|
|
35
|
+
captureOutput: true,
|
|
36
|
+
})
|
|
37
|
+
const data = JSON.parse(stdout)
|
|
38
|
+
return data.info[config.vmName]?.ipv4?.[0] || null
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function vmExists(config: MultipassConfig): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await run('multipass list', { silent: true, captureOutput: true })
|
|
47
|
+
return stdout.includes(config.vmName)
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function createVM(config: MultipassConfig): Promise<void> {
|
|
54
|
+
console.info('\nš„ļø creating multipass vm...\n')
|
|
55
|
+
|
|
56
|
+
if (await vmExists(config)) {
|
|
57
|
+
console.info(`ā
vm '${config.vmName}' already exists`)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cloudInit = `#cloud-config
|
|
62
|
+
package_update: true
|
|
63
|
+
package_upgrade: true
|
|
64
|
+
packages:
|
|
65
|
+
- curl
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
const cloudInitPath = `${process.cwd()}/.cloud-init.yml`
|
|
69
|
+
writeFileSync(cloudInitPath, cloudInit)
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await run(
|
|
73
|
+
`multipass launch --name ${config.vmName} --cpus 4 --memory 4G --disk 20G --cloud-init ${cloudInitPath}`
|
|
74
|
+
)
|
|
75
|
+
} finally {
|
|
76
|
+
await run(`rm -f ${cloudInitPath}`, { silent: true })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.info('\nā³ waiting for vm to be ready...')
|
|
80
|
+
|
|
81
|
+
let attempts = 0
|
|
82
|
+
while (attempts < 30) {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await run(`multipass info ${config.vmName} --format json`, {
|
|
85
|
+
silent: true,
|
|
86
|
+
captureOutput: true,
|
|
87
|
+
})
|
|
88
|
+
const data = JSON.parse(stdout)
|
|
89
|
+
if (data.info[config.vmName]?.state === 'Running') {
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// vm not ready yet
|
|
94
|
+
}
|
|
95
|
+
await sleep(2000)
|
|
96
|
+
attempts++
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// wait for cloud-init
|
|
100
|
+
console.info('waiting for cloud-init to complete...')
|
|
101
|
+
await sleep(10000)
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await run(`multipass exec ${config.vmName} -- timeout 300 cloud-init status --wait`)
|
|
105
|
+
console.info('ā
cloud-init complete')
|
|
106
|
+
} catch {
|
|
107
|
+
console.info('ā ļø cloud-init status check timed out, continuing...')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.info('ā
vm ready')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function setupVMSSH(config: MultipassConfig): Promise<void> {
|
|
114
|
+
console.info('\nš setting up ssh...\n')
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await run(`test -f ${config.sshKeyPath}`, { silent: true })
|
|
118
|
+
console.info('ā
ssh key exists')
|
|
119
|
+
} catch {
|
|
120
|
+
console.info('generating ssh key...')
|
|
121
|
+
await run(`ssh-keygen -t rsa -b 4096 -f ${config.sshKeyPath} -N ""`, { silent: true })
|
|
122
|
+
console.info('ā
ssh key generated')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { stdout: pubKey } = await run(`cat ${config.sshKeyPath}.pub`, {
|
|
126
|
+
silent: true,
|
|
127
|
+
captureOutput: true,
|
|
128
|
+
})
|
|
129
|
+
await run(
|
|
130
|
+
`multipass exec ${config.vmName} -- sh -c 'mkdir -p ~/.ssh && echo "${pubKey.trim()}" >> ~/.ssh/authorized_keys'`
|
|
131
|
+
)
|
|
132
|
+
console.info('ā
ssh key configured')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function setupPortForward(
|
|
136
|
+
config: MultipassConfig,
|
|
137
|
+
ports: { local: number; containerName: string; containerPort: number }[]
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
console.info('\nš setting up port forwarding...\n')
|
|
140
|
+
|
|
141
|
+
const ip = await getVMIP(config)
|
|
142
|
+
if (!ip) {
|
|
143
|
+
console.error('ā could not get vm ip')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const forwards: string[] = []
|
|
148
|
+
for (const { local, containerName, containerPort } of ports) {
|
|
149
|
+
const { stdout: containerIP } = await run(
|
|
150
|
+
`multipass exec ${config.vmName} -- sudo docker inspect $(multipass exec ${config.vmName} -- sudo docker ps -q -f name=${containerName}) -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'`,
|
|
151
|
+
{ silent: true, captureOutput: true }
|
|
152
|
+
)
|
|
153
|
+
console.info(`${containerName} container: ${containerIP.trim()}:${containerPort}`)
|
|
154
|
+
forwards.push(`-L ${local}:${containerIP.trim()}:${containerPort}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// kill any existing port forwards
|
|
158
|
+
try {
|
|
159
|
+
await run(`pkill -f "ssh.*${config.vmName}"`, { silent: true })
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore if none found
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const { local, containerName, containerPort } of ports) {
|
|
165
|
+
console.info(`forwarding localhost:${local} -> ${containerName}:${containerPort}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await run(
|
|
169
|
+
`ssh -i ${config.sshKeyPath} -o StrictHostKeyChecking=no -f -N ${forwards.join(' ')} ubuntu@${ip}`,
|
|
170
|
+
{ silent: true }
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
console.info('ā
port forwarding active')
|
|
174
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse a .env file, handling multi-line quoted values (PEM keys etc)
|
|
3
|
+
*/
|
|
4
|
+
export function parseEnvFile(
|
|
5
|
+
content: string,
|
|
6
|
+
options?: { allowedKeys?: string[] }
|
|
7
|
+
): Record<string, string> {
|
|
8
|
+
const result: Record<string, string> = {}
|
|
9
|
+
const lines = content.split('\n')
|
|
10
|
+
let currentKey: string | null = null
|
|
11
|
+
let currentValue: string[] = []
|
|
12
|
+
let inMultiline = false
|
|
13
|
+
|
|
14
|
+
const save = () => {
|
|
15
|
+
if (!currentKey) return
|
|
16
|
+
if (options?.allowedKeys && !options.allowedKeys.includes(currentKey)) {
|
|
17
|
+
currentKey = null
|
|
18
|
+
currentValue = []
|
|
19
|
+
inMultiline = false
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
let value = currentValue.join('\n')
|
|
23
|
+
// remove surrounding quotes
|
|
24
|
+
if (
|
|
25
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
26
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
27
|
+
) {
|
|
28
|
+
value = value.slice(1, -1)
|
|
29
|
+
}
|
|
30
|
+
value = value.replace(/\\"/g, '"')
|
|
31
|
+
result[currentKey] = value
|
|
32
|
+
currentKey = null
|
|
33
|
+
currentValue = []
|
|
34
|
+
inMultiline = false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
if (inMultiline) {
|
|
39
|
+
currentValue.push(line)
|
|
40
|
+
if (line.trim().endsWith('"') || line.trim().endsWith("'")) {
|
|
41
|
+
save()
|
|
42
|
+
}
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const trimmed = line.trim()
|
|
47
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
48
|
+
|
|
49
|
+
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/)
|
|
50
|
+
if (match && match[1]) {
|
|
51
|
+
if (currentKey) save()
|
|
52
|
+
|
|
53
|
+
currentKey = match[1]
|
|
54
|
+
const valueStart = match[2] || ''
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
(valueStart.startsWith('"') && !valueStart.endsWith('"')) ||
|
|
58
|
+
(valueStart.startsWith("'") && !valueStart.endsWith("'"))
|
|
59
|
+
) {
|
|
60
|
+
inMultiline = true
|
|
61
|
+
currentValue = [valueStart]
|
|
62
|
+
} else {
|
|
63
|
+
currentValue = [valueStart]
|
|
64
|
+
save()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (currentKey) save()
|
|
69
|
+
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
// collapse multi-line values (like PEM keys) to single line for yaml compatibility
|
|
4
|
+
function yamlSafe(value: string): string {
|
|
5
|
+
if (value.includes('\n')) {
|
|
6
|
+
return value.replace(/\n/g, '\\n')
|
|
7
|
+
}
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* processes docker-compose file, replacing ${VAR:-default} with actual env values.
|
|
13
|
+
* handles multi-line values (PEM keys etc) by escaping newlines for yaml.
|
|
14
|
+
*/
|
|
15
|
+
export function processComposeEnv(
|
|
16
|
+
composeFile: string,
|
|
17
|
+
outputFile: string,
|
|
18
|
+
envVars: Record<string, string | undefined>
|
|
19
|
+
): void {
|
|
20
|
+
let content = readFileSync(composeFile, 'utf-8')
|
|
21
|
+
|
|
22
|
+
// replace all ${VAR:-default} patterns with actual env values
|
|
23
|
+
content = content.replace(
|
|
24
|
+
/\$\{([A-Z_][A-Z0-9_]*):-([^}]*)\}/g,
|
|
25
|
+
(_match, varName, defaultValue) => {
|
|
26
|
+
const value = envVars[varName]
|
|
27
|
+
if (value) {
|
|
28
|
+
console.info(
|
|
29
|
+
` ${varName}: ${value.slice(0, 50)}${value.length > 50 ? '...' : ''}`
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return value ? yamlSafe(value) : defaultValue
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// replace standalone ${VAR} patterns
|
|
37
|
+
content = content.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_match, varName) => {
|
|
38
|
+
const value = envVars[varName]
|
|
39
|
+
return value ? yamlSafe(value) : _match
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
writeFileSync(outputFile, content)
|
|
43
|
+
console.info(`ā
processed compose file: ${outputFile}`)
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { run } from './run'
|
|
2
|
+
|
|
3
|
+
export async function checkSSHKey(sshKeyPath: string): Promise<void> {
|
|
4
|
+
try {
|
|
5
|
+
await run(`test -f ${sshKeyPath}`, { silent: true })
|
|
6
|
+
console.info('ā
ssh key exists')
|
|
7
|
+
} catch {
|
|
8
|
+
throw new Error(`ssh key not found: ${sshKeyPath}`)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function testSSHConnection(host: string, sshKey: string): Promise<void> {
|
|
13
|
+
console.info('\nš testing ssh connection...\n')
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await run(
|
|
17
|
+
`ssh -i ${sshKey} -o StrictHostKeyChecking=no -o ConnectTimeout=5 ${host} "echo 'SSH connection successful'"`
|
|
18
|
+
)
|
|
19
|
+
console.info('ā
ssh connection verified')
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(`cannot connect to ${host} - check ssh key: ${sshKey}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generic uncloud deployment helpers for ci/cd
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { time } from '@take-out/helpers'
|
|
11
|
+
|
|
12
|
+
import { run } from './run'
|
|
13
|
+
|
|
14
|
+
export async function checkUncloudConfigured(): Promise<boolean> {
|
|
15
|
+
return Boolean(process.env.DEPLOY_HOST && process.env.DEPLOY_DB)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function verifyDatabaseConfig(): Promise<{
|
|
19
|
+
valid: boolean
|
|
20
|
+
errors: string[]
|
|
21
|
+
}> {
|
|
22
|
+
const errors: string[] = []
|
|
23
|
+
|
|
24
|
+
const deployDb = process.env.DEPLOY_DB
|
|
25
|
+
const upstreamDb = process.env.ZERO_UPSTREAM_DB
|
|
26
|
+
const cvrDb = process.env.ZERO_CVR_DB
|
|
27
|
+
const changeDb = process.env.ZERO_CHANGE_DB
|
|
28
|
+
|
|
29
|
+
if (!deployDb) errors.push('DEPLOY_DB is not set')
|
|
30
|
+
if (!upstreamDb) errors.push('ZERO_UPSTREAM_DB is not set')
|
|
31
|
+
if (!cvrDb) errors.push('ZERO_CVR_DB is not set')
|
|
32
|
+
if (!changeDb) errors.push('ZERO_CHANGE_DB is not set')
|
|
33
|
+
|
|
34
|
+
if (errors.length > 0) {
|
|
35
|
+
return { valid: false, errors }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const getHost = (url: string): string | null => {
|
|
39
|
+
try {
|
|
40
|
+
const match = url.match(/@([^:/]+)/)
|
|
41
|
+
return match?.[1] || null
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const deployHost = getHost(deployDb!)
|
|
48
|
+
const upstreamHost = getHost(upstreamDb!)
|
|
49
|
+
const cvrHost = getHost(cvrDb!)
|
|
50
|
+
const changeHost = getHost(changeDb!)
|
|
51
|
+
|
|
52
|
+
if (deployHost && upstreamHost && deployHost !== upstreamHost) {
|
|
53
|
+
errors.push(
|
|
54
|
+
`ZERO_UPSTREAM_DB host (${upstreamHost}) does not match DEPLOY_DB host (${deployHost})`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
if (deployHost && cvrHost && deployHost !== cvrHost) {
|
|
58
|
+
errors.push(
|
|
59
|
+
`ZERO_CVR_DB host (${cvrHost}) does not match DEPLOY_DB host (${deployHost})`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
if (deployHost && changeHost && deployHost !== changeHost) {
|
|
63
|
+
errors.push(
|
|
64
|
+
`ZERO_CHANGE_DB host (${changeHost}) does not match DEPLOY_DB host (${deployHost})`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { valid: errors.length === 0, errors }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function installUncloudCLI(version = '0.16.0') {
|
|
72
|
+
console.info('š§ checking uncloud cli...')
|
|
73
|
+
try {
|
|
74
|
+
await run('uc --version', { silent: true, timeout: time.ms.seconds(5) })
|
|
75
|
+
console.info(' uncloud cli already installed')
|
|
76
|
+
} catch {
|
|
77
|
+
console.info(` installing uncloud cli v${version}...`)
|
|
78
|
+
await run(
|
|
79
|
+
`curl -fsS https://get.uncloud.run/install.sh | sh -s -- --version ${version}`,
|
|
80
|
+
{ timeout: time.ms.seconds(30) }
|
|
81
|
+
)
|
|
82
|
+
console.info(' ā uncloud cli installed')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function setupSSHKey() {
|
|
87
|
+
if (!process.env.DEPLOY_SSH_KEY) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sshKeyValue = process.env.DEPLOY_SSH_KEY
|
|
92
|
+
|
|
93
|
+
// check if it's a path to an existing file (local usage) or key content (CI usage)
|
|
94
|
+
if (existsSync(sshKeyValue)) {
|
|
95
|
+
console.info(` using ssh key from: ${sshKeyValue}`)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// CI usage - DEPLOY_SSH_KEY contains the actual key content
|
|
100
|
+
console.info('š setting up ssh key from environment...')
|
|
101
|
+
const sshDir = join(homedir(), '.ssh')
|
|
102
|
+
const keyPath = join(sshDir, 'uncloud_deploy')
|
|
103
|
+
|
|
104
|
+
if (!existsSync(sshDir)) {
|
|
105
|
+
await mkdir(sshDir, { recursive: true })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// decode base64-encoded keys (github secrets often store keys as base64)
|
|
109
|
+
let keyContent = sshKeyValue
|
|
110
|
+
if (
|
|
111
|
+
!sshKeyValue.includes('-----BEGIN') &&
|
|
112
|
+
/^[A-Za-z0-9+/=\s]+$/.test(sshKeyValue.trim())
|
|
113
|
+
) {
|
|
114
|
+
console.info(' detected base64-encoded key, decoding...')
|
|
115
|
+
keyContent = Buffer.from(sshKeyValue.trim(), 'base64').toString('utf-8')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ensure trailing newline (github secrets can strip it)
|
|
119
|
+
if (!keyContent.endsWith('\n')) {
|
|
120
|
+
keyContent += '\n'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await writeFile(keyPath, keyContent, { mode: 0o600 })
|
|
124
|
+
|
|
125
|
+
// add host to known_hosts
|
|
126
|
+
if (process.env.DEPLOY_HOST) {
|
|
127
|
+
try {
|
|
128
|
+
await run(
|
|
129
|
+
`ssh-keyscan -H ${process.env.DEPLOY_HOST} >> ${join(sshDir, 'known_hosts')}`,
|
|
130
|
+
{ silent: true, timeout: time.ms.seconds(10) }
|
|
131
|
+
)
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore - ssh will prompt if needed
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// override env var to point to the file we created
|
|
138
|
+
process.env.DEPLOY_SSH_KEY = keyPath
|
|
139
|
+
console.info(` ssh key written to ${keyPath}`)
|
|
140
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { run } from './run'
|
|
6
|
+
|
|
7
|
+
export interface UncloudConfig {
|
|
8
|
+
context?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ucCmd(config?: UncloudConfig): string {
|
|
12
|
+
return config?.context ? `uc -c ${config.context}` : 'uc'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function checkUncloudCLI(): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await run('uc --version', { silent: true, captureOutput: true })
|
|
18
|
+
console.info(`ā
uncloud cli installed (${stdout.trim()})`)
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`uncloud cli not found - install: curl -fsS https://get.uncloud.run/install.sh | sh`
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureUncloudContext(host: string, sshKey: string, contextName: string): void {
|
|
27
|
+
const configDir = join(homedir(), '.config', 'uncloud')
|
|
28
|
+
const configPath = join(configDir, 'config.yaml')
|
|
29
|
+
|
|
30
|
+
if (!existsSync(configDir)) {
|
|
31
|
+
mkdirSync(configDir, { recursive: true })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// check if config already has context pointing to correct host
|
|
35
|
+
if (existsSync(configPath)) {
|
|
36
|
+
const existing = readFileSync(configPath, 'utf-8')
|
|
37
|
+
if (existing.includes(`${contextName}:`) && existing.includes(host.split('@')[1])) {
|
|
38
|
+
console.info(`ā
uncloud config already has ${contextName} context for ${host}`)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// only create config if it doesn't exist - don't overwrite existing config
|
|
44
|
+
// which may have other contexts
|
|
45
|
+
if (!existsSync(configPath)) {
|
|
46
|
+
const config = `current_context: ${contextName}
|
|
47
|
+
contexts:
|
|
48
|
+
${contextName}:
|
|
49
|
+
connections:
|
|
50
|
+
- ssh: ${host}
|
|
51
|
+
ssh_key_file: ${sshKey}
|
|
52
|
+
`
|
|
53
|
+
writeFileSync(configPath, config)
|
|
54
|
+
console.info(`ā
created uncloud config at ${configPath}`)
|
|
55
|
+
} else {
|
|
56
|
+
console.info(`ā
using existing uncloud config (context: ${contextName})`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function initUncloud(
|
|
61
|
+
host: string,
|
|
62
|
+
sshKey: string,
|
|
63
|
+
options: { noDNS?: boolean; noCaddy?: boolean; context?: string } = {}
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
console.info('\nš initializing uncloud...\n')
|
|
66
|
+
|
|
67
|
+
const uc = ucCmd(options)
|
|
68
|
+
const contextName = options.context || 'default'
|
|
69
|
+
|
|
70
|
+
// check if we already have local context that works
|
|
71
|
+
try {
|
|
72
|
+
await run(`${uc} ls`, { silent: true })
|
|
73
|
+
console.info('ā
local cluster context exists and connected')
|
|
74
|
+
return
|
|
75
|
+
} catch {
|
|
76
|
+
// no local context - check if server has uncloud running
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sshCmd = `ssh -i ${sshKey} -o StrictHostKeyChecking=no ${host}`
|
|
80
|
+
|
|
81
|
+
// check if server already has uncloud daemon running
|
|
82
|
+
let serverHasUncloud = false
|
|
83
|
+
try {
|
|
84
|
+
await run(`${sshCmd} "systemctl is-active uncloud.service"`, { silent: true })
|
|
85
|
+
serverHasUncloud = true
|
|
86
|
+
console.info('ā
server has uncloud daemon running')
|
|
87
|
+
} catch {
|
|
88
|
+
try {
|
|
89
|
+
await run(`${sshCmd} "test -f /usr/local/bin/uncloudd"`, { silent: true })
|
|
90
|
+
serverHasUncloud = true
|
|
91
|
+
console.info('ā
server has uncloud installed')
|
|
92
|
+
} catch {
|
|
93
|
+
// server doesn't have uncloud - needs fresh init
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (serverHasUncloud) {
|
|
98
|
+
ensureUncloudContext(host, sshKey, contextName)
|
|
99
|
+
console.info(' (skipping init to avoid cluster reset)')
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// fresh server - need to initialize
|
|
104
|
+
console.info('š¦ uncloud not installed on server - initializing...')
|
|
105
|
+
|
|
106
|
+
const flags: string[] = []
|
|
107
|
+
if (options.noDNS) flags.push('--no-dns')
|
|
108
|
+
if (options.noCaddy) flags.push('--no-caddy')
|
|
109
|
+
const flagStr = flags.join(' ')
|
|
110
|
+
|
|
111
|
+
await run(`echo "y" | uc machine init ${host} -i ${sshKey} ${flagStr}`)
|
|
112
|
+
|
|
113
|
+
console.info('ā
uncloud initialized')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function pushImage(
|
|
117
|
+
imageName: string,
|
|
118
|
+
config?: UncloudConfig
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
console.info('\nš¤ pushing image to cluster...\n')
|
|
121
|
+
|
|
122
|
+
await run(`${ucCmd(config)} image push ${imageName}`)
|
|
123
|
+
console.info('ā
image pushed')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function deployStack(
|
|
127
|
+
composeFile: string,
|
|
128
|
+
options?: { profile?: string } & UncloudConfig
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
console.info('\nš¦ deploying stack...\n')
|
|
131
|
+
|
|
132
|
+
const profileFlag = options?.profile ? `--profile ${options.profile}` : ''
|
|
133
|
+
// --recreate ensures containers are recreated even if config unchanged (pulls fresh images)
|
|
134
|
+
await run(`${ucCmd(options)} deploy -f ${composeFile} ${profileFlag} --recreate --yes`)
|
|
135
|
+
|
|
136
|
+
console.info('\nā
deployment complete!')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function showStatus(config?: UncloudConfig): Promise<void> {
|
|
140
|
+
console.info('\nš deployment status:\n')
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await run(`${ucCmd(config)} ls`)
|
|
144
|
+
} catch {
|
|
145
|
+
console.error('could not fetch status')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function showContainers(config?: UncloudConfig): Promise<void> {
|
|
150
|
+
console.info('\nš¦ container status:\n')
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await run(`${ucCmd(config)} ps`)
|
|
154
|
+
} catch {
|
|
155
|
+
console.error('could not fetch container status')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function startService(
|
|
160
|
+
service?: string,
|
|
161
|
+
config?: UncloudConfig
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const target = service || 'all services'
|
|
164
|
+
console.info(`\nā¶ļø starting ${target}...\n`)
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await run(`${ucCmd(config)} start${service ? ` ${service}` : ''}`)
|
|
168
|
+
console.info(`ā
${target} started`)
|
|
169
|
+
} catch {
|
|
170
|
+
throw new Error(`failed to start ${target}`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function stopService(
|
|
175
|
+
service?: string,
|
|
176
|
+
config?: UncloudConfig
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const target = service || 'all services'
|
|
179
|
+
console.info(`\nā¹ļø stopping ${target}...\n`)
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await run(`${ucCmd(config)} stop${service ? ` ${service}` : ''}`)
|
|
183
|
+
console.info(`ā
${target} stopped`)
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error(`failed to stop ${target}`)
|
|
186
|
+
}
|
|
187
|
+
}
|