Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/core/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
14 changes: 10 additions & 4 deletions src/core/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,24 @@ export const runPreflights = async (
return results.find((r) => r !== null) || null;
};

export const requireLocalStackRunning = async (): Promise<ToolResponse | null> => {
export const requireLocalStackRunning = async (
targetEndpoint = LOCALSTACK_BASE_URL
): Promise<ToolResponse | null> => {
/**
* 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;
Expand Down
12 changes: 12 additions & 0 deletions src/lib/localstack/localstack.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
16 changes: 10 additions & 6 deletions src/lib/localstack/localstack.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GatewayHealth> {
export async function getGatewayHealth(baseUrl = LOCALSTACK_BASE_URL): Promise<GatewayHealth> {
try {
const data = await httpClient.request<{
services?: Record<string, string>;
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 };
Expand Down
69 changes: 69 additions & 0 deletions src/tools-tests/localstack-deployer.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof runPreflights>;

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);
});
});
9 changes: 8 additions & 1 deletion src/tools/localstack-deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down