@marcusrbrown/infra 0.4.9 → 0.4.11
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 +2 -2
- package/src/__snapshots__/cli.test.ts.snap +32 -1
- package/src/cli.ts +2 -0
- package/src/commands/gateway/backup.test.ts +288 -0
- package/src/commands/gateway/backup.ts +188 -0
- package/src/commands/gateway/deploy.test.ts +297 -0
- package/src/commands/gateway/deploy.ts +148 -0
- package/src/commands/gateway/host.test.ts +73 -0
- package/src/commands/gateway/host.ts +31 -0
- package/src/commands/gateway/index.ts +17 -0
- package/src/commands/gateway/logs.test.ts +222 -0
- package/src/commands/gateway/logs.ts +158 -0
- package/src/commands/gateway/restore.test.ts +494 -0
- package/src/commands/gateway/restore.ts +297 -0
- package/src/commands/gateway/status.test.ts +354 -0
- package/src/commands/gateway/status.ts +226 -0
- package/src/commands/status.test.ts +25 -0
- package/src/commands/status.ts +13 -3
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
5
|
+
import {validateGatewayHost} from './host'
|
|
6
|
+
|
|
7
|
+
declare const process: {
|
|
8
|
+
env: Record<string, string | undefined>
|
|
9
|
+
exitCode?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MITMPROXY_CERTS_VOLUME = 'mitmproxy-certs'
|
|
13
|
+
const CA_CERT_FILE = 'mitmproxy-ca-cert.pem'
|
|
14
|
+
const CA_KEY_FILE = 'mitmproxy-ca.pem'
|
|
15
|
+
const COMPOSE_PROJECT_DIR = '/opt/gateway/deploy'
|
|
16
|
+
const EXPECTED_ARCHIVE_FILES = new Set([CA_CERT_FILE, CA_KEY_FILE])
|
|
17
|
+
|
|
18
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export type RestoreSpawnFn = (
|
|
21
|
+
cmd: string[],
|
|
22
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
23
|
+
) => {
|
|
24
|
+
stdout: ReadableStream<Uint8Array>
|
|
25
|
+
stderr: ReadableStream<Uint8Array>
|
|
26
|
+
exited: Promise<number>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RestoreOpts {
|
|
30
|
+
host: string
|
|
31
|
+
input: string
|
|
32
|
+
includeCa: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RestoreResult = {ok: true; confirmed: true} | {ok: false; error: string}
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Returns the argv array for an SSH command to the given host. */
|
|
40
|
+
function sshCommand(host: string, remote: string): string[] {
|
|
41
|
+
return ['ssh', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', `root@${host}`, remote]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function spawnText(
|
|
45
|
+
spawn: RestoreSpawnFn,
|
|
46
|
+
cmd: string[],
|
|
47
|
+
env: Record<string, string>,
|
|
48
|
+
): Promise<{stdout: string; stderr: string; exitCode: number}> {
|
|
49
|
+
const child = spawn(cmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
50
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
51
|
+
new Response(child.stdout).text(),
|
|
52
|
+
new Response(child.stderr).text(),
|
|
53
|
+
child.exited,
|
|
54
|
+
])
|
|
55
|
+
return {stdout, stderr, exitCode}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validates that the local tarball contains exactly the two expected CA files
|
|
60
|
+
* and nothing else. Uses `tar -tf` to list contents without extracting.
|
|
61
|
+
* Returns an error string if invalid, or undefined if valid.
|
|
62
|
+
*/
|
|
63
|
+
export function validateBackupArchive(
|
|
64
|
+
inputPath: string,
|
|
65
|
+
spawn: RestoreSpawnFn,
|
|
66
|
+
env: Record<string, string>,
|
|
67
|
+
): Promise<string | undefined> {
|
|
68
|
+
return spawnText(spawn, ['tar', '-tf', inputPath], env).then(result => {
|
|
69
|
+
if (result.exitCode !== 0) {
|
|
70
|
+
return `Cannot read archive (tar -tf exit ${result.exitCode}): ${result.stderr.trim() || 'unknown error'}`
|
|
71
|
+
}
|
|
72
|
+
const listed = result.stdout
|
|
73
|
+
.split('\n')
|
|
74
|
+
.map(f => f.trim())
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
const listedSet = new Set(listed)
|
|
77
|
+
const missing = [...EXPECTED_ARCHIVE_FILES].filter(f => !listedSet.has(f))
|
|
78
|
+
const extra = listed.filter(f => !EXPECTED_ARCHIVE_FILES.has(f))
|
|
79
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
80
|
+
const parts: string[] = []
|
|
81
|
+
if (missing.length > 0) parts.push(`missing: ${missing.join(', ')}`)
|
|
82
|
+
if (extra.length > 0) parts.push(`unexpected: ${extra.join(', ')}`)
|
|
83
|
+
return `Backup archive is malformed — ${parts.join('; ')}`
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Core logic ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function defaultSpawn(
|
|
92
|
+
cmd: string[],
|
|
93
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
94
|
+
): ReturnType<RestoreSpawnFn> {
|
|
95
|
+
return Bun.spawn(cmd, opts) as ReturnType<RestoreSpawnFn>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function restoreGatewayCa(
|
|
99
|
+
opts: RestoreOpts,
|
|
100
|
+
spawn: RestoreSpawnFn = defaultSpawn,
|
|
101
|
+
): Promise<RestoreResult> {
|
|
102
|
+
if (!opts.includeCa) {
|
|
103
|
+
return {ok: false, error: 'Only CA restore is currently supported; --no-include-ca is not accepted'}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validate input file is non-empty before SSHing
|
|
107
|
+
const inputFile = Bun.file(opts.input)
|
|
108
|
+
const inputSize = inputFile.size
|
|
109
|
+
|
|
110
|
+
if (inputSize === 0) {
|
|
111
|
+
return {ok: false, error: `Input file is empty: ${opts.input}`}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Validate host before any SSH invocation
|
|
115
|
+
validateGatewayHost(opts.host)
|
|
116
|
+
|
|
117
|
+
const env: Record<string, string> = {
|
|
118
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
119
|
+
HOME: process.env.HOME ?? '/tmp',
|
|
120
|
+
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// COR1: Validate archive contents before touching the remote — fail fast before SCP
|
|
124
|
+
const archiveError = await validateBackupArchive(opts.input, spawn, env)
|
|
125
|
+
if (archiveError !== undefined) {
|
|
126
|
+
return {ok: false, error: archiveError}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SEC2: Generate an unguessable remote tmp path via mktemp on the droplet
|
|
130
|
+
const mktempResult = await spawnText(spawn, sshCommand(opts.host, 'mktemp -t gateway-ca-restore-XXXXXX.tar'), env)
|
|
131
|
+
if (mktempResult.exitCode !== 0) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `mktemp failed on remote (exit ${mktempResult.exitCode}): ${mktempResult.stderr.trim() || 'unknown error'}`,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const tmpRemote = mktempResult.stdout.trim()
|
|
138
|
+
|
|
139
|
+
// SCP the tarball to the unguessable remote path
|
|
140
|
+
const scpCmd = [
|
|
141
|
+
'scp',
|
|
142
|
+
'-o',
|
|
143
|
+
'BatchMode=yes',
|
|
144
|
+
'-o',
|
|
145
|
+
'StrictHostKeyChecking=yes',
|
|
146
|
+
opts.input,
|
|
147
|
+
`root@${opts.host}:${tmpRemote}`,
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
let primaryError: RestoreResult | undefined
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const scpResult = await spawnText(spawn, scpCmd, env)
|
|
154
|
+
if (scpResult.exitCode !== 0) {
|
|
155
|
+
primaryError = {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `SCP failed (exit ${scpResult.exitCode}): ${scpResult.stderr.trim() || 'unknown error'}`,
|
|
158
|
+
}
|
|
159
|
+
return primaryError
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Defense-in-depth: chmod 600 the SCP'd file on the remote before docker run
|
|
163
|
+
const chmodResult = await spawnText(spawn, sshCommand(opts.host, `chmod 600 ${tmpRemote}`), env)
|
|
164
|
+
if (chmodResult.exitCode !== 0) {
|
|
165
|
+
primaryError = {
|
|
166
|
+
ok: false,
|
|
167
|
+
error: `chmod 600 on remote failed (exit ${chmodResult.exitCode}): ${chmodResult.stderr.trim() || 'unknown error'}`,
|
|
168
|
+
}
|
|
169
|
+
return primaryError
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Extract the tarball into the mitmproxy-certs volume
|
|
173
|
+
const extractResult = await spawnText(
|
|
174
|
+
spawn,
|
|
175
|
+
sshCommand(
|
|
176
|
+
opts.host,
|
|
177
|
+
`docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/dst -v ${tmpRemote}:/src.tar:ro alpine sh -c 'tar -xf /src.tar -C /dst'`,
|
|
178
|
+
),
|
|
179
|
+
env,
|
|
180
|
+
)
|
|
181
|
+
if (extractResult.exitCode !== 0) {
|
|
182
|
+
primaryError = {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: `docker run extract failed (exit ${extractResult.exitCode}): ${extractResult.stderr.trim() || 'unknown error'}`,
|
|
185
|
+
}
|
|
186
|
+
return primaryError
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Restart mitmproxy and gateway services
|
|
190
|
+
const restartResult = await spawnText(
|
|
191
|
+
spawn,
|
|
192
|
+
sshCommand(opts.host, `docker compose --project-directory ${COMPOSE_PROJECT_DIR} restart mitmproxy gateway`),
|
|
193
|
+
env,
|
|
194
|
+
)
|
|
195
|
+
if (restartResult.exitCode !== 0) {
|
|
196
|
+
primaryError = {
|
|
197
|
+
ok: false,
|
|
198
|
+
error: `docker compose restart failed (exit ${restartResult.exitCode}): ${restartResult.stderr.trim() || 'unknown error'}`,
|
|
199
|
+
}
|
|
200
|
+
return primaryError
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Byte-equal confirmation: read cert and key from inside the volume
|
|
204
|
+
const [certResult, keyResult] = await Promise.all([
|
|
205
|
+
spawnText(
|
|
206
|
+
spawn,
|
|
207
|
+
sshCommand(opts.host, `docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine cat /src/${CA_CERT_FILE}`),
|
|
208
|
+
env,
|
|
209
|
+
),
|
|
210
|
+
spawnText(
|
|
211
|
+
spawn,
|
|
212
|
+
sshCommand(opts.host, `docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine cat /src/${CA_KEY_FILE}`),
|
|
213
|
+
env,
|
|
214
|
+
),
|
|
215
|
+
])
|
|
216
|
+
|
|
217
|
+
if (certResult.exitCode !== 0 || keyResult.exitCode !== 0) {
|
|
218
|
+
primaryError = {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `Confirmation read failed — cert exit ${certResult.exitCode}, key exit ${keyResult.exitCode}`,
|
|
221
|
+
}
|
|
222
|
+
return primaryError
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Extract cert and key from the local tarball for comparison
|
|
226
|
+
const localCertProc = Bun.spawnSync(['tar', '-xOf', opts.input, CA_CERT_FILE])
|
|
227
|
+
const localKeyProc = Bun.spawnSync(['tar', '-xOf', opts.input, CA_KEY_FILE])
|
|
228
|
+
|
|
229
|
+
const localCert = new TextDecoder().decode(localCertProc.stdout)
|
|
230
|
+
const localKey = new TextDecoder().decode(localKeyProc.stdout)
|
|
231
|
+
|
|
232
|
+
if (certResult.stdout !== localCert || keyResult.stdout !== localKey) {
|
|
233
|
+
primaryError = {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: `Confirmation mismatch: restored CA content does not match input tarball. The volume may be in an inconsistent state.`,
|
|
236
|
+
}
|
|
237
|
+
return primaryError
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {ok: true, confirmed: true}
|
|
241
|
+
} finally {
|
|
242
|
+
// Always clean up the tmp file from the droplet; never let cleanup mask the primary failure
|
|
243
|
+
try {
|
|
244
|
+
await spawnText(spawn, sshCommand(opts.host, `rm -f ${tmpRemote}`), env)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.error(`Cleanup also failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export function registerGatewayRestore(cli: ReturnType<typeof goke>): void {
|
|
254
|
+
cli
|
|
255
|
+
.command(
|
|
256
|
+
'gateway restore',
|
|
257
|
+
'Restore the mitmproxy CA certificate and private key from a local tarball into the gateway Docker volume. Restarts mitmproxy and gateway services after restore and confirms byte-equal content before reporting success.',
|
|
258
|
+
)
|
|
259
|
+
.option(
|
|
260
|
+
'--input <file>',
|
|
261
|
+
z.string().describe('Path to the tarball produced by `gateway backup --include-ca`. Required.'),
|
|
262
|
+
)
|
|
263
|
+
.option(
|
|
264
|
+
'--include-ca',
|
|
265
|
+
z
|
|
266
|
+
.boolean()
|
|
267
|
+
.default(true)
|
|
268
|
+
.describe('Restore the mitmproxy CA certificate and private key. Currently the only supported restore target.'),
|
|
269
|
+
)
|
|
270
|
+
.example('# Restore CA from a backup tarball')
|
|
271
|
+
.example('infra gateway restore --input apps/gateway/.local/mitmproxy-ca.tar')
|
|
272
|
+
.example('# Restore CA from a custom path')
|
|
273
|
+
.example('infra gateway restore --input /secure/backup/mitmproxy-ca.tar --include-ca')
|
|
274
|
+
.action(async options => {
|
|
275
|
+
const hostEnvKey = 'GATEWAY_HOST'
|
|
276
|
+
const host = process.env[hostEnvKey]
|
|
277
|
+
|
|
278
|
+
if (!host) {
|
|
279
|
+
console.error(`Gateway host not set. Export ${hostEnvKey} before running restore.`)
|
|
280
|
+
process.exitCode = 1
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const input = options.input
|
|
285
|
+
const includeCa = options.includeCa !== false
|
|
286
|
+
|
|
287
|
+
const result = await restoreGatewayCa({host, input, includeCa})
|
|
288
|
+
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
console.error(`Restore failed: ${result.error}`)
|
|
291
|
+
process.exitCode = 1
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log('CA restore complete. Byte-equal confirmation passed.')
|
|
296
|
+
})
|
|
297
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {parseComposePs, parseComposePsOutput, type ComposePsEntry, type ServiceRow} from './status'
|
|
4
|
+
|
|
5
|
+
// ─── parseComposePs ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe('parseComposePs', () => {
|
|
8
|
+
it('parses all 3 services running with gateway and mitmproxy healthy, workspace n-a', () => {
|
|
9
|
+
const raw: ComposePsEntry[] = [
|
|
10
|
+
{Name: 'gateway', State: 'running', Health: 'healthy'},
|
|
11
|
+
{Name: 'workspace', State: 'running', Health: ''},
|
|
12
|
+
{Name: 'mitmproxy', State: 'running', Health: 'healthy'},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const rows = parseComposePs(raw)
|
|
16
|
+
|
|
17
|
+
expect(rows).toHaveLength(3)
|
|
18
|
+
expect(rows[0]).toEqual({service: 'gateway', state: 'running', health: 'healthy'} satisfies ServiceRow)
|
|
19
|
+
expect(rows[1]).toEqual({service: 'workspace', state: 'running', health: 'n-a'} satisfies ServiceRow)
|
|
20
|
+
expect(rows[2]).toEqual({service: 'mitmproxy', state: 'running', health: 'healthy'} satisfies ServiceRow)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('shows exited state when a container has exited', () => {
|
|
24
|
+
const raw: ComposePsEntry[] = [
|
|
25
|
+
{Name: 'gateway', State: 'exited', Health: ''},
|
|
26
|
+
{Name: 'workspace', State: 'running', Health: ''},
|
|
27
|
+
{Name: 'mitmproxy', State: 'running', Health: 'healthy'},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const rows = parseComposePs(raw)
|
|
31
|
+
|
|
32
|
+
expect(rows[0]).toEqual({service: 'gateway', state: 'exited', health: 'n-a'} satisfies ServiceRow)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('maps unhealthy health status', () => {
|
|
36
|
+
const raw: ComposePsEntry[] = [{Name: 'gateway', State: 'running', Health: 'unhealthy'}]
|
|
37
|
+
|
|
38
|
+
const rows = parseComposePs(raw)
|
|
39
|
+
|
|
40
|
+
expect(rows[0]?.health).toBe('unhealthy')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('maps starting health status', () => {
|
|
44
|
+
const raw: ComposePsEntry[] = [{Name: 'gateway', State: 'running', Health: 'starting'}]
|
|
45
|
+
|
|
46
|
+
const rows = parseComposePs(raw)
|
|
47
|
+
|
|
48
|
+
expect(rows[0]?.health).toBe('starting')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns n-a for unknown health values', () => {
|
|
52
|
+
const raw: ComposePsEntry[] = [{Name: 'gateway', State: 'running', Health: 'something-weird'}]
|
|
53
|
+
|
|
54
|
+
const rows = parseComposePs(raw)
|
|
55
|
+
|
|
56
|
+
expect(rows[0]?.health).toBe('n-a')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns empty array for empty input', () => {
|
|
60
|
+
expect(parseComposePs([])).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ─── isAllRunning ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe('isAllRunning', () => {
|
|
67
|
+
it('returns true when all services are running', async () => {
|
|
68
|
+
const {isAllRunning} = await import('./status')
|
|
69
|
+
const rows: ServiceRow[] = [
|
|
70
|
+
{service: 'gateway', state: 'running', health: 'healthy'},
|
|
71
|
+
{service: 'workspace', state: 'running', health: 'n-a'},
|
|
72
|
+
{service: 'mitmproxy', state: 'running', health: 'healthy'},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
expect(isAllRunning(rows)).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('returns false when any service is not running', async () => {
|
|
79
|
+
const {isAllRunning} = await import('./status')
|
|
80
|
+
const rows: ServiceRow[] = [
|
|
81
|
+
{service: 'gateway', state: 'exited', health: 'n-a'},
|
|
82
|
+
{service: 'workspace', state: 'running', health: 'n-a'},
|
|
83
|
+
{service: 'mitmproxy', state: 'running', health: 'healthy'},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
expect(isAllRunning(rows)).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ─── getGatewayComposeStatus (SSH mocked) ────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
type SpawnFn = (
|
|
93
|
+
cmd: string[],
|
|
94
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
95
|
+
) => {
|
|
96
|
+
stdout: ReadableStream<Uint8Array>
|
|
97
|
+
stderr: ReadableStream<Uint8Array>
|
|
98
|
+
exited: Promise<number>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeSpawnOk(jsonOutput: string): SpawnFn {
|
|
102
|
+
return (_cmd, _opts) => {
|
|
103
|
+
const encoder = new TextEncoder()
|
|
104
|
+
return {
|
|
105
|
+
stdout: new ReadableStream({
|
|
106
|
+
start(controller) {
|
|
107
|
+
controller.enqueue(encoder.encode(jsonOutput))
|
|
108
|
+
controller.close()
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
stderr: new ReadableStream({
|
|
112
|
+
start(controller) {
|
|
113
|
+
controller.close()
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
exited: Promise.resolve(0),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function makeSpawnError(message: string): SpawnFn {
|
|
122
|
+
return (_cmd, _opts) => {
|
|
123
|
+
const encoder = new TextEncoder()
|
|
124
|
+
return {
|
|
125
|
+
stdout: new ReadableStream({
|
|
126
|
+
start(controller) {
|
|
127
|
+
controller.close()
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
stderr: new ReadableStream({
|
|
131
|
+
start(controller) {
|
|
132
|
+
controller.enqueue(encoder.encode(message))
|
|
133
|
+
controller.close()
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
exited: Promise.resolve(1),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('getGatewayComposeStatus — host validation (SEC1)', () => {
|
|
142
|
+
it('rejects a leading-hyphen host and does not invoke ssh', async () => {
|
|
143
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
144
|
+
|
|
145
|
+
const neverSpawn: SpawnFn = () => {
|
|
146
|
+
throw new Error('spawn must not be called for an invalid host')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await expect(getGatewayComposeStatus('-oProxyCommand=evil', neverSpawn)).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('rejects a host with shell metacharacters and does not invoke ssh', async () => {
|
|
153
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
154
|
+
|
|
155
|
+
const neverSpawn: SpawnFn = () => {
|
|
156
|
+
throw new Error('spawn must not be called for an invalid host')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await expect(getGatewayComposeStatus('gateway.example.com;rm -rf', neverSpawn)).rejects.toThrow(
|
|
160
|
+
'Invalid GATEWAY_HOST',
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('rejects an empty host and does not invoke ssh', async () => {
|
|
165
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
166
|
+
|
|
167
|
+
const neverSpawn: SpawnFn = () => {
|
|
168
|
+
throw new Error('spawn must not be called for an invalid host')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await expect(getGatewayComposeStatus('', neverSpawn)).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('accepts a valid FQDN and invokes ssh normally', async () => {
|
|
175
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
176
|
+
|
|
177
|
+
const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
|
|
178
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(psOutput))
|
|
179
|
+
|
|
180
|
+
expect(result.ok).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('accepts localhost as a valid host', async () => {
|
|
184
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
185
|
+
|
|
186
|
+
const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
|
|
187
|
+
const result = await getGatewayComposeStatus('localhost', makeSpawnOk(psOutput))
|
|
188
|
+
|
|
189
|
+
expect(result.ok).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('accepts an IPv4 address as a valid host', async () => {
|
|
193
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
194
|
+
|
|
195
|
+
const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
|
|
196
|
+
const result = await getGatewayComposeStatus('147.182.133.210', makeSpawnOk(psOutput))
|
|
197
|
+
|
|
198
|
+
expect(result.ok).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('getGatewayComposeStatus', () => {
|
|
203
|
+
let originalEnv: Record<string, string | undefined>
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
originalEnv = {GATEWAY_HOST: process.env.GATEWAY_HOST}
|
|
207
|
+
process.env.GATEWAY_HOST = 'gateway.example.com'
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
if (originalEnv.GATEWAY_HOST === undefined) {
|
|
212
|
+
delete process.env.GATEWAY_HOST
|
|
213
|
+
} else {
|
|
214
|
+
process.env.GATEWAY_HOST = originalEnv.GATEWAY_HOST
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('returns ok=true with 3 service rows when all services are running', async () => {
|
|
219
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
220
|
+
|
|
221
|
+
const psOutput = JSON.stringify([
|
|
222
|
+
{Name: 'gateway', State: 'running', Health: 'healthy'},
|
|
223
|
+
{Name: 'workspace', State: 'running', Health: ''},
|
|
224
|
+
{Name: 'mitmproxy', State: 'running', Health: 'healthy'},
|
|
225
|
+
])
|
|
226
|
+
|
|
227
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(psOutput))
|
|
228
|
+
|
|
229
|
+
expect(result.ok).toBe(true)
|
|
230
|
+
expect(result.services).toHaveLength(3)
|
|
231
|
+
expect(result.services[0]).toEqual({service: 'gateway', state: 'running', health: 'healthy'})
|
|
232
|
+
expect(result.services[1]).toEqual({service: 'workspace', state: 'running', health: 'n-a'})
|
|
233
|
+
expect(result.services[2]).toEqual({service: 'mitmproxy', state: 'running', health: 'healthy'})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('returns ok=false when gateway service is exited', async () => {
|
|
237
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
238
|
+
|
|
239
|
+
const psOutput = JSON.stringify([
|
|
240
|
+
{Name: 'gateway', State: 'exited', Health: ''},
|
|
241
|
+
{Name: 'workspace', State: 'running', Health: ''},
|
|
242
|
+
{Name: 'mitmproxy', State: 'running', Health: 'healthy'},
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(psOutput))
|
|
246
|
+
|
|
247
|
+
expect(result.ok).toBe(false)
|
|
248
|
+
expect(result.services[0]?.state).toBe('exited')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('returns ok=false with error when SSH fails', async () => {
|
|
252
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
253
|
+
|
|
254
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnError('Connection refused'))
|
|
255
|
+
|
|
256
|
+
expect(result.ok).toBe(false)
|
|
257
|
+
expect(result.error).toContain('SSH')
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ─── parseComposePsOutput ─────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
// Realistic fixture shape matching live droplet output
|
|
264
|
+
const ndjsonFixture = [
|
|
265
|
+
String.raw`{"Command":"\"docker-entrypoint.sh\"","CreatedAt":"2026-05-20 08:20:18 +0000 UTC","ExitCode":0,"Health":"healthy","ID":"01e1a16f5752","Image":"fro-bot-gateway","Labels":"","LocalVolumes":"0","Mounts":"","Name":"fro-bot-gateway-1","Names":"fro-bot-gateway-1","Networks":"gateway_default","Ports":"","Project":"gateway","Publishers":null,"RunningFor":"2 hours ago","Service":"gateway","Size":"0B","State":"running","Status":"Up 2 hours (healthy)"}`,
|
|
266
|
+
String.raw`{"Command":"\"mitmproxy\"","CreatedAt":"2026-05-20 08:20:18 +0000 UTC","ExitCode":0,"Health":"healthy","ID":"02b2c27f6863","Image":"fro-bot-mitmproxy","Labels":"","LocalVolumes":"0","Mounts":"","Name":"fro-bot-mitmproxy-1","Names":"fro-bot-mitmproxy-1","Networks":"gateway_default","Ports":"","Project":"gateway","Publishers":null,"RunningFor":"2 hours ago","Service":"mitmproxy","Size":"0B","State":"running","Status":"Up 2 hours (healthy)"}`,
|
|
267
|
+
String.raw`{"Command":"\"sleep infinity\"","CreatedAt":"2026-05-20 08:20:18 +0000 UTC","ExitCode":0,"Health":"","ID":"03c3d38g7974","Image":"fro-bot-workspace","Labels":"","LocalVolumes":"0","Mounts":"","Name":"fro-bot-workspace-1","Names":"fro-bot-workspace-1","Networks":"gateway_default","Ports":"","Project":"gateway","Publishers":null,"RunningFor":"2 hours ago","Service":"workspace","Size":"0B","State":"running","Status":"Up 2 hours"}`,
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
describe('parseComposePsOutput', () => {
|
|
271
|
+
it('parses NDJSON with 3 lines into 3 entries with correct Name/State/Health', () => {
|
|
272
|
+
const raw = ndjsonFixture.join('\n')
|
|
273
|
+
const entries = parseComposePsOutput(raw)
|
|
274
|
+
|
|
275
|
+
expect(entries).toHaveLength(3)
|
|
276
|
+
expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
|
|
277
|
+
expect(entries[0]?.State).toBe('running')
|
|
278
|
+
expect(entries[0]?.Health).toBe('healthy')
|
|
279
|
+
expect(entries[1]?.Name).toBe('fro-bot-mitmproxy-1')
|
|
280
|
+
expect(entries[1]?.State).toBe('running')
|
|
281
|
+
expect(entries[1]?.Health).toBe('healthy')
|
|
282
|
+
expect(entries[2]?.Name).toBe('fro-bot-workspace-1')
|
|
283
|
+
expect(entries[2]?.State).toBe('running')
|
|
284
|
+
expect(entries[2]?.Health).toBe('')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('parses legacy single JSON array format', () => {
|
|
288
|
+
const raw = JSON.stringify([
|
|
289
|
+
{Name: 'fro-bot-gateway-1', State: 'running', Health: 'healthy'},
|
|
290
|
+
{Name: 'fro-bot-mitmproxy-1', State: 'running', Health: 'healthy'},
|
|
291
|
+
])
|
|
292
|
+
const entries = parseComposePsOutput(raw)
|
|
293
|
+
|
|
294
|
+
expect(entries).toHaveLength(2)
|
|
295
|
+
expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
|
|
296
|
+
expect(entries[1]?.Name).toBe('fro-bot-mitmproxy-1')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('returns empty array for empty string', () => {
|
|
300
|
+
expect(parseComposePsOutput('')).toEqual([])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('returns empty array for whitespace-only input', () => {
|
|
304
|
+
expect(parseComposePsOutput(' \n \n ')).toEqual([])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('parses a single NDJSON line', () => {
|
|
308
|
+
const raw = ndjsonFixture.at(0) ?? ''
|
|
309
|
+
const entries = parseComposePsOutput(raw)
|
|
310
|
+
|
|
311
|
+
expect(entries).toHaveLength(1)
|
|
312
|
+
expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('handles trailing newline correctly', () => {
|
|
316
|
+
const raw = `${ndjsonFixture.join('\n')}\n`
|
|
317
|
+
const entries = parseComposePsOutput(raw)
|
|
318
|
+
|
|
319
|
+
expect(entries).toHaveLength(3)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('throws on a malformed NDJSON line', () => {
|
|
323
|
+
const raw = `${ndjsonFixture[0]}\nnot-valid-json\n${ndjsonFixture[2]}`
|
|
324
|
+
|
|
325
|
+
expect(() => parseComposePsOutput(raw)).toThrow()
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// ─── getGatewayComposeStatus — NDJSON integration ────────────────────────────
|
|
330
|
+
|
|
331
|
+
describe('getGatewayComposeStatus — NDJSON stdout', () => {
|
|
332
|
+
it('parses NDJSON docker compose ps output and returns correct services', async () => {
|
|
333
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
334
|
+
|
|
335
|
+
const ndjsonOutput = ndjsonFixture.join('\n')
|
|
336
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(ndjsonOutput))
|
|
337
|
+
|
|
338
|
+
expect(result.ok).toBe(true)
|
|
339
|
+
expect(result.services).toHaveLength(3)
|
|
340
|
+
expect(result.services[0]).toEqual({service: 'fro-bot-gateway-1', state: 'running', health: 'healthy'})
|
|
341
|
+
expect(result.services[1]).toEqual({service: 'fro-bot-mitmproxy-1', state: 'running', health: 'healthy'})
|
|
342
|
+
expect(result.services[2]).toEqual({service: 'fro-bot-workspace-1', state: 'running', health: 'n-a'})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('returns ok=false with error message when NDJSON contains a malformed line', async () => {
|
|
346
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
347
|
+
|
|
348
|
+
const badOutput = `${ndjsonFixture[0]}\nnot-valid-json`
|
|
349
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(badOutput))
|
|
350
|
+
|
|
351
|
+
expect(result.ok).toBe(false)
|
|
352
|
+
expect(result.error).toContain('Failed to parse docker compose ps output')
|
|
353
|
+
})
|
|
354
|
+
})
|