diff --git a/src/deploy/adapters/awsSsm.ts b/src/deploy/adapters/awsSsm.ts index d0ce4e8e..8e944616 100644 --- a/src/deploy/adapters/awsSsm.ts +++ b/src/deploy/adapters/awsSsm.ts @@ -75,14 +75,18 @@ export function parameterNameFor(varName: string, opts: AwsSsmOptions): string { // ── Helpers ──────────────────────────────────────────────────────────────── function which(bin: string): string | null { - const r = spawnSync('which', [bin], { encoding: 'utf-8' }); + const r = spawnSync('which', [bin], { encoding: 'utf-8', env: process.env }); return r.status === 0 ? r.stdout.trim() || null : null; } /** Region from the ambient aws CLI config, for picker defaults. */ export function detectAwsRegion(): string | null { + // env is passed on purpose (here, in which() and in spawnAsync): Bun only + // looks up the current PATH when env is given, and the tests put a stub aws + // first on PATH. Node behaves the same either way. const r = spawnSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8', + env: process.env, }); return r.status === 0 ? r.stdout.trim() || null : null; } @@ -93,7 +97,7 @@ function spawnAsync( stdin?: string, ): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); diff --git a/tests/deploy/awsSsm.test.ts b/tests/deploy/awsSsm.test.ts index 13434116..15b5d2f5 100644 --- a/tests/deploy/awsSsm.test.ts +++ b/tests/deploy/awsSsm.test.ts @@ -1,8 +1,7 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { spawnSync } from 'child_process'; import { awsSsmAdapter, leafFor, @@ -13,13 +12,29 @@ import { } from '../../src/deploy/adapters/awsSsm'; import { TargetConfig } from '../../src/deploy/adapter'; -// "Happy path" preflight needs the aws CLI on PATH (the binary check is the -// last step before ok:true). CI doesn't ship it, so gate that test. -// Config-error tests short-circuit before the binary check. -const HAS_AWS = spawnSync('which', ['aws']).status === 0; - const ROOT = join(tmpdir(), `capy-aws-ssm-adapter-${process.pid}`); +// No test here may start the real aws CLI: on CI runners it can take more +// than 5 s to start, and a deploy test would reach out to AWS. A stub aws goes +// first on PATH for the whole file. It answers `configure get region` with a +// fixed region and fails every other command at once. +const STUB_BIN = join(tmpdir(), `capy-aws-stub-${process.pid}`); +const STUB_REGION = 'stub-region-1'; +const realPath = process.env.PATH; +beforeAll(() => { + mkdirSync(STUB_BIN, { recursive: true }); + writeFileSync( + join(STUB_BIN, 'aws'), + `#!/bin/sh\nif [ "$1" = configure ]; then echo ${STUB_REGION}; exit 0; fi\necho "stub aws: no AWS calls in tests" >&2\nexit 1\n`, + { mode: 0o755 }, + ); + process.env.PATH = `${STUB_BIN}:${realPath}`; +}); +afterAll(() => { + process.env.PATH = realPath; + rmSync(STUB_BIN, { recursive: true, force: true }); +}); + beforeEach(() => { if (existsSync(ROOT)) rmSync(ROOT, { recursive: true, force: true }); mkdirSync(ROOT, { recursive: true }); @@ -86,6 +101,15 @@ describe('awsSsm — name mapping', () => { }); describe('awsSsm — detect', () => { + test('uses the region from the aws CLI (the stub, never the real aws)', async () => { + writeFileSync( + join(ROOT, 'main.tf'), + 'data "aws_ssm_parameter" "db" {\n name = "/capy/prod/database-url"\n}\n', + ); + const r = await awsSsmAdapter.detect(ROOT); + expect(r.options?.region).toBe(STUB_REGION); + }); + test('infers prefix + kebab naming from literal tf parameter names', async () => { mkdirSync(join(ROOT, 'infra'), { recursive: true }); writeFileSync( @@ -193,7 +217,7 @@ describe('awsSsm — preflight (config errors, no aws CLI needed)', () => { expect(r.reason).toContain('no vars'); }); - test.if(HAS_AWS)('valid config passes with aws on PATH', async () => { + test('valid config passes with aws on PATH', async () => { const r = await awsSsmAdapter.preflight(baseTarget(), { cwd: ROOT }); expect(r.ok).toBe(true); });