Skip to content
Merged
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
40 changes: 21 additions & 19 deletions README.md

Large diffs are not rendered by default.

34 changes: 31 additions & 3 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
},
{
"name": "localstack-cloud-pods",
"description": "Manages LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting"
"description": "Manages remote LocalStack Cloud Pods for saving, loading, and deleting cloud-backed state snapshots"
},
{
"name": "localstack-state-management",
"description": "Exports, imports, resets, and inspects LocalStack state with local file-based workflows on disk"
},
{
"name": "localstack-extensions",
Expand Down Expand Up @@ -176,9 +180,33 @@
},
{
"name": "cloud-pods",
"description": "Manage LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting",
"description": "Manage remote LocalStack Cloud Pods for saving, loading, and deleting cloud-backed snapshots",
"arguments": ["action", "pod_name"],
"text": "Please ${arguments.action} Cloud Pod in the LocalStack container with the pod name ${arguments.pod_name}."
"text": "Please ${arguments.action} Cloud Pod in the LocalStack container with the pod name ${arguments.pod_name}. Use Cloud Pods for remote cloud-backed snapshots; use LocalStack state management for local export/import files on disk."
},
{
"name": "localstack-state-export",
"description": "Export LocalStack state to a local file on disk",
"arguments": ["file_path", "services"],
"text": "Export my running LocalStack state to the local file ${arguments.file_path}. If services are provided (${arguments.services}), export only those services. Use this local file workflow instead of Cloud Pods because I want a disk file."
},
{
"name": "localstack-state-import",
"description": "Import LocalStack state from a local file on disk",
"arguments": ["file_path"],
"text": "Import LocalStack state from the local file ${arguments.file_path}. This is for local file-based restore; use Cloud Pods instead when I want remote cloud-backed snapshots."
},
{
"name": "localstack-state-inspect",
"description": "Inspect current LocalStack state locally",
"arguments": ["services"],
"text": "Inspect the current LocalStack state in JSON format. If services are provided (${arguments.services}), show only those services. Explain that this is local runtime state inspection and not a Cloud Pods remote snapshot operation."
},
{
"name": "localstack-state-reset",
"description": "Reset LocalStack state locally",
"arguments": ["services"],
"text": "Reset LocalStack state. If services are provided (${arguments.services}), reset only those services; otherwise reset all service state. Warn me that this is destructive and separate from deleting Cloud Pods."
},
{
"name": "extensions-list",
Expand Down
1 change: 1 addition & 0 deletions src/core/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
],
"localstack-chaos-injector": ["action", "rules_count", "latency_ms"],
"localstack-cloud-pods": ["action", "pod_name"],
"localstack-state-management": ["action", "has_file_path", "services_count"],
"localstack-deployer": [
"action",
"projectType",
Expand Down
1 change: 1 addition & 0 deletions src/lib/localstack/license-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum ProFeature {
EXTENSIONS = "localstack.platform.plugin/extensions",
REPLICATOR = "localstack.platform.plugin/replicator",
SNOWFLAKE = "localstack.aws.provider/snowflake:pro",
STATE_MANAGEMENT = "localstack.platform.plugin/snapshot",
}

export interface LicenseCheckResult {
Expand Down
120 changes: 118 additions & 2 deletions src/lib/localstack/localstack.client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LOCALSTACK_BASE_URL } from "../../core/config";
import { httpClient, HttpError } from "../../core/http-client";

export type ApiResult<T> =
Expand Down Expand Up @@ -58,6 +59,13 @@ export interface AppInspectorSetStatusResponse {
export type AppInspectorStatus = "enabled" | "disabled";
export type AppInspectorQuery = Record<string, string | number | undefined>;

export interface StateExportResult {
content: Buffer;
services: string[];
size: number;
contentLength?: number;
}

// Chaos API Client
export class ChaosApiClient {
private async makeRequest(
Expand Down Expand Up @@ -184,8 +192,116 @@ export class CloudPodsApiClient {
deletePod(podName: string) {
return this.makeRequest(`/_localstack/pods/${encodeURIComponent(podName)}`, "DELETE", true, {});
}
resetState() {
return this.makeRequest("/_localstack/state/reset", "POST", false, {});
}

// Local file-based State Management API Client
export class StateManagementApiClient {
private async requestResponse(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResult<Response>> {
try {
const response = await fetch(`${LOCALSTACK_BASE_URL}${endpoint}`, options);
if (!response.ok) {
return {
success: false,
message: await response.text(),
statusCode: response.status,
};
}
return { success: true, data: response };
} catch (error) {
return {
success: false,
message: `Failed to communicate with LocalStack State Management API: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}

private serviceQuery(services?: string[]) {
if (!services || services.length === 0) return "";
return `?services=${encodeURIComponent(services.join(","))}`;
}

async exportState(services?: string[]): Promise<ApiResult<StateExportResult>> {
const result = await this.requestResponse(
`/_localstack/pods/state${this.serviceQuery(services)}`,
{
method: "GET",
}
);
if (!result.success) return result;

const response = result.data;
const content = Buffer.from(await response.arrayBuffer());
const exportedServices = (response.headers.get("x-localstack-pod-services") ?? "")
.split(",")
.map((service) => service.trim())
.filter(Boolean);
const size = Number(response.headers.get("x-localstack-pod-size") ?? content.length);
const contentLength = Number(response.headers.get("content-length") ?? content.length);

return {
success: true,
data: {
content,
services: exportedServices,
size: Number.isNaN(size) ? content.length : size,
contentLength: Number.isNaN(contentLength) ? undefined : contentLength,
},
};
}

async importState(content: Buffer): Promise<ApiResult<string>> {
const body = content.buffer.slice(
content.byteOffset,
content.byteOffset + content.byteLength
) as ArrayBuffer;
const result = await this.requestResponse("/_localstack/pods", {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body,
});
if (!result.success) return result;
return { success: true, data: await result.data.text() };
}

async resetState(services?: string[]): Promise<ApiResult<void>> {
if (!services || services.length === 0) {
const result = await this.requestResponse("/_localstack/state/reset", { method: "POST" });
return result.success ? { success: true, data: undefined } : result;
}

for (const service of services) {
const result = await this.requestResponse(
`/_localstack/state/${encodeURIComponent(service)}/reset`,
{ method: "POST" }
);
if (!result.success) return result;
}

return { success: true, data: undefined };
}

async inspectState(): Promise<ApiResult<unknown>> {
try {
const data = await httpClient.request<unknown>("/_localstack/pods/state/metamodel", {
method: "GET",
});
return { success: true, data };
} catch (error) {
if (error instanceof HttpError) {
return {
success: false,
message: error.body || error.message,
statusCode: error.status,
};
}
return {
success: false,
message: `Failed to communicate with LocalStack State Management API: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}
}

Expand Down
128 changes: 128 additions & 0 deletions src/lib/localstack/state-management.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import fs from "fs";
import os from "os";
import path from "path";
import {
buildStateAnalyticsArgs,
filterInspectServices,
formatInspectResult,
normalizeServices,
validateStateManagementArgs,
} from "../../tools/localstack-state-management";

describe("localstack-state-management", () => {
describe("normalizeServices", () => {
it("accepts comma-delimited and array service inputs", () => {
expect(normalizeServices("s3, lambda, s3")).toEqual(["s3", "lambda"]);
expect(normalizeServices(["sqs", "sns", "sqs"])).toEqual(["sqs", "sns"]);
});
});

describe("validateStateManagementArgs", () => {
it("validates export with file path and services", () => {
const filePath = path.join(os.tmpdir(), "ls-state-export-test.zip");
const result = validateStateManagementArgs({
action: "export",
file_path: filePath,
services: ["s3", "lambda"],
} as any);

expect(result.error).toBeUndefined();
expect(result.outputPath).toBe(filePath);
expect(result.serviceList).toEqual(["s3", "lambda"]);
});

it("requires an existing file for import and rejects service filters", () => {
const filePath = path.join(os.tmpdir(), "ls-state-import-test.zip");
fs.writeFileSync(filePath, "state");

try {
const result = validateStateManagementArgs({
action: "import",
file_path: filePath,
services: "s3",
} as any);

expect(result.error?.content[0].text).toContain("Unsupported Service Filter");
} finally {
fs.unlinkSync(filePath);
}
});

it("validates service-level reset", () => {
const result = validateStateManagementArgs({
action: "reset",
services: ["s3", "sqs"],
} as any);

expect(result.error).toBeUndefined();
expect(result.serviceList).toEqual(["s3", "sqs"]);
});

it("validates inspect without requiring a file path", () => {
const result = validateStateManagementArgs({
action: "inspect",
} as any);

expect(result.error).toBeUndefined();
expect(result.serviceList).toEqual([]);
});
});

describe("buildStateAnalyticsArgs", () => {
it("does not include raw file paths or service names", () => {
const analyticsArgs = buildStateAnalyticsArgs({
action: "export",
file_path: "/tmp/customer-state.zip",
services: ["s3", "lambda"],
} as any);

expect(analyticsArgs).toEqual({
action: "export",
has_file_path: true,
services_count: 2,
});
expect(JSON.stringify(analyticsArgs)).not.toContain("/tmp/customer-state.zip");
expect(JSON.stringify(analyticsArgs)).not.toContain("lambda");
});
});

describe("filterInspectServices", () => {
it("filters account-scoped inspect data to selected services", () => {
const filtered = filterInspectServices(
{
"000000000000": {
s3: { buckets: ["test"] },
lambda: { functions: ["fn"] },
sqs: { queues: ["q"] },
},
},
["s3", "sqs"]
);

expect(filtered).toEqual({
"000000000000": {
s3: { buckets: ["test"] },
sqs: { queues: ["q"] },
},
});
});
});

describe("formatInspectResult", () => {
it("returns filtered JSON markdown for selected services", () => {
const result = formatInspectResult(
{
"000000000000": {
s3: { buckets: ["test"] },
lambda: { functions: ["fn"] },
},
},
["s3"]
);

expect(result.content[0].text).toContain("LocalStack State Inspect");
expect(result.content[0].text).toContain('"s3"');
expect(result.content[0].text).not.toContain('"lambda"');
});
});
});
19 changes: 6 additions & 13 deletions src/tools/localstack-cloud-pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { withToolAnalytics } from "../core/analytics";

// Define the schema for tool parameters
export const schema = {
action: z.enum(["save", "load", "delete", "reset"]).describe("The Cloud Pods action to perform."),
action: z
.enum(["save", "load", "delete"])
.describe(
"The Cloud Pods action to perform."
),

pod_name: z
.string()
Expand All @@ -34,7 +38,7 @@ export const schema = {
// Define tool metadata
export const metadata: ToolMetadata = {
name: "localstack-cloud-pods",
description: "Manages LocalStack Cloud Pods with following actions: save, load, delete, reset",
description: "Manages remote LocalStack Cloud Pods with following actions: save, load, delete",
annotations: {
title: "LocalStack Cloud Pods",
readOnlyHint: false,
Expand Down Expand Up @@ -127,17 +131,6 @@ export default async function localstackCloudPods({
return ResponseBuilder.success(`Cloud Pod '**${pod_name}**' has been permanently deleted.`);
}

case "reset": {
const result = await client.resetState();
if (!result.success) {
return ResponseBuilder.error("Cloud Pods Error", result.message);
}

return ResponseBuilder.markdown(
"⚠️ LocalStack state has been reset successfully. **All unsaved state has been permanently lost.**"
);
}

default:
return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`);
}
Expand Down
Loading
Loading