diff --git a/src/core/preflight.test.ts b/src/core/preflight.test.ts index 1bf1fda..ce25f5d 100644 --- a/src/core/preflight.test.ts +++ b/src/core/preflight.test.ts @@ -38,6 +38,18 @@ describe("requireLocalStackRunning", () => { // No stale advice to install/run a CLI. expect(result?.content[0].text).not.toMatch(/localstack start|lstk/); }); + + test("checks and reports a configured remote endpoint", async () => { + mockedGetGatewayHealth.mockResolvedValueOnce({ reachable: false, ready: false }); + const endpoint = "https://ls-example.sandbox.localstack.cloud"; + + const result = await requireLocalStackRunning(endpoint); + + expect(mockedGetGatewayHealth).toHaveBeenCalledWith(endpoint); + expect(result?.content[0].text).toContain(endpoint); + expect(result?.content[0].text).toMatch(/configured deployment endpoint/i); + expect(result?.content[0].text).not.toMatch(/localstack-management/); + }); }); describe("requireDockerDaemon", () => { diff --git a/src/core/preflight.ts b/src/core/preflight.ts index 74d078d..91e23cf 100644 --- a/src/core/preflight.ts +++ b/src/core/preflight.ts @@ -52,18 +52,24 @@ export const runPreflights = async ( return results.find((r) => r !== null) || null; }; -export const requireLocalStackRunning = async (): Promise => { +export const requireLocalStackRunning = async ( + targetEndpoint = LOCALSTACK_BASE_URL +): Promise => { /** * Probe the gateway directly instead of looking for a * specific container, so an externally managed runtime that is healthy and * reachable is not falsely reported as "not running". */ - const health = await getGatewayHealth(); + const health = await getGatewayHealth(targetEndpoint); if (!health.reachable) { + const guidance = + targetEndpoint === LOCALSTACK_BASE_URL + ? "Start it with the localstack-management tool (action: start) and try again. " + + "If it is running on a non-default host or port, set LOCALSTACK_HOSTNAME / LOCALSTACK_PORT for the MCP server." + : "Verify that the configured deployment endpoint is running and reachable."; return ResponseBuilder.error( "LocalStack Not Running", - `LocalStack is not reachable at ${LOCALSTACK_BASE_URL}. Start it with the localstack-management tool (action: start) and try again. ` + - `If it is running on a non-default host or port, set LOCALSTACK_HOSTNAME / LOCALSTACK_PORT for the MCP server.` + `LocalStack is not reachable at ${targetEndpoint}. ${guidance}` ); } return null; diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts index 8a436f8..c566943 100644 --- a/src/lib/localstack/localstack.utils.test.ts +++ b/src/lib/localstack/localstack.utils.test.ts @@ -118,6 +118,18 @@ describe("localstack.utils", () => { }); describe("getGatewayHealth", () => { + test("probes a configured HTTPS gateway", async () => { + mockedRequest.mockResolvedValueOnce({ services: { s3: "running" } } as any); + const endpoint = "https://ls-example.sandbox.localstack.cloud"; + + await getGatewayHealth(endpoint); + + expect(mockedRequest).toHaveBeenCalledWith( + "/_localstack/health", + expect.objectContaining({ baseUrl: endpoint }) + ); + }); + test("reports reachable + ready when the gateway answers with running services", async () => { mockedRequest.mockResolvedValueOnce({ services: { s3: "running", lambda: "available" }, diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index 5d59ff7..bf153d4 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -140,20 +140,24 @@ const READY_SERVICE_STATES = new Set(["available", "running"]); /** * Provenance-agnostic LocalStack detection. * - * Probes the LocalStack gateway health endpoint (`/_localstack/health`) directly over - * HTTP. Any container exposing the gateway on :4566 answers this — regardless of who - * started it (this server, `lstk`, docker-compose, raw `docker run`) or what the - * container is named. + * Probes the LocalStack gateway health endpoint (`/_localstack/health`) directly. + * The local gateway is used by default; callers can provide a remote HTTPS base URL. + * Any container exposing the gateway on :4566 answers this — regardless of who started + * it (this server, `lstk`, docker-compose, raw `docker run`) or what the container is named. * * This is the source of truth for "is LocalStack running?". */ -export async function getGatewayHealth(): Promise { +export async function getGatewayHealth(baseUrl = LOCALSTACK_BASE_URL): Promise { try { const data = await httpClient.request<{ services?: Record; edition?: string; version?: string; - }>("/_localstack/health", { method: "GET", timeout: GATEWAY_HEALTH_TIMEOUT }); + }>("/_localstack/health", { + method: "GET", + timeout: GATEWAY_HEALTH_TIMEOUT, + baseUrl, + }); if (!data || typeof data !== "object" || Array.isArray(data)) { return { reachable: false, ready: false }; diff --git a/src/tools-tests/localstack-deployer.test.ts b/src/tools-tests/localstack-deployer.test.ts new file mode 100644 index 0000000..288e03d --- /dev/null +++ b/src/tools-tests/localstack-deployer.test.ts @@ -0,0 +1,69 @@ +import localstackDeployer from "../tools/localstack-deployer"; +import { requireLocalStackRunning, runPreflights } from "../core/preflight"; + +jest.mock("../core/analytics", () => ({ + withToolAnalytics: (_name: string, _args: unknown, fn: () => unknown) => fn(), +})); + +jest.mock("../core/preflight", () => ({ + requireAuthToken: jest.fn().mockReturnValue(null), + requireLocalStackRunning: jest.fn().mockResolvedValue(null), + runPreflights: jest.fn().mockResolvedValue({ content: [] }), +})); + +const mockedRequireLocalStackRunning = requireLocalStackRunning as jest.MockedFunction< + typeof requireLocalStackRunning +>; +const mockedRunPreflights = runPreflights as jest.MockedFunction; + +describe("localstack-deployer preflight", () => { + const originalEndpoint = process.env.AWS_ENDPOINT_URL; + + afterEach(() => { + if (originalEndpoint === undefined) { + delete process.env.AWS_ENDPOINT_URL; + } else { + process.env.AWS_ENDPOINT_URL = originalEndpoint; + } + jest.clearAllMocks(); + }); + + test("checks the configured deployment endpoint", async () => { + process.env.AWS_ENDPOINT_URL = "https://ls-example.sandbox.localstack.cloud/"; + + await localstackDeployer({ + action: "deploy", + projectType: "terraform", + directory: undefined, + variables: undefined, + stackName: undefined, + templatePath: undefined, + s3Bucket: undefined, + resolveS3: undefined, + saveParams: undefined, + }); + + expect(mockedRequireLocalStackRunning).toHaveBeenCalledWith( + "https://ls-example.sandbox.localstack.cloud" + ); + expect(mockedRunPreflights).toHaveBeenCalledTimes(1); + }); + + test("keeps container-based CloudFormation actions on the local gateway", async () => { + process.env.AWS_ENDPOINT_URL = "https://ls-example.sandbox.localstack.cloud"; + + await localstackDeployer({ + action: "create-stack", + projectType: "auto", + directory: undefined, + variables: undefined, + stackName: undefined, + templatePath: undefined, + s3Bucket: undefined, + resolveS3: undefined, + saveParams: undefined, + }); + + expect(mockedRequireLocalStackRunning).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/src/tools/localstack-deployer.ts b/src/tools/localstack-deployer.ts index e96e4cc..06bb03c 100644 --- a/src/tools/localstack-deployer.ts +++ b/src/tools/localstack-deployer.ts @@ -110,7 +110,14 @@ export default async function localstackDeployer({ saveParams, }, async () => { - const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]); + const deploymentEndpoint = + action === "deploy" || action === "destroy" + ? process.env.AWS_ENDPOINT_URL?.trim().replace(/\/+$/, "") || undefined + : undefined; + const preflightError = await runPreflights([ + requireAuthToken(), + requireLocalStackRunning(deploymentEndpoint), + ]); if (preflightError) return preflightError; if (action === "create-stack") {