@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.17",
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": "workspace:*",
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
+ }
@@ -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(` ssh attempt ${attempt}/${maxRetries} failed, retrying in ${retryDelay / 1000}s...`)
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(`cannot connect to ${host} after ${maxRetries} attempts - check ssh key: ${sshKey}`)
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
- `ssh-keyscan -H ${host} >> ${join(sshDir, 'known_hosts')}`,
152
- { silent: true, timeout: time.ms.seconds(10) }
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
  }