@marcusrbrown/infra 0.8.1 → 0.9.1
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 +1 -1
- package/src/__snapshots__/cli.test.ts.snap +19 -1
- package/src/cli.ts +2 -0
- package/src/commands/cliproxy/setup/validation.test.ts +168 -0
- package/src/commands/cliproxy/setup/validation.ts +36 -9
- package/src/commands/cliproxy/status.test.ts +368 -38
- package/src/commands/cliproxy/status.ts +168 -41
- package/src/commands/mcp.test.ts +5 -7
- package/src/commands/mcp.ts +3 -0
- package/src/commands/status.test.ts +36 -0
- package/src/commands/status.ts +10 -3
- package/src/commands/umami/deploy.test.ts +202 -0
- package/src/commands/umami/deploy.ts +132 -0
- package/src/commands/umami/host.test.ts +62 -0
- package/src/commands/umami/host.ts +31 -0
- package/src/commands/umami/index.ts +13 -0
- package/src/commands/umami/logs.test.ts +154 -0
- package/src/commands/umami/logs.ts +161 -0
- package/src/commands/umami/status.test.ts +387 -0
- package/src/commands/umami/status.ts +267 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {createCapturedCtx, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
4
|
+
import {
|
|
5
|
+
getUmamiComposeStatus,
|
|
6
|
+
getUmamiStatusSummary,
|
|
7
|
+
parseComposePs,
|
|
8
|
+
parseComposePsOutput,
|
|
9
|
+
umamiStatusAction,
|
|
10
|
+
type ComposePsEntry,
|
|
11
|
+
type ServiceRow,
|
|
12
|
+
} from './status'
|
|
13
|
+
|
|
14
|
+
// ─── parseComposePsOutput ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe('parseComposePsOutput', () => {
|
|
17
|
+
it('parses NDJSON output (one JSON object per line)', () => {
|
|
18
|
+
const ndjson = [
|
|
19
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
20
|
+
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
21
|
+
].join('\n')
|
|
22
|
+
|
|
23
|
+
const entries = parseComposePsOutput(ndjson)
|
|
24
|
+
|
|
25
|
+
expect(entries).toHaveLength(2)
|
|
26
|
+
expect(entries[0]).toEqual({Name: 'umami', State: 'running', Health: 'healthy'})
|
|
27
|
+
expect(entries[1]).toEqual({Name: 'db', State: 'running', Health: ''})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('parses JSON-array output (legacy compose format)', () => {
|
|
31
|
+
const jsonArray = JSON.stringify([
|
|
32
|
+
{Name: 'umami', State: 'running', Health: 'healthy'},
|
|
33
|
+
{Name: 'db', State: 'running', Health: ''},
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
const entries = parseComposePsOutput(jsonArray)
|
|
37
|
+
|
|
38
|
+
expect(entries).toHaveLength(2)
|
|
39
|
+
expect(entries[0]).toEqual({Name: 'umami', State: 'running', Health: 'healthy'})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns empty array for empty string', () => {
|
|
43
|
+
expect(parseComposePsOutput('')).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns empty array for whitespace-only string', () => {
|
|
47
|
+
expect(parseComposePsOutput(' \n ')).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ─── parseComposePs ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe('parseComposePs', () => {
|
|
54
|
+
it('maps running services with healthy status', () => {
|
|
55
|
+
const raw: ComposePsEntry[] = [
|
|
56
|
+
{Name: 'umami', State: 'running', Health: 'healthy'},
|
|
57
|
+
{Name: 'db', State: 'running', Health: ''},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const rows = parseComposePs(raw)
|
|
61
|
+
|
|
62
|
+
expect(rows).toHaveLength(2)
|
|
63
|
+
expect(rows[0]).toEqual({service: 'umami', state: 'running', health: 'healthy'} satisfies ServiceRow)
|
|
64
|
+
expect(rows[1]).toEqual({service: 'db', state: 'running', health: 'n-a'} satisfies ServiceRow)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('maps exited state', () => {
|
|
68
|
+
const raw: ComposePsEntry[] = [{Name: 'umami', State: 'exited', Health: ''}]
|
|
69
|
+
|
|
70
|
+
const rows = parseComposePs(raw)
|
|
71
|
+
|
|
72
|
+
expect(rows[0]).toEqual({service: 'umami', state: 'exited', health: 'n-a'} satisfies ServiceRow)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('maps unhealthy health status', () => {
|
|
76
|
+
const raw: ComposePsEntry[] = [{Name: 'umami', State: 'running', Health: 'unhealthy'}]
|
|
77
|
+
|
|
78
|
+
const rows = parseComposePs(raw)
|
|
79
|
+
|
|
80
|
+
expect(rows[0]?.health).toBe('unhealthy')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('maps starting health status', () => {
|
|
84
|
+
const raw: ComposePsEntry[] = [{Name: 'umami', State: 'running', Health: 'starting'}]
|
|
85
|
+
|
|
86
|
+
const rows = parseComposePs(raw)
|
|
87
|
+
|
|
88
|
+
expect(rows[0]?.health).toBe('starting')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('maps unknown health values to n-a', () => {
|
|
92
|
+
const raw: ComposePsEntry[] = [{Name: 'umami', State: 'running', Health: 'something-weird'}]
|
|
93
|
+
|
|
94
|
+
const rows = parseComposePs(raw)
|
|
95
|
+
|
|
96
|
+
expect(rows[0]?.health).toBe('n-a')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns empty array for empty input', () => {
|
|
100
|
+
expect(parseComposePs([])).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ─── getUmamiComposeStatus (SSH mocked via SpawnFn) ──────────────────────────
|
|
105
|
+
|
|
106
|
+
type SpawnFn = (
|
|
107
|
+
cmd: string[],
|
|
108
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
109
|
+
) => {
|
|
110
|
+
stdout: ReadableStream<Uint8Array>
|
|
111
|
+
stderr: ReadableStream<Uint8Array>
|
|
112
|
+
exited: Promise<number>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeSpawn(stdout: string, stderr = '', exitCode = 0): SpawnFn {
|
|
116
|
+
return (_cmd, _opts) => {
|
|
117
|
+
const enc = new TextEncoder()
|
|
118
|
+
return {
|
|
119
|
+
stdout: new ReadableStream({
|
|
120
|
+
start(controller) {
|
|
121
|
+
controller.enqueue(enc.encode(stdout))
|
|
122
|
+
controller.close()
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
stderr: new ReadableStream({
|
|
126
|
+
start(controller) {
|
|
127
|
+
controller.enqueue(enc.encode(stderr))
|
|
128
|
+
controller.close()
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
exited: Promise.resolve(exitCode),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe('getUmamiComposeStatus', () => {
|
|
137
|
+
it('returns service rows from NDJSON output', async () => {
|
|
138
|
+
const ndjson = [
|
|
139
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
140
|
+
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
141
|
+
].join('\n')
|
|
142
|
+
|
|
143
|
+
const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn(ndjson))
|
|
144
|
+
|
|
145
|
+
expect(result.ok).toBe(true)
|
|
146
|
+
expect(result.services).toHaveLength(2)
|
|
147
|
+
expect(result.services[0]?.service).toBe('umami')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('returns service rows from JSON-array output', async () => {
|
|
151
|
+
const jsonArray = JSON.stringify([
|
|
152
|
+
{Name: 'umami', State: 'running', Health: 'healthy'},
|
|
153
|
+
{Name: 'db', State: 'running', Health: ''},
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn(jsonArray))
|
|
157
|
+
|
|
158
|
+
expect(result.ok).toBe(true)
|
|
159
|
+
expect(result.services).toHaveLength(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('returns ok=false when SSH exits non-zero', async () => {
|
|
163
|
+
const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn('', 'connection refused', 1))
|
|
164
|
+
|
|
165
|
+
expect(result.ok).toBe(false)
|
|
166
|
+
expect(result.error).toContain('SSH command failed')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('rejects invalid host before spawning SSH', async () => {
|
|
170
|
+
let spawnCalled = false
|
|
171
|
+
const spy: SpawnFn = (cmd, opts) => {
|
|
172
|
+
spawnCalled = true
|
|
173
|
+
return makeSpawn('')(cmd, opts)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await expect(getUmamiComposeStatus('-oProxyCommand=evil', spy)).rejects.toThrow('Invalid UMAMI_DOMAIN')
|
|
177
|
+
expect(spawnCalled).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ─── getUmamiStatusSummary ────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe('getUmamiStatusSummary', () => {
|
|
184
|
+
it('shows service rows with DEGRADED marker when services present but unhealthy', async () => {
|
|
185
|
+
const ndjson = [
|
|
186
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'unhealthy'}),
|
|
187
|
+
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
188
|
+
].join('\n')
|
|
189
|
+
|
|
190
|
+
const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(ndjson))
|
|
191
|
+
|
|
192
|
+
expect(summary.http).toContain('DEGRADED')
|
|
193
|
+
expect(summary.http).toContain('umami')
|
|
194
|
+
expect(summary.http).not.toContain('No services reported')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('shows ERROR with no-services message when compose ps returns empty output', async () => {
|
|
198
|
+
const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(''))
|
|
199
|
+
|
|
200
|
+
expect(summary.http).toContain('ERROR')
|
|
201
|
+
expect(summary.http).toContain('No services')
|
|
202
|
+
expect(summary.http).not.toContain('DEGRADED')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('shows OK with service rows when all services are healthy', async () => {
|
|
206
|
+
const ndjson = [
|
|
207
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
208
|
+
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
209
|
+
].join('\n')
|
|
210
|
+
|
|
211
|
+
const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(ndjson))
|
|
212
|
+
|
|
213
|
+
expect(summary.http).toContain('OK')
|
|
214
|
+
expect(summary.http).toContain('umami')
|
|
215
|
+
expect(summary.http).not.toContain('DEGRADED')
|
|
216
|
+
expect(summary.http).not.toContain('ERROR')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// ─── umamiStatusAction ───────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe('status command', () => {
|
|
223
|
+
let originalEnv: Record<string, string | undefined>
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
originalEnv = {UMAMI_DOMAIN: process.env.UMAMI_DOMAIN, MY_UMAMI_HOST: process.env.MY_UMAMI_HOST}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
afterEach(() => {
|
|
230
|
+
if (originalEnv.UMAMI_DOMAIN === undefined) {
|
|
231
|
+
delete process.env.UMAMI_DOMAIN
|
|
232
|
+
} else {
|
|
233
|
+
process.env.UMAMI_DOMAIN = originalEnv.UMAMI_DOMAIN
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (originalEnv.MY_UMAMI_HOST === undefined) {
|
|
237
|
+
delete process.env.MY_UMAMI_HOST
|
|
238
|
+
} else {
|
|
239
|
+
process.env.MY_UMAMI_HOST = originalEnv.MY_UMAMI_HOST
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('outputs service rows through ctx when services are running', async () => {
|
|
244
|
+
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
245
|
+
|
|
246
|
+
const ndjson = [
|
|
247
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
248
|
+
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
249
|
+
].join('\n')
|
|
250
|
+
|
|
251
|
+
const {ctx, captured} = createCapturedCtx()
|
|
252
|
+
|
|
253
|
+
await umamiStatusAction({}, ctx, makeSpawn(ndjson))
|
|
254
|
+
|
|
255
|
+
const output = captured.stdout.join('\n')
|
|
256
|
+
expect(output).toContain('umami')
|
|
257
|
+
expect(output).toContain('running')
|
|
258
|
+
expect(captured.exit).toBeNull()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('calls ctx.console.error and ctx.process.exit(1) on SSH failure without throwing', async () => {
|
|
262
|
+
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
263
|
+
|
|
264
|
+
const {ctx, captured} = createCapturedCtx()
|
|
265
|
+
|
|
266
|
+
let threw = false
|
|
267
|
+
try {
|
|
268
|
+
await umamiStatusAction({}, ctx, makeSpawn('', 'connection refused', 1))
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error instanceof MockProcessExit) {
|
|
271
|
+
// expected — MockProcessExit is thrown by ctx.process.exit
|
|
272
|
+
} else {
|
|
273
|
+
threw = true
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
expect(threw).toBe(false)
|
|
278
|
+
expect(captured.stderr.join('')).toContain('Error')
|
|
279
|
+
expect(captured.exit?.code).toBe(1)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('calls ctx.console.error and exits when UMAMI_DOMAIN is not set', async () => {
|
|
283
|
+
delete process.env.UMAMI_DOMAIN
|
|
284
|
+
|
|
285
|
+
const {ctx, captured} = createCapturedCtx()
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await umamiStatusAction({}, ctx)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (!(error instanceof MockProcessExit)) throw error
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
expect(captured.stderr.join('')).toContain('UMAMI_DOMAIN')
|
|
294
|
+
expect(captured.exit?.code).toBe(1)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('rejects bad host via ctx.console.error without throwing unhandled', async () => {
|
|
298
|
+
process.env.UMAMI_DOMAIN = '-oProxyCommand=evil'
|
|
299
|
+
|
|
300
|
+
const {ctx, captured} = createCapturedCtx()
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
await umamiStatusAction({}, ctx, makeSpawn(''))
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (!(error instanceof MockProcessExit)) throw error
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
expect(captured.stderr.join('')).toContain('Invalid UMAMI_DOMAIN')
|
|
309
|
+
expect(captured.exit?.code).toBe(1)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('reads host from the env var named by --key instead of UMAMI_DOMAIN', async () => {
|
|
313
|
+
delete process.env.UMAMI_DOMAIN
|
|
314
|
+
process.env.MY_UMAMI_HOST = 'metrics.fro.bot'
|
|
315
|
+
|
|
316
|
+
const ndjson = [
|
|
317
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
318
|
+
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
319
|
+
].join('\n')
|
|
320
|
+
|
|
321
|
+
const {ctx, captured} = createCapturedCtx()
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await umamiStatusAction({key: 'MY_UMAMI_HOST'}, ctx, makeSpawn(ndjson))
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (!(error instanceof MockProcessExit)) throw error
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const output = captured.stdout.join('\n')
|
|
330
|
+
expect(output).toContain('umami')
|
|
331
|
+
expect(captured.exit).toBeNull()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('exits 1 with a clear message when docker compose ps returns empty output', async () => {
|
|
335
|
+
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
336
|
+
|
|
337
|
+
const {ctx, captured} = createCapturedCtx()
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
await umamiStatusAction({}, ctx, makeSpawn(''))
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (!(error instanceof MockProcessExit)) throw error
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
expect(captured.stderr.join('')).toMatch(/no services|empty/i)
|
|
346
|
+
expect(captured.exit?.code).toBe(1)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('reports degraded when a service is running but health is unhealthy', async () => {
|
|
350
|
+
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
351
|
+
|
|
352
|
+
const ndjson = [
|
|
353
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'unhealthy'}),
|
|
354
|
+
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
355
|
+
].join('\n')
|
|
356
|
+
|
|
357
|
+
const {ctx, captured} = createCapturedCtx()
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
await umamiStatusAction({}, ctx, makeSpawn(ndjson))
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (!(error instanceof MockProcessExit)) throw error
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const output = captured.stdout.join('\n')
|
|
366
|
+
expect(output).toContain('DEGRADED')
|
|
367
|
+
expect(captured.exit?.code).toBe(1)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('reports OK when a service is running with health n-a (e.g. caddy has no healthcheck)', async () => {
|
|
371
|
+
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
372
|
+
|
|
373
|
+
const ndjson = [
|
|
374
|
+
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
375
|
+
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
376
|
+
JSON.stringify({Name: 'caddy', State: 'running', Health: ''}),
|
|
377
|
+
].join('\n')
|
|
378
|
+
|
|
379
|
+
const {ctx, captured} = createCapturedCtx()
|
|
380
|
+
|
|
381
|
+
await umamiStatusAction({}, ctx, makeSpawn(ndjson))
|
|
382
|
+
|
|
383
|
+
const output = captured.stdout.join('\n')
|
|
384
|
+
expect(output).toContain('Status: OK')
|
|
385
|
+
expect(captured.exit).toBeNull()
|
|
386
|
+
})
|
|
387
|
+
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
3
|
+
import type {StatusSummary} from '../status'
|
|
4
|
+
|
|
5
|
+
import {z} from 'zod'
|
|
6
|
+
|
|
7
|
+
import {validateUmamiHost} from './host'
|
|
8
|
+
|
|
9
|
+
declare const process: {
|
|
10
|
+
env: Record<string, string | undefined>
|
|
11
|
+
exitCode?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const COMPOSE_PROJECT_DIR = '/opt/umami'
|
|
15
|
+
|
|
16
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface ComposePsEntry {
|
|
19
|
+
Name: string
|
|
20
|
+
State: string
|
|
21
|
+
Health: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'n-a'
|
|
25
|
+
|
|
26
|
+
export interface ServiceRow {
|
|
27
|
+
service: string
|
|
28
|
+
state: string
|
|
29
|
+
health: HealthStatus
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UmamiStatusResult {
|
|
33
|
+
ok: boolean
|
|
34
|
+
services: ServiceRow[]
|
|
35
|
+
error?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SpawnFn = (
|
|
39
|
+
cmd: string[],
|
|
40
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
41
|
+
) => {
|
|
42
|
+
stdout: ReadableStream<Uint8Array>
|
|
43
|
+
stderr: ReadableStream<Uint8Array>
|
|
44
|
+
exited: Promise<number>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function parseComposePsOutput(stdoutText: string): ComposePsEntry[] {
|
|
50
|
+
const trimmed = stdoutText.trim()
|
|
51
|
+
if (trimmed.length === 0) return []
|
|
52
|
+
|
|
53
|
+
// Legacy compose may emit a single JSON array; current versions emit NDJSON.
|
|
54
|
+
if (trimmed.startsWith('[')) {
|
|
55
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
56
|
+
return Array.isArray(parsed) ? (parsed as ComposePsEntry[]) : []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// NDJSON: one JSON object per non-empty line.
|
|
60
|
+
return trimmed
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map(line => line.trim())
|
|
63
|
+
.filter(line => line.length > 0)
|
|
64
|
+
.map(line => JSON.parse(line) as ComposePsEntry)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeHealth(raw: string): HealthStatus {
|
|
68
|
+
if (raw === 'healthy' || raw === 'unhealthy' || raw === 'starting') {
|
|
69
|
+
return raw
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 'n-a'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseComposePs(entries: ComposePsEntry[]): ServiceRow[] {
|
|
76
|
+
return entries.map(entry => ({
|
|
77
|
+
service: entry.Name,
|
|
78
|
+
state: entry.State,
|
|
79
|
+
health: normalizeHealth(entry.Health),
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isAllRunning(rows: ServiceRow[]): boolean {
|
|
84
|
+
return rows.every(row => row.state === 'running' && (row.health === 'healthy' || row.health === 'n-a'))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── SSH-backed status fetch ──────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function defaultSpawn(
|
|
90
|
+
cmd: string[],
|
|
91
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
92
|
+
): ReturnType<SpawnFn> {
|
|
93
|
+
return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<UmamiStatusResult> {
|
|
97
|
+
validateUmamiHost(host)
|
|
98
|
+
|
|
99
|
+
const sshCmd = [
|
|
100
|
+
'ssh',
|
|
101
|
+
'-o',
|
|
102
|
+
'BatchMode=yes',
|
|
103
|
+
'-o',
|
|
104
|
+
'StrictHostKeyChecking=yes',
|
|
105
|
+
`root@${host}`,
|
|
106
|
+
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const env: Record<string, string> = {
|
|
110
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
111
|
+
HOME: process.env.HOME ?? '/tmp',
|
|
112
|
+
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
116
|
+
|
|
117
|
+
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
|
118
|
+
new Response(child.stdout).text(),
|
|
119
|
+
new Response(child.stderr).text(),
|
|
120
|
+
child.exited,
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
if (exitCode !== 0) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
services: [],
|
|
127
|
+
error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let entries: ComposePsEntry[]
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
entries = parseComposePsOutput(stdoutText)
|
|
135
|
+
} catch {
|
|
136
|
+
return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const services = parseComposePs(entries)
|
|
140
|
+
const ok = isAllRunning(services)
|
|
141
|
+
|
|
142
|
+
return {ok, services}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Unified status aggregator export ────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export async function getUmamiStatusSummary(host: string, spawn?: SpawnFn): Promise<StatusSummary> {
|
|
148
|
+
let result: UmamiStatusResult
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
result = await getUmamiComposeStatus(host, spawn)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
154
|
+
return {
|
|
155
|
+
app: 'umami',
|
|
156
|
+
http: `ERROR: ${errorMsg}`,
|
|
157
|
+
lastDeploy: '—',
|
|
158
|
+
version: '—',
|
|
159
|
+
contentHash: '—',
|
|
160
|
+
usageStats: '—',
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Three-way split: no services → ERROR, services present but degraded → DEGRADED, all healthy → OK
|
|
165
|
+
if (result.services.length === 0) {
|
|
166
|
+
const errorMsg = result.error ?? 'No services reported'
|
|
167
|
+
return {
|
|
168
|
+
app: 'umami',
|
|
169
|
+
http: `ERROR: ${errorMsg}`,
|
|
170
|
+
lastDeploy: '—',
|
|
171
|
+
version: '—',
|
|
172
|
+
contentHash: '—',
|
|
173
|
+
usageStats: '—',
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rows = result.services.map(s => `${s.service}:${s.state}/${s.health}`).join(', ')
|
|
178
|
+
|
|
179
|
+
if (!result.ok) {
|
|
180
|
+
return {
|
|
181
|
+
app: 'umami',
|
|
182
|
+
http: `DEGRADED: ${rows}`,
|
|
183
|
+
lastDeploy: '—',
|
|
184
|
+
version: '—',
|
|
185
|
+
contentHash: '—',
|
|
186
|
+
usageStats: '—',
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
app: 'umami',
|
|
192
|
+
http: `OK: ${rows}`,
|
|
193
|
+
lastDeploy: '—',
|
|
194
|
+
version: '—',
|
|
195
|
+
contentHash: '—',
|
|
196
|
+
usageStats: '—',
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Action (exported for direct testing) ────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
export async function umamiStatusAction(
|
|
203
|
+
options: {key?: string | undefined},
|
|
204
|
+
ctx: ActionCtx,
|
|
205
|
+
spawn?: SpawnFn,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const hostEnvKey = options.key ?? 'UMAMI_DOMAIN'
|
|
208
|
+
const host = process.env[hostEnvKey]
|
|
209
|
+
|
|
210
|
+
if (!host) {
|
|
211
|
+
ctx.console.error('Umami host not set. Export UMAMI_DOMAIN or pass --key <env-name> pointing to a set variable.')
|
|
212
|
+
ctx.process.exit(1)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
ctx.console.log('Umami status')
|
|
217
|
+
ctx.console.log('')
|
|
218
|
+
|
|
219
|
+
let result: UmamiStatusResult
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
result = await getUmamiComposeStatus(host, spawn)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
ctx.console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
|
225
|
+
ctx.process.exit(1)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (result.services.length === 0) {
|
|
230
|
+
ctx.console.error(`Error: No services reported by docker compose ps`)
|
|
231
|
+
ctx.process.exit(1)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
ctx.console.log('Service State Health')
|
|
236
|
+
ctx.console.log('─────────────────────────────────────')
|
|
237
|
+
|
|
238
|
+
for (const row of result.services) {
|
|
239
|
+
const svc = row.service.padEnd(16)
|
|
240
|
+
const state = row.state.padEnd(10)
|
|
241
|
+
ctx.console.log(`${svc} ${state} ${row.health}`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
ctx.console.log('')
|
|
245
|
+
|
|
246
|
+
if (result.ok) {
|
|
247
|
+
ctx.console.log('Status: OK')
|
|
248
|
+
} else {
|
|
249
|
+
ctx.console.log('Status: DEGRADED (one or more services not running)')
|
|
250
|
+
ctx.process.exit(1)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export function registerUmamiStatus(cli: ReturnType<typeof goke>): void {
|
|
257
|
+
cli
|
|
258
|
+
.command('umami status', 'Show operational health of the Umami analytics deployment via docker compose ps.')
|
|
259
|
+
.option(
|
|
260
|
+
'--key [key]',
|
|
261
|
+
z
|
|
262
|
+
.string()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe('Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.'),
|
|
265
|
+
)
|
|
266
|
+
.action((options, ctx) => umamiStatusAction(options, ctx))
|
|
267
|
+
}
|