@marcusrbrown/infra 0.13.1 → 0.13.3
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/dist/cli.js +11965 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,633 +0,0 @@
|
|
|
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
|
-
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
137
|
-
|
|
138
|
-
describe('getUmamiComposeStatus — SSH command includes UserKnownHostsFile', () => {
|
|
139
|
-
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
|
|
140
|
-
let capturedCmd: string[] = []
|
|
141
|
-
|
|
142
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
143
|
-
capturedCmd = cmd
|
|
144
|
-
const enc = new TextEncoder()
|
|
145
|
-
return {
|
|
146
|
-
stdout: new ReadableStream({
|
|
147
|
-
start(c) {
|
|
148
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
149
|
-
c.close()
|
|
150
|
-
},
|
|
151
|
-
}),
|
|
152
|
-
stderr: new ReadableStream({
|
|
153
|
-
start(c) {
|
|
154
|
-
c.close()
|
|
155
|
-
},
|
|
156
|
-
}),
|
|
157
|
-
exited: Promise.resolve(0),
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
162
|
-
|
|
163
|
-
// Find the UserKnownHostsFile option in the ssh command
|
|
164
|
-
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
165
|
-
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
166
|
-
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
167
|
-
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
171
|
-
let capturedCmd: string[] = []
|
|
172
|
-
|
|
173
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
174
|
-
capturedCmd = cmd
|
|
175
|
-
const enc = new TextEncoder()
|
|
176
|
-
return {
|
|
177
|
-
stdout: new ReadableStream({
|
|
178
|
-
start(c) {
|
|
179
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
180
|
-
c.close()
|
|
181
|
-
},
|
|
182
|
-
}),
|
|
183
|
-
stderr: new ReadableStream({
|
|
184
|
-
start(c) {
|
|
185
|
-
c.close()
|
|
186
|
-
},
|
|
187
|
-
}),
|
|
188
|
-
exited: Promise.resolve(0),
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
193
|
-
|
|
194
|
-
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
195
|
-
expect(strictIdx).toBeGreaterThan(-1)
|
|
196
|
-
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
197
|
-
})
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
describe('getUmamiComposeStatus', () => {
|
|
201
|
-
it('returns service rows from NDJSON output', async () => {
|
|
202
|
-
const ndjson = [
|
|
203
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
204
|
-
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
205
|
-
].join('\n')
|
|
206
|
-
|
|
207
|
-
const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn(ndjson))
|
|
208
|
-
|
|
209
|
-
expect(result.ok).toBe(true)
|
|
210
|
-
expect(result.services).toHaveLength(2)
|
|
211
|
-
expect(result.services[0]?.service).toBe('umami')
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
it('returns service rows from JSON-array output', async () => {
|
|
215
|
-
const jsonArray = JSON.stringify([
|
|
216
|
-
{Name: 'umami', State: 'running', Health: 'healthy'},
|
|
217
|
-
{Name: 'db', State: 'running', Health: ''},
|
|
218
|
-
])
|
|
219
|
-
|
|
220
|
-
const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn(jsonArray))
|
|
221
|
-
|
|
222
|
-
expect(result.ok).toBe(true)
|
|
223
|
-
expect(result.services).toHaveLength(2)
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('returns ok=false when SSH exits non-zero', async () => {
|
|
227
|
-
const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn('', 'connection refused', 1))
|
|
228
|
-
|
|
229
|
-
expect(result.ok).toBe(false)
|
|
230
|
-
expect(result.error).toContain('SSH command failed')
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
it('rejects invalid host before spawning SSH', async () => {
|
|
234
|
-
let spawnCalled = false
|
|
235
|
-
const spy: SpawnFn = (cmd, opts) => {
|
|
236
|
-
spawnCalled = true
|
|
237
|
-
return makeSpawn('')(cmd, opts)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
await expect(getUmamiComposeStatus('-oProxyCommand=evil', spy)).rejects.toThrow('Invalid UMAMI_DOMAIN')
|
|
241
|
-
expect(spawnCalled).toBe(false)
|
|
242
|
-
})
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
// ─── getUmamiStatusSummary ────────────────────────────────────────────────────
|
|
246
|
-
|
|
247
|
-
describe('getUmamiStatusSummary', () => {
|
|
248
|
-
it('shows service rows with DEGRADED marker when services present but unhealthy', async () => {
|
|
249
|
-
const ndjson = [
|
|
250
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'unhealthy'}),
|
|
251
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
252
|
-
].join('\n')
|
|
253
|
-
|
|
254
|
-
const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(ndjson))
|
|
255
|
-
|
|
256
|
-
expect(summary.http).toContain('DEGRADED')
|
|
257
|
-
expect(summary.http).toContain('umami')
|
|
258
|
-
expect(summary.http).not.toContain('No services reported')
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
it('shows ERROR with no-services message when compose ps returns empty output', async () => {
|
|
262
|
-
const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(''))
|
|
263
|
-
|
|
264
|
-
expect(summary.http).toContain('ERROR')
|
|
265
|
-
expect(summary.http).toContain('No services')
|
|
266
|
-
expect(summary.http).not.toContain('DEGRADED')
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
it('shows OK with service rows when all services are healthy', async () => {
|
|
270
|
-
const ndjson = [
|
|
271
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
272
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
273
|
-
].join('\n')
|
|
274
|
-
|
|
275
|
-
const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(ndjson))
|
|
276
|
-
|
|
277
|
-
expect(summary.http).toContain('OK')
|
|
278
|
-
expect(summary.http).toContain('umami')
|
|
279
|
-
expect(summary.http).not.toContain('DEGRADED')
|
|
280
|
-
expect(summary.http).not.toContain('ERROR')
|
|
281
|
-
})
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
// ─── umamiStatusAction ───────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
describe('status command', () => {
|
|
287
|
-
let originalEnv: Record<string, string | undefined>
|
|
288
|
-
|
|
289
|
-
beforeEach(() => {
|
|
290
|
-
originalEnv = {UMAMI_DOMAIN: process.env.UMAMI_DOMAIN, MY_UMAMI_HOST: process.env.MY_UMAMI_HOST}
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
afterEach(() => {
|
|
294
|
-
if (originalEnv.UMAMI_DOMAIN === undefined) {
|
|
295
|
-
delete process.env.UMAMI_DOMAIN
|
|
296
|
-
} else {
|
|
297
|
-
process.env.UMAMI_DOMAIN = originalEnv.UMAMI_DOMAIN
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (originalEnv.MY_UMAMI_HOST === undefined) {
|
|
301
|
-
delete process.env.MY_UMAMI_HOST
|
|
302
|
-
} else {
|
|
303
|
-
process.env.MY_UMAMI_HOST = originalEnv.MY_UMAMI_HOST
|
|
304
|
-
}
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
it('outputs service rows through ctx when services are running', async () => {
|
|
308
|
-
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
309
|
-
|
|
310
|
-
const ndjson = [
|
|
311
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
312
|
-
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
313
|
-
].join('\n')
|
|
314
|
-
|
|
315
|
-
const {ctx, captured} = createCapturedCtx()
|
|
316
|
-
|
|
317
|
-
await umamiStatusAction({}, ctx, makeSpawn(ndjson))
|
|
318
|
-
|
|
319
|
-
const output = captured.stdout.join('\n')
|
|
320
|
-
expect(output).toContain('umami')
|
|
321
|
-
expect(output).toContain('running')
|
|
322
|
-
expect(captured.exit).toBeNull()
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
it('calls ctx.console.error and ctx.process.exit(1) on SSH failure without throwing', async () => {
|
|
326
|
-
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
327
|
-
|
|
328
|
-
const {ctx, captured} = createCapturedCtx()
|
|
329
|
-
|
|
330
|
-
let threw = false
|
|
331
|
-
try {
|
|
332
|
-
await umamiStatusAction({}, ctx, makeSpawn('', 'connection refused', 1))
|
|
333
|
-
} catch (error) {
|
|
334
|
-
if (error instanceof MockProcessExit) {
|
|
335
|
-
// expected — MockProcessExit is thrown by ctx.process.exit
|
|
336
|
-
} else {
|
|
337
|
-
threw = true
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
expect(threw).toBe(false)
|
|
342
|
-
expect(captured.stderr.join('')).toContain('Error')
|
|
343
|
-
expect(captured.exit?.code).toBe(1)
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
it('calls ctx.console.error and exits when UMAMI_DOMAIN is not set', async () => {
|
|
347
|
-
delete process.env.UMAMI_DOMAIN
|
|
348
|
-
|
|
349
|
-
const {ctx, captured} = createCapturedCtx()
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
await umamiStatusAction({}, ctx)
|
|
353
|
-
} catch (error) {
|
|
354
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
expect(captured.stderr.join('')).toContain('UMAMI_DOMAIN')
|
|
358
|
-
expect(captured.exit?.code).toBe(1)
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
it('rejects bad host via ctx.console.error without throwing unhandled', async () => {
|
|
362
|
-
process.env.UMAMI_DOMAIN = '-oProxyCommand=evil'
|
|
363
|
-
|
|
364
|
-
const {ctx, captured} = createCapturedCtx()
|
|
365
|
-
|
|
366
|
-
try {
|
|
367
|
-
await umamiStatusAction({}, ctx, makeSpawn(''))
|
|
368
|
-
} catch (error) {
|
|
369
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
expect(captured.stderr.join('')).toContain('Invalid UMAMI_DOMAIN')
|
|
373
|
-
expect(captured.exit?.code).toBe(1)
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
it('reads host from the env var named by --key instead of UMAMI_DOMAIN', async () => {
|
|
377
|
-
delete process.env.UMAMI_DOMAIN
|
|
378
|
-
process.env.MY_UMAMI_HOST = 'metrics.fro.bot'
|
|
379
|
-
|
|
380
|
-
const ndjson = [
|
|
381
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
382
|
-
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
383
|
-
].join('\n')
|
|
384
|
-
|
|
385
|
-
const {ctx, captured} = createCapturedCtx()
|
|
386
|
-
|
|
387
|
-
try {
|
|
388
|
-
await umamiStatusAction({key: 'MY_UMAMI_HOST'}, ctx, makeSpawn(ndjson))
|
|
389
|
-
} catch (error) {
|
|
390
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const output = captured.stdout.join('\n')
|
|
394
|
-
expect(output).toContain('umami')
|
|
395
|
-
expect(captured.exit).toBeNull()
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
it('exits 1 with a clear message when docker compose ps returns empty output', async () => {
|
|
399
|
-
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
400
|
-
|
|
401
|
-
const {ctx, captured} = createCapturedCtx()
|
|
402
|
-
|
|
403
|
-
try {
|
|
404
|
-
await umamiStatusAction({}, ctx, makeSpawn(''))
|
|
405
|
-
} catch (error) {
|
|
406
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
expect(captured.stderr.join('')).toMatch(/no services|empty/i)
|
|
410
|
-
expect(captured.exit?.code).toBe(1)
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
it('reports degraded when a service is running but health is unhealthy', async () => {
|
|
414
|
-
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
415
|
-
|
|
416
|
-
const ndjson = [
|
|
417
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'unhealthy'}),
|
|
418
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
419
|
-
].join('\n')
|
|
420
|
-
|
|
421
|
-
const {ctx, captured} = createCapturedCtx()
|
|
422
|
-
|
|
423
|
-
try {
|
|
424
|
-
await umamiStatusAction({}, ctx, makeSpawn(ndjson))
|
|
425
|
-
} catch (error) {
|
|
426
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const output = captured.stdout.join('\n')
|
|
430
|
-
expect(output).toContain('DEGRADED')
|
|
431
|
-
expect(captured.exit?.code).toBe(1)
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
it('reports OK when a service is running with health n-a (e.g. caddy has no healthcheck)', async () => {
|
|
435
|
-
process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
|
|
436
|
-
|
|
437
|
-
const ndjson = [
|
|
438
|
-
JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
|
|
439
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
440
|
-
JSON.stringify({Name: 'caddy', State: 'running', Health: ''}),
|
|
441
|
-
].join('\n')
|
|
442
|
-
|
|
443
|
-
const {ctx, captured} = createCapturedCtx()
|
|
444
|
-
|
|
445
|
-
await umamiStatusAction({}, ctx, makeSpawn(ndjson))
|
|
446
|
-
|
|
447
|
-
const output = captured.stdout.join('\n')
|
|
448
|
-
expect(output).toContain('Status: OK')
|
|
449
|
-
expect(captured.exit).toBeNull()
|
|
450
|
-
})
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
// ─── SSH error redaction — host value must not leak in returned error strings ──
|
|
454
|
-
|
|
455
|
-
describe('getUmamiComposeStatus — SSH error redaction', () => {
|
|
456
|
-
it('does not include the resolved host value in the error when SSH fails', async () => {
|
|
457
|
-
const {getUmamiComposeStatus} = await import('./status')
|
|
458
|
-
const secretLookingHost = 'TOPSECRETHOSTNAME'
|
|
459
|
-
|
|
460
|
-
const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
|
|
461
|
-
const encoder = new TextEncoder()
|
|
462
|
-
return {
|
|
463
|
-
stdout: new ReadableStream({
|
|
464
|
-
start(c) {
|
|
465
|
-
c.enqueue(encoder.encode(''))
|
|
466
|
-
c.close()
|
|
467
|
-
},
|
|
468
|
-
}),
|
|
469
|
-
stderr: new ReadableStream({
|
|
470
|
-
start(c) {
|
|
471
|
-
c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
|
|
472
|
-
c.close()
|
|
473
|
-
},
|
|
474
|
-
}),
|
|
475
|
-
exited: Promise.resolve(255),
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const result = await getUmamiComposeStatus(secretLookingHost, mockSpawn)
|
|
480
|
-
|
|
481
|
-
expect(result.ok).toBe(false)
|
|
482
|
-
expect(result.error).toBeDefined()
|
|
483
|
-
expect(result.error).not.toContain(secretLookingHost)
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
|
|
487
|
-
const {getUmamiComposeStatus} = await import('./status')
|
|
488
|
-
const secretHost = 'AKIAIOSFODNN7EXAMPLE'
|
|
489
|
-
|
|
490
|
-
const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
|
|
491
|
-
const encoder = new TextEncoder()
|
|
492
|
-
return {
|
|
493
|
-
stdout: new ReadableStream({
|
|
494
|
-
start(c) {
|
|
495
|
-
c.close()
|
|
496
|
-
},
|
|
497
|
-
}),
|
|
498
|
-
stderr: new ReadableStream({
|
|
499
|
-
start(c) {
|
|
500
|
-
c.enqueue(
|
|
501
|
-
encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
|
|
502
|
-
)
|
|
503
|
-
c.close()
|
|
504
|
-
},
|
|
505
|
-
}),
|
|
506
|
-
exited: Promise.resolve(255),
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const result = await getUmamiComposeStatus(secretHost, mockSpawn)
|
|
511
|
-
|
|
512
|
-
expect(result.ok).toBe(false)
|
|
513
|
-
expect(result.error).not.toContain(secretHost)
|
|
514
|
-
expect(result.error).not.toContain(secretHost.toLowerCase())
|
|
515
|
-
})
|
|
516
|
-
})
|
|
517
|
-
|
|
518
|
-
// ─── SSH identity injection (UMAMI_SSH_KEY) ───────────────────────────────────
|
|
519
|
-
|
|
520
|
-
describe('getUmamiComposeStatus — SSH identity injection', () => {
|
|
521
|
-
let originalEnv: Record<string, string | undefined>
|
|
522
|
-
|
|
523
|
-
beforeEach(() => {
|
|
524
|
-
originalEnv = {UMAMI_SSH_KEY: process.env.UMAMI_SSH_KEY}
|
|
525
|
-
})
|
|
526
|
-
|
|
527
|
-
afterEach(() => {
|
|
528
|
-
if (originalEnv.UMAMI_SSH_KEY === undefined) {
|
|
529
|
-
delete process.env.UMAMI_SSH_KEY
|
|
530
|
-
} else {
|
|
531
|
-
process.env.UMAMI_SSH_KEY = originalEnv.UMAMI_SSH_KEY
|
|
532
|
-
}
|
|
533
|
-
})
|
|
534
|
-
|
|
535
|
-
it('includes -i <path> and IdentitiesOnly=yes in ssh argv when UMAMI_SSH_KEY is set', async () => {
|
|
536
|
-
process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
537
|
-
|
|
538
|
-
let capturedCmd: string[] = []
|
|
539
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
540
|
-
capturedCmd = cmd
|
|
541
|
-
const enc = new TextEncoder()
|
|
542
|
-
return {
|
|
543
|
-
stdout: new ReadableStream({
|
|
544
|
-
start(c) {
|
|
545
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
546
|
-
c.close()
|
|
547
|
-
},
|
|
548
|
-
}),
|
|
549
|
-
stderr: new ReadableStream({
|
|
550
|
-
start(c) {
|
|
551
|
-
c.close()
|
|
552
|
-
},
|
|
553
|
-
}),
|
|
554
|
-
exited: Promise.resolve(0),
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
559
|
-
|
|
560
|
-
const iIdx = capturedCmd.indexOf('-i')
|
|
561
|
-
expect(iIdx).toBeGreaterThan(-1)
|
|
562
|
-
expect(capturedCmd[iIdx + 1]).toBeTruthy()
|
|
563
|
-
|
|
564
|
-
const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
|
|
565
|
-
expect(identitiesOnlyIdx).toBeGreaterThan(-1)
|
|
566
|
-
|
|
567
|
-
const destination = capturedCmd.find(arg => arg.includes('@'))
|
|
568
|
-
expect(destination).toBe('root@metrics.fro.bot')
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
it('does not include -i or IdentitiesOnly=yes when UMAMI_SSH_KEY is absent', async () => {
|
|
572
|
-
delete process.env.UMAMI_SSH_KEY
|
|
573
|
-
|
|
574
|
-
let capturedCmd: string[] = []
|
|
575
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
576
|
-
capturedCmd = cmd
|
|
577
|
-
const enc = new TextEncoder()
|
|
578
|
-
return {
|
|
579
|
-
stdout: new ReadableStream({
|
|
580
|
-
start(c) {
|
|
581
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
582
|
-
c.close()
|
|
583
|
-
},
|
|
584
|
-
}),
|
|
585
|
-
stderr: new ReadableStream({
|
|
586
|
-
start(c) {
|
|
587
|
-
c.close()
|
|
588
|
-
},
|
|
589
|
-
}),
|
|
590
|
-
exited: Promise.resolve(0),
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
595
|
-
|
|
596
|
-
expect(capturedCmd.indexOf('-i')).toBe(-1)
|
|
597
|
-
expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
it('cleans up the temp key file after the SSH command completes', async () => {
|
|
601
|
-
const {statSync} = await import('node:fs')
|
|
602
|
-
process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
603
|
-
|
|
604
|
-
let capturedKeyPath: string | undefined
|
|
605
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
606
|
-
const iIdx = cmd.indexOf('-i')
|
|
607
|
-
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
608
|
-
const enc = new TextEncoder()
|
|
609
|
-
return {
|
|
610
|
-
stdout: new ReadableStream({
|
|
611
|
-
start(c) {
|
|
612
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
613
|
-
c.close()
|
|
614
|
-
},
|
|
615
|
-
}),
|
|
616
|
-
stderr: new ReadableStream({
|
|
617
|
-
start(c) {
|
|
618
|
-
c.close()
|
|
619
|
-
},
|
|
620
|
-
}),
|
|
621
|
-
exited: Promise.resolve(0),
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
626
|
-
|
|
627
|
-
expect(capturedKeyPath).toBeTruthy()
|
|
628
|
-
const keyPath = capturedKeyPath
|
|
629
|
-
if (keyPath) {
|
|
630
|
-
expect(() => statSync(keyPath)).toThrow()
|
|
631
|
-
}
|
|
632
|
-
})
|
|
633
|
-
})
|