@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.
@@ -0,0 +1,297 @@
1
+ import {resolve} from 'node:path'
2
+ import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
3
+
4
+ import {getGatewayDeployEnv, validateGatewayRemotePreconditions} from './deploy'
5
+
6
+ const cliDir = resolve(import.meta.dir, '../../..')
7
+
8
+ const envKeys = [
9
+ 'AWS_ACCESS_KEY_ID',
10
+ 'AWS_SECRET_ACCESS_KEY',
11
+ 'AWS_SESSION_TOKEN',
12
+ 'DISCORD_APPLICATION_ID',
13
+ 'DISCORD_GUILD_ID',
14
+ 'DISCORD_TOKEN',
15
+ 'GATEWAY_HOST',
16
+ 'HOME',
17
+ 'OBJECT_STORE_HOSTS',
18
+ 'PATH',
19
+ 'S3_BUCKET',
20
+ 'S3_ENDPOINT',
21
+ 'S3_REGION',
22
+ 'SSH_AUTH_SOCK',
23
+ ] as const
24
+
25
+ type ManagedEnvKey = (typeof envKeys)[number]
26
+
27
+ let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
28
+
29
+ function restoreManagedEnv(): void {
30
+ for (const key of envKeys) {
31
+ const value = originalEnv[key]
32
+
33
+ if (value === undefined) {
34
+ delete process.env[key]
35
+ continue
36
+ }
37
+
38
+ process.env[key] = value
39
+ }
40
+ }
41
+
42
+ function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
43
+ restoreManagedEnv()
44
+
45
+ for (const [key, value] of Object.entries(overrides)) {
46
+ if (value === undefined) {
47
+ delete process.env[key]
48
+ continue
49
+ }
50
+
51
+ process.env[key as ManagedEnvKey] = value
52
+ }
53
+ }
54
+
55
+ async function runDeployCommand(
56
+ args: string[],
57
+ envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
58
+ ): Promise<{stdout: string; stderr: string; exitCode: number}> {
59
+ const env: Record<string, string> = {}
60
+
61
+ for (const [key, value] of Object.entries(process.env)) {
62
+ if (value !== undefined) {
63
+ env[key] = value
64
+ }
65
+ }
66
+
67
+ // Apply overrides: undefined means explicitly unset the key
68
+ for (const [key, value] of Object.entries(envOverrides)) {
69
+ if (value === undefined) {
70
+ delete env[key]
71
+ } else {
72
+ env[key] = value
73
+ }
74
+ }
75
+
76
+ // Apply defaults only for keys not explicitly overridden
77
+ if (!Object.prototype.hasOwnProperty.call(envOverrides, 'HOME') && !env.HOME) {
78
+ env.HOME = '/tmp/test-home'
79
+ }
80
+
81
+ if (!Object.prototype.hasOwnProperty.call(envOverrides, 'PATH') && !env.PATH) {
82
+ env.PATH = '/usr/bin:/bin'
83
+ }
84
+
85
+ if (!Object.prototype.hasOwnProperty.call(envOverrides, 'SSH_AUTH_SOCK') && !env.SSH_AUTH_SOCK) {
86
+ env.SSH_AUTH_SOCK = '/tmp/test-sock'
87
+ }
88
+
89
+ env.NO_COLOR = '1'
90
+
91
+ const proc = Bun.spawn(['bun', 'src/cli.ts', 'gateway', 'deploy', ...args], {
92
+ cwd: cliDir,
93
+ env,
94
+ stdout: 'pipe',
95
+ stderr: 'pipe',
96
+ })
97
+
98
+ const [stdout, stderr, exitCode] = await Promise.all([
99
+ new Response(proc.stdout).text(),
100
+ new Response(proc.stderr).text(),
101
+ proc.exited,
102
+ ])
103
+
104
+ return {stdout, stderr, exitCode}
105
+ }
106
+
107
+ describe('gateway deploy', () => {
108
+ beforeEach(() => {
109
+ originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
110
+ })
111
+
112
+ afterEach(() => {
113
+ restoreManagedEnv()
114
+ })
115
+
116
+ describe('getGatewayDeployEnv', () => {
117
+ it('returns the expected deploy environment when required variables are present', () => {
118
+ setManagedEnv({
119
+ GATEWAY_HOST: 'gateway.example.com',
120
+ HOME: '/tmp/test-home',
121
+ PATH: '/usr/bin:/bin',
122
+ SSH_AUTH_SOCK: '/tmp/test-sock',
123
+ })
124
+
125
+ const env = getGatewayDeployEnv()
126
+
127
+ expect(env.GATEWAY_HOST).toBe('gateway.example.com')
128
+ expect(env.HOME).toBe('/tmp/test-home')
129
+ expect(env.PATH).toBe('/usr/bin:/bin')
130
+ expect(env.SSH_AUTH_SOCK).toBe('/tmp/test-sock')
131
+ })
132
+
133
+ it('throws when SSH_AUTH_SOCK is missing', () => {
134
+ setManagedEnv({
135
+ GATEWAY_HOST: 'gateway.example.com',
136
+ HOME: '/tmp/test-home',
137
+ PATH: '/usr/bin:/bin',
138
+ SSH_AUTH_SOCK: undefined,
139
+ })
140
+
141
+ expect(() => getGatewayDeployEnv()).toThrow('SSH_AUTH_SOCK')
142
+ })
143
+
144
+ it('throws when PATH is missing', () => {
145
+ setManagedEnv({
146
+ GATEWAY_HOST: 'gateway.example.com',
147
+ HOME: '/tmp/test-home',
148
+ PATH: undefined,
149
+ SSH_AUTH_SOCK: '/tmp/test-sock',
150
+ })
151
+
152
+ expect(() => getGatewayDeployEnv()).toThrow('PATH')
153
+ })
154
+
155
+ it('passes required gateway env vars to the child process', () => {
156
+ setManagedEnv({
157
+ AWS_ACCESS_KEY_ID: 'test-key-id',
158
+ AWS_SECRET_ACCESS_KEY: 'test-secret',
159
+ AWS_SESSION_TOKEN: undefined,
160
+ DISCORD_APPLICATION_ID: 'test-app-id',
161
+ DISCORD_GUILD_ID: 'test-guild-id',
162
+ DISCORD_TOKEN: 'test-token',
163
+ GATEWAY_HOST: 'gateway.example.com',
164
+ HOME: '/tmp/test-home',
165
+ OBJECT_STORE_HOSTS: undefined,
166
+ PATH: '/usr/bin:/bin',
167
+ S3_BUCKET: 'test-bucket',
168
+ S3_ENDPOINT: undefined,
169
+ S3_REGION: 'us-east-1',
170
+ SSH_AUTH_SOCK: '/tmp/test-sock',
171
+ })
172
+
173
+ const env = getGatewayDeployEnv()
174
+
175
+ expect(env.DISCORD_TOKEN).toBe('test-token')
176
+ expect(env.AWS_ACCESS_KEY_ID).toBe('test-key-id')
177
+ expect(env.AWS_SECRET_ACCESS_KEY).toBe('test-secret')
178
+ expect(env.DISCORD_APPLICATION_ID).toBe('test-app-id')
179
+ expect(env.DISCORD_GUILD_ID).toBe('test-guild-id')
180
+ expect(env.S3_BUCKET).toBe('test-bucket')
181
+ expect(env.S3_REGION).toBe('us-east-1')
182
+ expect(env.GATEWAY_HOST).toBe('gateway.example.com')
183
+ // Optional vars present (empty string when unset)
184
+ expect(env.S3_ENDPOINT).toBe('')
185
+ expect(env.OBJECT_STORE_HOSTS).toBe('')
186
+ expect(env.AWS_SESSION_TOKEN).toBe('')
187
+ // Core vars still present
188
+ expect(env.PATH).toBe('/usr/bin:/bin')
189
+ expect(env.HOME).toBe('/tmp/test-home')
190
+ expect(env.SSH_AUTH_SOCK).toBe('/tmp/test-sock')
191
+ })
192
+
193
+ it('forwards S3_ENDPOINT and OBJECT_STORE_HOSTS when present in process.env', () => {
194
+ setManagedEnv({
195
+ GATEWAY_HOST: 'gateway.example.com',
196
+ HOME: '/tmp/test-home',
197
+ OBJECT_STORE_HOSTS: 'r2.example.com minio.example.com',
198
+ PATH: '/usr/bin:/bin',
199
+ S3_ENDPOINT: 'https://r2.example.com',
200
+ SSH_AUTH_SOCK: '/tmp/test-sock',
201
+ })
202
+
203
+ const env = getGatewayDeployEnv()
204
+
205
+ expect(env.S3_ENDPOINT).toBe('https://r2.example.com')
206
+ expect(env.OBJECT_STORE_HOSTS).toBe('r2.example.com minio.example.com')
207
+ })
208
+
209
+ it('forwards AWS_SESSION_TOKEN when present in process.env', () => {
210
+ setManagedEnv({
211
+ AWS_SESSION_TOKEN: 'sts-temporary-token-value',
212
+ GATEWAY_HOST: 'gateway.example.com',
213
+ HOME: '/tmp/test-home',
214
+ PATH: '/usr/bin:/bin',
215
+ SSH_AUTH_SOCK: '/tmp/test-sock',
216
+ })
217
+
218
+ const env = getGatewayDeployEnv()
219
+
220
+ expect(env.AWS_SESSION_TOKEN).toBe('sts-temporary-token-value')
221
+ })
222
+ })
223
+
224
+ describe('validateGatewayRemotePreconditions', () => {
225
+ it('throws when gh is not available', () => {
226
+ const spy = spyOn(Bun, 'which').mockReturnValue(null)
227
+
228
+ try {
229
+ expect(() => validateGatewayRemotePreconditions()).toThrow('gh')
230
+ } finally {
231
+ spy.mockRestore()
232
+ }
233
+ })
234
+
235
+ it('does not throw when gh is available', () => {
236
+ const spy = spyOn(Bun, 'which').mockReturnValue('/usr/bin/gh')
237
+
238
+ try {
239
+ expect(() => validateGatewayRemotePreconditions()).not.toThrow()
240
+ } finally {
241
+ spy.mockRestore()
242
+ }
243
+ })
244
+ })
245
+
246
+ describe('--remote (default) dry-run', () => {
247
+ it('prints planned actions without invoking gh', async () => {
248
+ const {stdout, exitCode} = await runDeployCommand(['--dry-run'], {
249
+ HOME: '/tmp/test-home',
250
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
251
+ SSH_AUTH_SOCK: '/tmp/test-sock',
252
+ })
253
+
254
+ expect(exitCode).toBe(0)
255
+ expect(stdout).toContain('Dry run')
256
+ expect(stdout).toContain('Deploy Gateway')
257
+ })
258
+ })
259
+
260
+ describe('--local --dry-run', () => {
261
+ it('prints planned local actions without spawning bun run', async () => {
262
+ const {stdout, exitCode} = await runDeployCommand(['--local', '--dry-run'], {
263
+ HOME: '/tmp/test-home',
264
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
265
+ SSH_AUTH_SOCK: '/tmp/test-sock',
266
+ })
267
+
268
+ expect(exitCode).toBe(0)
269
+ expect(stdout).toContain('Dry run')
270
+ expect(stdout).toContain('local')
271
+ })
272
+
273
+ it('succeeds even when SSH_AUTH_SOCK is unset', async () => {
274
+ const {stdout, exitCode} = await runDeployCommand(['--local', '--dry-run'], {
275
+ HOME: '/tmp/test-home',
276
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
277
+ SSH_AUTH_SOCK: undefined,
278
+ })
279
+
280
+ expect(exitCode).toBe(0)
281
+ expect(stdout).toContain('Dry run')
282
+ })
283
+ })
284
+
285
+ describe('--local --force-recreate --dry-run', () => {
286
+ it('includes --force-recreate in the planned command', async () => {
287
+ const {stdout, exitCode} = await runDeployCommand(['--local', '--force-recreate', '--dry-run'], {
288
+ HOME: '/tmp/test-home',
289
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
290
+ SSH_AUTH_SOCK: '/tmp/test-sock',
291
+ })
292
+
293
+ expect(exitCode).toBe(0)
294
+ expect(stdout).toContain('--force-recreate')
295
+ })
296
+ })
297
+ })
@@ -0,0 +1,148 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {z} from 'zod'
4
+
5
+ declare const process: {
6
+ env: Record<string, string | undefined>
7
+ exitCode?: number
8
+ }
9
+
10
+ const REPO = 'marcusrbrown/infra'
11
+ const WORKFLOW_NAME = 'Deploy Gateway'
12
+ const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-gateway.yaml'
13
+
14
+ type CliInstance = ReturnType<typeof goke>
15
+
16
+ // ─── Env helpers ─────────────────────────────────────────────────────────────
17
+
18
+ export function getGatewayDeployEnv(): Record<string, string> {
19
+ const path = process.env.PATH
20
+ const home = process.env.HOME
21
+ const sshAuthSock = process.env.SSH_AUTH_SOCK
22
+
23
+ if (!path) {
24
+ throw new Error('PATH is required for local deploy')
25
+ }
26
+
27
+ if (!home) {
28
+ throw new Error('HOME is required for local deploy')
29
+ }
30
+
31
+ if (!sshAuthSock) {
32
+ throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
33
+ }
34
+
35
+ return {
36
+ PATH: path,
37
+ HOME: home,
38
+ SSH_AUTH_SOCK: sshAuthSock,
39
+ GATEWAY_HOST: process.env.GATEWAY_HOST ?? '',
40
+ DISCORD_TOKEN: process.env.DISCORD_TOKEN ?? '',
41
+ DISCORD_APPLICATION_ID: process.env.DISCORD_APPLICATION_ID ?? '',
42
+ DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID ?? '',
43
+ AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ?? '',
44
+ AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ?? '',
45
+ AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ?? '',
46
+ S3_BUCKET: process.env.S3_BUCKET ?? '',
47
+ S3_REGION: process.env.S3_REGION ?? '',
48
+ S3_ENDPOINT: process.env.S3_ENDPOINT ?? '',
49
+ OBJECT_STORE_HOSTS: process.env.OBJECT_STORE_HOSTS ?? '',
50
+ }
51
+ }
52
+
53
+ export function validateGatewayRemotePreconditions(): void {
54
+ if (!Bun.which('gh')) {
55
+ throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
56
+ }
57
+ }
58
+
59
+ // ─── Command registration ─────────────────────────────────────────────────────
60
+
61
+ export function registerGatewayDeploy(cli: CliInstance): void {
62
+ cli
63
+ .command(
64
+ 'gateway deploy',
65
+ 'Deploy the gateway. Default mode triggers the GitHub Deploy Gateway workflow, while --local runs apps/gateway deploy directly with Bun.',
66
+ )
67
+ .option(
68
+ '--local',
69
+ z
70
+ .boolean()
71
+ .default(false)
72
+ .describe('Run local deployment with Bun using apps/gateway instead of triggering GitHub Actions.'),
73
+ )
74
+ .option(
75
+ '--dry-run',
76
+ z
77
+ .boolean()
78
+ .default(false)
79
+ .describe(
80
+ 'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
81
+ ),
82
+ )
83
+ .option(
84
+ '--force-recreate',
85
+ z.boolean().default(false).describe('Force recreate containers. Forwarded only in local mode.'),
86
+ )
87
+ .example('# Trigger remote GitHub Actions deploy (default mode)')
88
+ .example('infra gateway deploy')
89
+ .example('# Validate local deploy preconditions and planned command')
90
+ .example('infra gateway deploy --local --dry-run')
91
+ .example('# Run local deploy with explicit SSH agent context')
92
+ .example('infra gateway deploy --local')
93
+ .action(async options => {
94
+ if (options.local) {
95
+ const command = [
96
+ 'bun',
97
+ 'run',
98
+ '--cwd',
99
+ 'apps/gateway',
100
+ 'deploy',
101
+ ...(options.forceRecreate ? ['--force-recreate'] : []),
102
+ ]
103
+
104
+ if (options.dryRun) {
105
+ console.log('Dry run: local gateway deploy')
106
+ console.log(`- command: ${command.join(' ')}`)
107
+ return
108
+ }
109
+
110
+ const env = getGatewayDeployEnv()
111
+
112
+ const child = Bun.spawn(command, {
113
+ env,
114
+ stdout: 'inherit',
115
+ stderr: 'inherit',
116
+ })
117
+
118
+ const exitCode = await child.exited
119
+ if (exitCode !== 0) {
120
+ throw new Error(`Local deploy failed with exit code ${exitCode}`)
121
+ }
122
+
123
+ return
124
+ }
125
+
126
+ validateGatewayRemotePreconditions()
127
+
128
+ if (options.dryRun) {
129
+ console.log('Dry run: remote gateway deploy')
130
+ console.log(`- command: gh workflow run "${WORKFLOW_NAME}" --repo ${REPO}`)
131
+ console.log(`- workflow URL: ${WORKFLOW_URL}`)
132
+ return
133
+ }
134
+
135
+ const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
136
+ stdout: 'inherit',
137
+ stderr: 'inherit',
138
+ })
139
+
140
+ const exitCode = await child.exited
141
+ if (exitCode !== 0) {
142
+ throw new Error(`Failed to trigger "${WORKFLOW_NAME}" workflow (exit code ${exitCode})`)
143
+ }
144
+
145
+ console.log(`Workflow triggered: ${WORKFLOW_URL}`)
146
+ console.log('Approve the gateway environment deployment in GitHub Actions to continue.')
147
+ })
148
+ }
@@ -0,0 +1,73 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+
3
+ import {validateGatewayHost} from './host'
4
+
5
+ // ─── validateGatewayHost ──────────────────────────────────────────────────────
6
+
7
+ describe('validateGatewayHost', () => {
8
+ // ── Valid inputs ────────────────────────────────────────────────────────────
9
+
10
+ it('accepts a standard FQDN', () => {
11
+ expect(() => validateGatewayHost('gateway.example.com')).not.toThrow()
12
+ expect(validateGatewayHost('gateway.example.com')).toBe('gateway.example.com')
13
+ })
14
+
15
+ it('accepts localhost', () => {
16
+ expect(() => validateGatewayHost('localhost')).not.toThrow()
17
+ })
18
+
19
+ it('accepts an IPv4 address', () => {
20
+ expect(() => validateGatewayHost('147.182.133.210')).not.toThrow()
21
+ })
22
+
23
+ it('accepts a single-character hostname', () => {
24
+ expect(() => validateGatewayHost('a')).not.toThrow()
25
+ })
26
+
27
+ it('accepts a hostname with hyphens', () => {
28
+ expect(() => validateGatewayHost('my-gateway.prod.example.com')).not.toThrow()
29
+ })
30
+
31
+ // ── Injection attacks ───────────────────────────────────────────────────────
32
+
33
+ it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
34
+ expect(() => validateGatewayHost('-oProxyCommand=evil')).toThrow('Invalid GATEWAY_HOST')
35
+ })
36
+
37
+ it('rejects a value with shell metacharacters (semicolon)', () => {
38
+ expect(() => validateGatewayHost('gateway.example.com;rm -rf')).toThrow('Invalid GATEWAY_HOST')
39
+ })
40
+
41
+ it('rejects a value with shell metacharacters (backtick)', () => {
42
+ expect(() => validateGatewayHost('gateway.example.com`id`')).toThrow('Invalid GATEWAY_HOST')
43
+ })
44
+
45
+ it('rejects a value with spaces', () => {
46
+ expect(() => validateGatewayHost('gateway example.com')).toThrow('Invalid GATEWAY_HOST')
47
+ })
48
+
49
+ it('rejects a value with an at-sign', () => {
50
+ expect(() => validateGatewayHost('user@gateway.example.com')).toThrow('Invalid GATEWAY_HOST')
51
+ })
52
+
53
+ // ── Empty / blank ───────────────────────────────────────────────────────────
54
+
55
+ it('rejects an empty string', () => {
56
+ expect(() => validateGatewayHost('')).toThrow('Invalid GATEWAY_HOST')
57
+ })
58
+
59
+ // ── Error message sanitization ──────────────────────────────────────────────
60
+
61
+ it('truncates the invalid value in the error message to ~30 chars', () => {
62
+ const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
63
+ let message = ''
64
+ try {
65
+ validateGatewayHost(longMalicious)
66
+ } catch (error) {
67
+ message = error instanceof Error ? error.message : String(error)
68
+ }
69
+ // The excerpt in the message should not exceed 30 chars of the original value
70
+ expect(message).toContain('Invalid GATEWAY_HOST')
71
+ expect(message.length).toBeLessThan(longMalicious.length + 50)
72
+ })
73
+ })
@@ -0,0 +1,31 @@
1
+ // ─── Gateway host validation ──────────────────────────────────────────────────
2
+ //
3
+ // Validates GATEWAY_HOST values before they are passed as ssh argv arguments.
4
+ // A value starting with `-` would be interpreted by ssh as an option flag,
5
+ // enabling ProxyCommand injection and local code execution.
6
+
7
+ const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
8
+
9
+ /**
10
+ * Validates a candidate GATEWAY_HOST value against a strict hostname allowlist.
11
+ *
12
+ * Accepts: hostnames, FQDNs, IPv4 addresses, `localhost`.
13
+ * Rejects: empty strings, values starting with `-`, and anything containing
14
+ * characters outside `[A-Za-z0-9.-]`.
15
+ *
16
+ * @throws {Error} with a sanitized excerpt of the invalid value.
17
+ * @returns The validated host string (unchanged).
18
+ */
19
+ export function validateGatewayHost(host: string): string {
20
+ if (!host) {
21
+ throw new Error('Invalid GATEWAY_HOST: value is empty')
22
+ }
23
+
24
+ if (!VALID_HOST_RE.test(host)) {
25
+ // Truncate to 30 chars and strip non-printable bytes before echoing back
26
+ const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
27
+ throw new Error(`Invalid GATEWAY_HOST: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
28
+ }
29
+
30
+ return host
31
+ }
@@ -0,0 +1,17 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {registerGatewayBackup} from './backup'
4
+ import {registerGatewayDeploy} from './deploy'
5
+ import {registerGatewayLogs} from './logs'
6
+ import {registerGatewayRestore} from './restore'
7
+ import {registerGatewayStatus} from './status'
8
+
9
+ export {getGatewayStatusSummary} from './status'
10
+
11
+ export function registerGatewayCommands(cli: ReturnType<typeof goke>): void {
12
+ registerGatewayStatus(cli)
13
+ registerGatewayDeploy(cli)
14
+ registerGatewayLogs(cli)
15
+ registerGatewayBackup(cli)
16
+ registerGatewayRestore(cli)
17
+ }