@take-out/scripts 0.1.17 → 0.1.19
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@take-out/scripts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/run.ts",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^0.8.2",
|
|
32
|
-
"@take-out/helpers": "
|
|
32
|
+
"@take-out/helpers": "0.1.19",
|
|
33
33
|
"picocolors": "^1.1.1"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deployment health check helpers for uncloud deployments
|
|
3
|
+
* shared across repos via @take-out/scripts/helpers/deploy-health
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SSHContext {
|
|
7
|
+
sshOpts: string
|
|
8
|
+
deployUser: string
|
|
9
|
+
deployHost: string
|
|
10
|
+
$: (
|
|
11
|
+
strings: TemplateStringsArray,
|
|
12
|
+
...values: any[]
|
|
13
|
+
) => { quiet: () => Promise<{ stdout: Buffer }> }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ContainerInfo {
|
|
17
|
+
id: string
|
|
18
|
+
status: string
|
|
19
|
+
state: string
|
|
20
|
+
names: string
|
|
21
|
+
isRestarting: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* check for containers stuck in restart loop
|
|
26
|
+
* fails fast if any container is crash-looping
|
|
27
|
+
*/
|
|
28
|
+
export async function checkRestartLoops(ctx: SSHContext): Promise<{
|
|
29
|
+
hasLoop: boolean
|
|
30
|
+
services: string[]
|
|
31
|
+
}> {
|
|
32
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
33
|
+
try {
|
|
34
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
35
|
+
const result =
|
|
36
|
+
await $`${sshCmd.split(' ')} "docker ps -a --format '{{.Names}} {{.Status}}'"`.quiet()
|
|
37
|
+
const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
|
|
38
|
+
|
|
39
|
+
const loopingServices: string[] = []
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.includes('Restarting')) {
|
|
42
|
+
const name = line.split(' ')[0] || ''
|
|
43
|
+
loopingServices.push(name)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { hasLoop: loopingServices.length > 0, services: loopingServices }
|
|
48
|
+
} catch {
|
|
49
|
+
return { hasLoop: false, services: [] }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* get container status for all running containers
|
|
55
|
+
* extracts service name from container names (handles various naming patterns)
|
|
56
|
+
*/
|
|
57
|
+
export async function getContainerStatus(
|
|
58
|
+
ctx: SSHContext,
|
|
59
|
+
options?: { verbose?: boolean }
|
|
60
|
+
): Promise<Map<string, ContainerInfo>> {
|
|
61
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
62
|
+
try {
|
|
63
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
64
|
+
// use -a to see all containers including stopped/restarting
|
|
65
|
+
const result = await $`${sshCmd.split(' ')} "docker ps -a --format json"`.quiet()
|
|
66
|
+
const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
|
|
67
|
+
|
|
68
|
+
const containers = new Map<string, ContainerInfo>()
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
try {
|
|
71
|
+
const container = JSON.parse(line)
|
|
72
|
+
const name = container.Names || ''
|
|
73
|
+
let serviceName: string | null = null
|
|
74
|
+
|
|
75
|
+
// try various naming patterns
|
|
76
|
+
// pattern 1: "projectname-service-1" or "projectname_service_1"
|
|
77
|
+
let match = name.match(/[^_-]+[_-]([^_-]+)[_-]\w+/)
|
|
78
|
+
if (match) {
|
|
79
|
+
serviceName = match[1]
|
|
80
|
+
} else {
|
|
81
|
+
// pattern 2: "service-xxxx" where xxxx is alphanumeric
|
|
82
|
+
match = name.match(/^([^_-]+)[_-]\w+$/)
|
|
83
|
+
if (match) {
|
|
84
|
+
serviceName = match[1]
|
|
85
|
+
} else {
|
|
86
|
+
// pattern 3: just the name before any dash or underscore
|
|
87
|
+
match = name.match(/^([^_-]+)/)
|
|
88
|
+
if (match) {
|
|
89
|
+
serviceName = match[1]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (serviceName) {
|
|
95
|
+
const status = container.Status || ''
|
|
96
|
+
const state = container.State || ''
|
|
97
|
+
const isRestarting = state === 'restarting' || status.includes('Restarting')
|
|
98
|
+
|
|
99
|
+
containers.set(serviceName, {
|
|
100
|
+
id: container.ID,
|
|
101
|
+
status,
|
|
102
|
+
state,
|
|
103
|
+
names: container.Names,
|
|
104
|
+
isRestarting,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// skip invalid json lines
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return containers
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (options?.verbose) {
|
|
115
|
+
console.error('failed to get container status:', error)
|
|
116
|
+
}
|
|
117
|
+
return new Map()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* check health status of a specific container
|
|
123
|
+
* returns 'healthy', 'checking' (mid-probe), or 'unhealthy'
|
|
124
|
+
*/
|
|
125
|
+
export async function checkContainerHealth(
|
|
126
|
+
ctx: SSHContext,
|
|
127
|
+
containerId: string
|
|
128
|
+
): Promise<'healthy' | 'checking' | 'unhealthy'> {
|
|
129
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
130
|
+
try {
|
|
131
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
132
|
+
const healthResult =
|
|
133
|
+
await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}'"`.quiet()
|
|
134
|
+
const healthStatus = healthResult.stdout.toString().trim()
|
|
135
|
+
|
|
136
|
+
if (healthStatus === 'healthy') {
|
|
137
|
+
return 'healthy'
|
|
138
|
+
} else if (healthStatus === 'no-healthcheck') {
|
|
139
|
+
// fall back to checking if running for containers without healthcheck
|
|
140
|
+
const statusResult =
|
|
141
|
+
await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{.State.Status}}'"`.quiet()
|
|
142
|
+
const status = statusResult.stdout.toString().trim()
|
|
143
|
+
return status === 'running' ? 'healthy' : 'unhealthy'
|
|
144
|
+
} else if (healthStatus === 'starting') {
|
|
145
|
+
// mid-healthcheck probe, don't treat as failure yet
|
|
146
|
+
return 'checking'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return 'unhealthy'
|
|
150
|
+
} catch {
|
|
151
|
+
return 'unhealthy'
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* check http health endpoint from inside web container
|
|
157
|
+
*/
|
|
158
|
+
export async function checkHttpHealth(
|
|
159
|
+
ctx: SSHContext,
|
|
160
|
+
options?: { endpoint?: string; containerFilter?: string }
|
|
161
|
+
): Promise<{
|
|
162
|
+
healthy: boolean
|
|
163
|
+
status?: number
|
|
164
|
+
message?: string
|
|
165
|
+
}> {
|
|
166
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
167
|
+
const endpoint = options?.endpoint || 'http://localhost:8081/'
|
|
168
|
+
const containerFilter = options?.containerFilter || 'web'
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
172
|
+
const containersResult =
|
|
173
|
+
await $`${sshCmd.split(' ')} "docker ps --filter 'name=${containerFilter}' --format '{{.Names}}'"`.quiet()
|
|
174
|
+
const webContainer = containersResult.stdout.toString().trim()
|
|
175
|
+
|
|
176
|
+
if (!webContainer) {
|
|
177
|
+
return { healthy: false, message: 'web container not found' }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result =
|
|
181
|
+
await $`${sshCmd.split(' ')} "docker exec ${webContainer} curl -so /dev/null -w '%{http_code}' -m 5 ${endpoint}"`.quiet()
|
|
182
|
+
const output = result.stdout.toString().trim()
|
|
183
|
+
const statusCode = parseInt(output, 10)
|
|
184
|
+
|
|
185
|
+
if (statusCode >= 200 && statusCode < 400) {
|
|
186
|
+
return { healthy: true, status: statusCode }
|
|
187
|
+
} else if (statusCode >= 400 && statusCode < 500) {
|
|
188
|
+
return { healthy: true, status: statusCode, message: 'server running' }
|
|
189
|
+
} else if (statusCode >= 500) {
|
|
190
|
+
return { healthy: false, status: statusCode, message: 'server error' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { healthy: false, message: 'app starting...' }
|
|
194
|
+
} catch {
|
|
195
|
+
return { healthy: false, message: 'app starting...' }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* get recent logs from a container
|
|
201
|
+
*/
|
|
202
|
+
export async function getContainerLogs(
|
|
203
|
+
ctx: SSHContext,
|
|
204
|
+
containerId: string,
|
|
205
|
+
lines = 20
|
|
206
|
+
): Promise<string[]> {
|
|
207
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
208
|
+
try {
|
|
209
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
210
|
+
const result =
|
|
211
|
+
await $`${sshCmd.split(' ')} "docker logs ${containerId} --tail ${lines} 2>&1"`.quiet()
|
|
212
|
+
return result.stdout.toString().trim().split('\n')
|
|
213
|
+
} catch {
|
|
214
|
+
return []
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/helpers/ssh.ts
CHANGED
|
@@ -36,11 +36,15 @@ export async function testSSHConnection(
|
|
|
36
36
|
} catch (err) {
|
|
37
37
|
lastError = err as Error
|
|
38
38
|
if (attempt < maxRetries) {
|
|
39
|
-
console.info(
|
|
39
|
+
console.info(
|
|
40
|
+
` ssh attempt ${attempt}/${maxRetries} failed, retrying in ${retryDelay / 1000}s...`
|
|
41
|
+
)
|
|
40
42
|
await sleep(retryDelay)
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
throw new Error(
|
|
47
|
+
throw new Error(
|
|
48
|
+
`cannot connect to ${host} after ${maxRetries} attempts - check ssh key: ${sshKey}`
|
|
49
|
+
)
|
|
46
50
|
}
|
|
@@ -147,10 +147,10 @@ export async function setupSSHKey(options: SetupSSHKeyOptions = {}) {
|
|
|
147
147
|
// add host to known_hosts
|
|
148
148
|
if (host) {
|
|
149
149
|
try {
|
|
150
|
-
await run(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
150
|
+
await run(`ssh-keyscan -H ${host} >> ${join(sshDir, 'known_hosts')}`, {
|
|
151
|
+
silent: true,
|
|
152
|
+
timeout: time.ms.seconds(10),
|
|
153
|
+
})
|
|
154
154
|
} catch {
|
|
155
155
|
// ignore - ssh will prompt if needed
|
|
156
156
|
}
|