@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,708 +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
|
-
dashboardStatusAction,
|
|
6
|
-
getDashboardComposeStatus,
|
|
7
|
-
getDashboardStatusSummary,
|
|
8
|
-
parseComposePs,
|
|
9
|
-
parseComposePsOutput,
|
|
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: 'dashboard', 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: 'dashboard', 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: 'dashboard', 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: 'dashboard', 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: 'dashboard', 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: 'dashboard', 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: 'dashboard', State: 'exited', Health: ''}]
|
|
69
|
-
|
|
70
|
-
const rows = parseComposePs(raw)
|
|
71
|
-
|
|
72
|
-
expect(rows[0]).toEqual({service: 'dashboard', state: 'exited', health: 'n-a'} satisfies ServiceRow)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('maps unhealthy health status', () => {
|
|
76
|
-
const raw: ComposePsEntry[] = [{Name: 'dashboard', 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: 'dashboard', 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: 'dashboard', 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
|
-
// ─── getDashboardComposeStatus (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 ConnectTimeout ─────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
describe('getDashboardComposeStatus — SSH command includes ConnectTimeout', () => {
|
|
139
|
-
it('passes -o ConnectTimeout=10 to ssh', 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: 'dashboard', 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 getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
|
|
162
|
-
|
|
163
|
-
const connectTimeoutIdx = capturedCmd.findIndex(arg => arg.startsWith('ConnectTimeout='))
|
|
164
|
-
expect(connectTimeoutIdx).toBeGreaterThan(-1)
|
|
165
|
-
expect(capturedCmd[connectTimeoutIdx - 1]).toBe('-o')
|
|
166
|
-
expect(capturedCmd[connectTimeoutIdx]).toBe('ConnectTimeout=10')
|
|
167
|
-
})
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
171
|
-
|
|
172
|
-
describe('getDashboardComposeStatus — SSH command includes UserKnownHostsFile', () => {
|
|
173
|
-
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
|
|
174
|
-
let capturedCmd: string[] = []
|
|
175
|
-
|
|
176
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
177
|
-
capturedCmd = cmd
|
|
178
|
-
const enc = new TextEncoder()
|
|
179
|
-
return {
|
|
180
|
-
stdout: new ReadableStream({
|
|
181
|
-
start(c) {
|
|
182
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
|
|
183
|
-
c.close()
|
|
184
|
-
},
|
|
185
|
-
}),
|
|
186
|
-
stderr: new ReadableStream({
|
|
187
|
-
start(c) {
|
|
188
|
-
c.close()
|
|
189
|
-
},
|
|
190
|
-
}),
|
|
191
|
-
exited: Promise.resolve(0),
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
|
|
196
|
-
|
|
197
|
-
// Find the UserKnownHostsFile option in the ssh command
|
|
198
|
-
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
199
|
-
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
200
|
-
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
201
|
-
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
205
|
-
let capturedCmd: string[] = []
|
|
206
|
-
|
|
207
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
208
|
-
capturedCmd = cmd
|
|
209
|
-
const enc = new TextEncoder()
|
|
210
|
-
return {
|
|
211
|
-
stdout: new ReadableStream({
|
|
212
|
-
start(c) {
|
|
213
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
|
|
214
|
-
c.close()
|
|
215
|
-
},
|
|
216
|
-
}),
|
|
217
|
-
stderr: new ReadableStream({
|
|
218
|
-
start(c) {
|
|
219
|
-
c.close()
|
|
220
|
-
},
|
|
221
|
-
}),
|
|
222
|
-
exited: Promise.resolve(0),
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
|
|
227
|
-
|
|
228
|
-
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
229
|
-
expect(strictIdx).toBeGreaterThan(-1)
|
|
230
|
-
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
231
|
-
})
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
describe('getDashboardComposeStatus', () => {
|
|
235
|
-
it('returns service rows from NDJSON output', async () => {
|
|
236
|
-
const ndjson = [
|
|
237
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
|
|
238
|
-
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
239
|
-
].join('\n')
|
|
240
|
-
|
|
241
|
-
const result = await getDashboardComposeStatus('dashboard.fro.bot', makeSpawn(ndjson))
|
|
242
|
-
|
|
243
|
-
expect(result.ok).toBe(true)
|
|
244
|
-
expect(result.services).toHaveLength(2)
|
|
245
|
-
expect(result.services[0]?.service).toBe('dashboard')
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
it('returns service rows from JSON-array output', async () => {
|
|
249
|
-
const jsonArray = JSON.stringify([
|
|
250
|
-
{Name: 'dashboard', State: 'running', Health: 'healthy'},
|
|
251
|
-
{Name: 'db', State: 'running', Health: ''},
|
|
252
|
-
])
|
|
253
|
-
|
|
254
|
-
const result = await getDashboardComposeStatus('dashboard.fro.bot', makeSpawn(jsonArray))
|
|
255
|
-
|
|
256
|
-
expect(result.ok).toBe(true)
|
|
257
|
-
expect(result.services).toHaveLength(2)
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
it('returns ok=false when SSH exits non-zero', async () => {
|
|
261
|
-
const result = await getDashboardComposeStatus('dashboard.fro.bot', makeSpawn('', 'connection refused', 1))
|
|
262
|
-
|
|
263
|
-
expect(result.ok).toBe(false)
|
|
264
|
-
expect(result.error).toContain('SSH command failed')
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
it('rejects invalid host before spawning SSH', async () => {
|
|
268
|
-
let spawnCalled = false
|
|
269
|
-
const spy: SpawnFn = (cmd, opts) => {
|
|
270
|
-
spawnCalled = true
|
|
271
|
-
return makeSpawn('')(cmd, opts)
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
await expect(getDashboardComposeStatus('-oProxyCommand=evil', spy)).rejects.toThrow('Invalid DASHBOARD_DOMAIN')
|
|
275
|
-
expect(spawnCalled).toBe(false)
|
|
276
|
-
})
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
// ─── getDashboardStatusSummary ────────────────────────────────────────────────
|
|
280
|
-
|
|
281
|
-
describe('getDashboardStatusSummary', () => {
|
|
282
|
-
it('shows service rows with DEGRADED marker when services present but unhealthy', async () => {
|
|
283
|
-
const ndjson = [
|
|
284
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'unhealthy'}),
|
|
285
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
286
|
-
].join('\n')
|
|
287
|
-
|
|
288
|
-
const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(ndjson))
|
|
289
|
-
|
|
290
|
-
expect(summary.http).toContain('DEGRADED')
|
|
291
|
-
expect(summary.http).toContain('dashboard')
|
|
292
|
-
expect(summary.http).not.toContain('No services reported')
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
it('shows ERROR with no-services message when compose ps returns empty output', async () => {
|
|
296
|
-
const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(''))
|
|
297
|
-
|
|
298
|
-
expect(summary.http).toContain('ERROR')
|
|
299
|
-
expect(summary.http).toContain('No services')
|
|
300
|
-
expect(summary.http).not.toContain('DEGRADED')
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
it('shows OK with service rows when all services are healthy', async () => {
|
|
304
|
-
const ndjson = [
|
|
305
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
|
|
306
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
307
|
-
].join('\n')
|
|
308
|
-
|
|
309
|
-
const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(ndjson))
|
|
310
|
-
|
|
311
|
-
expect(summary.http).toContain('OK')
|
|
312
|
-
expect(summary.http).toContain('dashboard')
|
|
313
|
-
expect(summary.http).not.toContain('DEGRADED')
|
|
314
|
-
expect(summary.http).not.toContain('ERROR')
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
it('returns a summary with app: dashboard', async () => {
|
|
318
|
-
const ndjson = JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])
|
|
319
|
-
const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(ndjson))
|
|
320
|
-
expect(summary.app).toBe('dashboard')
|
|
321
|
-
})
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
// ─── dashboardStatusAction ───────────────────────────────────────────────────
|
|
325
|
-
|
|
326
|
-
describe('status command', () => {
|
|
327
|
-
let originalEnv: Record<string, string | undefined>
|
|
328
|
-
|
|
329
|
-
beforeEach(() => {
|
|
330
|
-
originalEnv = {DASHBOARD_DOMAIN: process.env.DASHBOARD_DOMAIN, MY_DASHBOARD_HOST: process.env.MY_DASHBOARD_HOST}
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
afterEach(() => {
|
|
334
|
-
if (originalEnv.DASHBOARD_DOMAIN === undefined) {
|
|
335
|
-
delete process.env.DASHBOARD_DOMAIN
|
|
336
|
-
} else {
|
|
337
|
-
process.env.DASHBOARD_DOMAIN = originalEnv.DASHBOARD_DOMAIN
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (originalEnv.MY_DASHBOARD_HOST === undefined) {
|
|
341
|
-
delete process.env.MY_DASHBOARD_HOST
|
|
342
|
-
} else {
|
|
343
|
-
process.env.MY_DASHBOARD_HOST = originalEnv.MY_DASHBOARD_HOST
|
|
344
|
-
}
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
it('outputs service rows through ctx when services are running', async () => {
|
|
348
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
349
|
-
|
|
350
|
-
const ndjson = [
|
|
351
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
|
|
352
|
-
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
353
|
-
].join('\n')
|
|
354
|
-
|
|
355
|
-
const {ctx, captured} = createCapturedCtx()
|
|
356
|
-
|
|
357
|
-
await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
|
|
358
|
-
|
|
359
|
-
const output = captured.stdout.join('\n')
|
|
360
|
-
expect(output).toContain('dashboard')
|
|
361
|
-
expect(output).toContain('running')
|
|
362
|
-
expect(captured.exit).toBeNull()
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
it('uses ctx.console NOT global console (output is captured via ctx)', async () => {
|
|
366
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
367
|
-
|
|
368
|
-
const ndjson = JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])
|
|
369
|
-
const {ctx, captured} = createCapturedCtx()
|
|
370
|
-
|
|
371
|
-
await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
|
|
372
|
-
|
|
373
|
-
// Output must appear in captured ctx, not global console
|
|
374
|
-
expect(captured.stdout.length).toBeGreaterThan(0)
|
|
375
|
-
expect(captured.stdout.join('')).toContain('dashboard')
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
it('calls ctx.console.error and ctx.process.exit(1) on SSH failure without throwing', async () => {
|
|
379
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
380
|
-
|
|
381
|
-
const {ctx, captured} = createCapturedCtx()
|
|
382
|
-
|
|
383
|
-
let threw = false
|
|
384
|
-
try {
|
|
385
|
-
await dashboardStatusAction({}, ctx, makeSpawn('', 'connection refused', 1))
|
|
386
|
-
} catch (error) {
|
|
387
|
-
if (error instanceof MockProcessExit) {
|
|
388
|
-
// expected — MockProcessExit is thrown by ctx.process.exit
|
|
389
|
-
} else {
|
|
390
|
-
threw = true
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
expect(threw).toBe(false)
|
|
395
|
-
expect(captured.stderr.join('')).toContain('Error')
|
|
396
|
-
expect(captured.exit?.code).toBe(1)
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
it('prints the SSH error message (not a generic no-services message) when SSH fails', async () => {
|
|
400
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
401
|
-
|
|
402
|
-
const {ctx, captured} = createCapturedCtx()
|
|
403
|
-
|
|
404
|
-
try {
|
|
405
|
-
await dashboardStatusAction(
|
|
406
|
-
{},
|
|
407
|
-
ctx,
|
|
408
|
-
makeSpawn('', 'ssh: connect to host dashboard.fro.bot port 22: Connection timed out', 255),
|
|
409
|
-
)
|
|
410
|
-
} catch (error) {
|
|
411
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const stderrText = captured.stderr.join('')
|
|
415
|
-
// Must surface the SSH failure detail, not the generic no-services fallback
|
|
416
|
-
expect(stderrText).toContain('SSH command failed')
|
|
417
|
-
expect(stderrText).not.toContain('No services reported by docker compose ps')
|
|
418
|
-
expect(captured.exit?.code).toBe(1)
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
it('calls ctx.console.error and exits when DASHBOARD_DOMAIN is not set', async () => {
|
|
422
|
-
delete process.env.DASHBOARD_DOMAIN
|
|
423
|
-
|
|
424
|
-
const {ctx, captured} = createCapturedCtx()
|
|
425
|
-
|
|
426
|
-
try {
|
|
427
|
-
await dashboardStatusAction({}, ctx)
|
|
428
|
-
} catch (error) {
|
|
429
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
expect(captured.stderr.join('')).toContain('DASHBOARD_DOMAIN')
|
|
433
|
-
expect(captured.exit?.code).toBe(1)
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
it('rejects bad host via ctx.console.error without throwing unhandled', async () => {
|
|
437
|
-
process.env.DASHBOARD_DOMAIN = '-oProxyCommand=evil'
|
|
438
|
-
|
|
439
|
-
const {ctx, captured} = createCapturedCtx()
|
|
440
|
-
|
|
441
|
-
try {
|
|
442
|
-
await dashboardStatusAction({}, ctx, makeSpawn(''))
|
|
443
|
-
} catch (error) {
|
|
444
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
expect(captured.stderr.join('')).toContain('Invalid DASHBOARD_DOMAIN')
|
|
448
|
-
expect(captured.exit?.code).toBe(1)
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
it('reads host from the env var named by --key instead of DASHBOARD_DOMAIN', async () => {
|
|
452
|
-
delete process.env.DASHBOARD_DOMAIN
|
|
453
|
-
process.env.MY_DASHBOARD_HOST = 'dashboard.fro.bot'
|
|
454
|
-
|
|
455
|
-
const ndjson = [
|
|
456
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
|
|
457
|
-
JSON.stringify({Name: 'db', State: 'running', Health: ''}),
|
|
458
|
-
].join('\n')
|
|
459
|
-
|
|
460
|
-
const {ctx, captured} = createCapturedCtx()
|
|
461
|
-
|
|
462
|
-
try {
|
|
463
|
-
await dashboardStatusAction({key: 'MY_DASHBOARD_HOST'}, ctx, makeSpawn(ndjson))
|
|
464
|
-
} catch (error) {
|
|
465
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const output = captured.stdout.join('\n')
|
|
469
|
-
expect(output).toContain('dashboard')
|
|
470
|
-
expect(captured.exit).toBeNull()
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
it('exits 1 with a clear message when docker compose ps returns empty output', async () => {
|
|
474
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
475
|
-
|
|
476
|
-
const {ctx, captured} = createCapturedCtx()
|
|
477
|
-
|
|
478
|
-
try {
|
|
479
|
-
await dashboardStatusAction({}, ctx, makeSpawn(''))
|
|
480
|
-
} catch (error) {
|
|
481
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
expect(captured.stderr.join('')).toMatch(/no services|empty/i)
|
|
485
|
-
expect(captured.exit?.code).toBe(1)
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
it('reports degraded when a service is running but health is unhealthy', async () => {
|
|
489
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
490
|
-
|
|
491
|
-
const ndjson = [
|
|
492
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'unhealthy'}),
|
|
493
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
494
|
-
].join('\n')
|
|
495
|
-
|
|
496
|
-
const {ctx, captured} = createCapturedCtx()
|
|
497
|
-
|
|
498
|
-
try {
|
|
499
|
-
await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
|
|
500
|
-
} catch (error) {
|
|
501
|
-
if (!(error instanceof MockProcessExit)) throw error
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const output = captured.stdout.join('\n')
|
|
505
|
-
expect(output).toContain('DEGRADED')
|
|
506
|
-
expect(captured.exit?.code).toBe(1)
|
|
507
|
-
})
|
|
508
|
-
|
|
509
|
-
it('reports OK when a service is running with health n-a (e.g. caddy has no healthcheck)', async () => {
|
|
510
|
-
process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
|
|
511
|
-
|
|
512
|
-
const ndjson = [
|
|
513
|
-
JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
|
|
514
|
-
JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
|
|
515
|
-
JSON.stringify({Name: 'caddy', State: 'running', Health: ''}),
|
|
516
|
-
].join('\n')
|
|
517
|
-
|
|
518
|
-
const {ctx, captured} = createCapturedCtx()
|
|
519
|
-
|
|
520
|
-
await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
|
|
521
|
-
|
|
522
|
-
const output = captured.stdout.join('\n')
|
|
523
|
-
expect(output).toContain('Status: OK')
|
|
524
|
-
expect(captured.exit).toBeNull()
|
|
525
|
-
})
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
// ─── SSH error redaction — host value must not leak in returned error strings ──
|
|
529
|
-
|
|
530
|
-
describe('getDashboardComposeStatus — SSH error redaction', () => {
|
|
531
|
-
it('does not include the resolved host value in the error when SSH fails', async () => {
|
|
532
|
-
const {getDashboardComposeStatus} = await import('./status')
|
|
533
|
-
const secretLookingHost = 'TOPSECRETHOSTNAME'
|
|
534
|
-
|
|
535
|
-
const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
|
|
536
|
-
const encoder = new TextEncoder()
|
|
537
|
-
return {
|
|
538
|
-
stdout: new ReadableStream({
|
|
539
|
-
start(c) {
|
|
540
|
-
c.enqueue(encoder.encode(''))
|
|
541
|
-
c.close()
|
|
542
|
-
},
|
|
543
|
-
}),
|
|
544
|
-
stderr: new ReadableStream({
|
|
545
|
-
start(c) {
|
|
546
|
-
c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
|
|
547
|
-
c.close()
|
|
548
|
-
},
|
|
549
|
-
}),
|
|
550
|
-
exited: Promise.resolve(255),
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const result = await getDashboardComposeStatus(secretLookingHost, mockSpawn)
|
|
555
|
-
|
|
556
|
-
expect(result.ok).toBe(false)
|
|
557
|
-
expect(result.error).toBeDefined()
|
|
558
|
-
expect(result.error).not.toContain(secretLookingHost)
|
|
559
|
-
})
|
|
560
|
-
|
|
561
|
-
it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
|
|
562
|
-
const {getDashboardComposeStatus} = await import('./status')
|
|
563
|
-
const secretHost = 'AKIAIOSFODNN7EXAMPLE'
|
|
564
|
-
|
|
565
|
-
const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
|
|
566
|
-
const encoder = new TextEncoder()
|
|
567
|
-
return {
|
|
568
|
-
stdout: new ReadableStream({
|
|
569
|
-
start(c) {
|
|
570
|
-
c.close()
|
|
571
|
-
},
|
|
572
|
-
}),
|
|
573
|
-
stderr: new ReadableStream({
|
|
574
|
-
start(c) {
|
|
575
|
-
c.enqueue(
|
|
576
|
-
encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
|
|
577
|
-
)
|
|
578
|
-
c.close()
|
|
579
|
-
},
|
|
580
|
-
}),
|
|
581
|
-
exited: Promise.resolve(255),
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const result = await getDashboardComposeStatus(secretHost, mockSpawn)
|
|
586
|
-
|
|
587
|
-
expect(result.ok).toBe(false)
|
|
588
|
-
expect(result.error).not.toContain(secretHost)
|
|
589
|
-
expect(result.error).not.toContain(secretHost.toLowerCase())
|
|
590
|
-
})
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
// ─── SSH identity injection (DASHBOARD_SSH_KEY) ───────────────────────────────
|
|
594
|
-
|
|
595
|
-
describe('getDashboardComposeStatus — SSH identity injection', () => {
|
|
596
|
-
let originalEnv: Record<string, string | undefined>
|
|
597
|
-
|
|
598
|
-
beforeEach(() => {
|
|
599
|
-
originalEnv = {DASHBOARD_SSH_KEY: process.env.DASHBOARD_SSH_KEY}
|
|
600
|
-
})
|
|
601
|
-
|
|
602
|
-
afterEach(() => {
|
|
603
|
-
if (originalEnv.DASHBOARD_SSH_KEY === undefined) {
|
|
604
|
-
delete process.env.DASHBOARD_SSH_KEY
|
|
605
|
-
} else {
|
|
606
|
-
process.env.DASHBOARD_SSH_KEY = originalEnv.DASHBOARD_SSH_KEY
|
|
607
|
-
}
|
|
608
|
-
})
|
|
609
|
-
|
|
610
|
-
it('includes -i <path> and IdentitiesOnly=yes in ssh argv when DASHBOARD_SSH_KEY is set', async () => {
|
|
611
|
-
process.env.DASHBOARD_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
612
|
-
|
|
613
|
-
let capturedCmd: string[] = []
|
|
614
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
615
|
-
capturedCmd = cmd
|
|
616
|
-
const enc = new TextEncoder()
|
|
617
|
-
return {
|
|
618
|
-
stdout: new ReadableStream({
|
|
619
|
-
start(c) {
|
|
620
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
|
|
621
|
-
c.close()
|
|
622
|
-
},
|
|
623
|
-
}),
|
|
624
|
-
stderr: new ReadableStream({
|
|
625
|
-
start(c) {
|
|
626
|
-
c.close()
|
|
627
|
-
},
|
|
628
|
-
}),
|
|
629
|
-
exited: Promise.resolve(0),
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
|
|
634
|
-
|
|
635
|
-
const iIdx = capturedCmd.indexOf('-i')
|
|
636
|
-
expect(iIdx).toBeGreaterThan(-1)
|
|
637
|
-
expect(capturedCmd[iIdx + 1]).toBeTruthy()
|
|
638
|
-
|
|
639
|
-
const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
|
|
640
|
-
expect(identitiesOnlyIdx).toBeGreaterThan(-1)
|
|
641
|
-
|
|
642
|
-
const destination = capturedCmd.find(arg => arg.includes('@'))
|
|
643
|
-
expect(destination).toBe('root@dashboard.fro.bot')
|
|
644
|
-
})
|
|
645
|
-
|
|
646
|
-
it('does not include -i or IdentitiesOnly=yes when DASHBOARD_SSH_KEY is absent', async () => {
|
|
647
|
-
delete process.env.DASHBOARD_SSH_KEY
|
|
648
|
-
|
|
649
|
-
let capturedCmd: string[] = []
|
|
650
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
651
|
-
capturedCmd = cmd
|
|
652
|
-
const enc = new TextEncoder()
|
|
653
|
-
return {
|
|
654
|
-
stdout: new ReadableStream({
|
|
655
|
-
start(c) {
|
|
656
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
|
|
657
|
-
c.close()
|
|
658
|
-
},
|
|
659
|
-
}),
|
|
660
|
-
stderr: new ReadableStream({
|
|
661
|
-
start(c) {
|
|
662
|
-
c.close()
|
|
663
|
-
},
|
|
664
|
-
}),
|
|
665
|
-
exited: Promise.resolve(0),
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
|
|
670
|
-
|
|
671
|
-
expect(capturedCmd.indexOf('-i')).toBe(-1)
|
|
672
|
-
expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
it('cleans up the temp key file after the SSH command completes', async () => {
|
|
676
|
-
const {statSync} = await import('node:fs')
|
|
677
|
-
process.env.DASHBOARD_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
678
|
-
|
|
679
|
-
let capturedKeyPath: string | undefined
|
|
680
|
-
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
681
|
-
const iIdx = cmd.indexOf('-i')
|
|
682
|
-
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
683
|
-
const enc = new TextEncoder()
|
|
684
|
-
return {
|
|
685
|
-
stdout: new ReadableStream({
|
|
686
|
-
start(c) {
|
|
687
|
-
c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
|
|
688
|
-
c.close()
|
|
689
|
-
},
|
|
690
|
-
}),
|
|
691
|
-
stderr: new ReadableStream({
|
|
692
|
-
start(c) {
|
|
693
|
-
c.close()
|
|
694
|
-
},
|
|
695
|
-
}),
|
|
696
|
-
exited: Promise.resolve(0),
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
|
|
701
|
-
|
|
702
|
-
expect(capturedKeyPath).toBeTruthy()
|
|
703
|
-
const keyPath = capturedKeyPath
|
|
704
|
-
if (keyPath) {
|
|
705
|
-
expect(() => statSync(keyPath)).toThrow()
|
|
706
|
-
}
|
|
707
|
-
})
|
|
708
|
-
})
|