diff --git a/README.md b/README.md index 3e9ecbd..2d919ca 100644 --- a/README.md +++ b/README.md @@ -2,102 +2,85 @@ - MildStack Logo + MildStack Logo

-# MildStack - -> A lightweight, local-first AWS emulator built for developers. The best localstack alternative. - -MildStack is an open-source project that helps you run and test AWS-like services locally with a focus on speed, simplicity, and low resource usage. - -It is designed to be a practical alternative for local cloud development, without unnecessary overhead. - -## Why MildStack? - -Working with AWS-based applications locally can be slow, heavy, or fragmented. - -MildStack aims to make that experience better by being: - -- lightweight -- fast -- developer-friendly -- open-source -- easy to extend -- suitable for local development workflows - -## What it is +

+ The Lightweight, Drop-in Replacement for LocalStack.
+ Fast, Open Source, and Developer-First. +

-MildStack is being built as a small ecosystem around a core emulator. +

+ Website • + Key Features • + Supported Services • + Community +

-The project currently includes: +--- -- a Go-based core for the emulator and API -- a CLI for local control and startup -- a desktop app for a more visual experience and resource browsing -- a web presence for documentation and project info +## ⚡️ What is MildStack? -## Project goals +MildStack is a high-performance, local-first AWS emulator designed to streamline your cloud development workflow. Unlike heavy alternatives that require Docker and significant system resources, MildStack is built in **Go** for maximum efficiency and speed. -MildStack is intended to be: +Stop waiting for containers to spin up. Start building instantly with a local cloud that feels "mild" on your CPU but "spicy" on productivity. -- a local AWS-like emulator -- simple to run and use -- performant and memory-efficient -- modular by design -- consistent across services -- easy to evolve over time +## ✨ Why MildStack? -## Tech stack +- **🚀 Instant-On**: No Docker required. MildStack runs as a native binary, starting in milliseconds. +- **🖥️ Desktop App**: A beautiful, intuitive UI to browse S3 buckets, query DynamoDB tables, and monitor SQS queues without leaving your IDE. +- **🍃 Ultra-Lightweight**: Minimal RAM and CPU footprint. Keep your machine cool while simulating complex cloud architectures. +- **🔌 Drop-in Compatibility**: Works seamlessly with official AWS SDKs and CLI. Just change your endpoint URL. +- **📡 Offline-First**: Build and test your cloud applications on a plane, a train, or anywhere without an internet connection. +- **💰 100% Free**: No "Pro" tiers for basic features. Everything you need for local development, open-source and free. -The project is centered around: +## 🛠 Supported Services -- **Go** for the core runtime, API, and CLI -- **Gin** for the HTTP API -- **Charm** for the terminal UI and CLI experience -- **Electron** for the desktop app -- **React** for the website and docs +MildStack is rapidly evolving. We currently provide robust support for core AWS services: -## Design principles +| Service | Status | Features | +| :--- | :--- | :--- | +| **S3** | ✅ Active | Bucket management, Multipart uploads, Metadata support | +| **DynamoDB** | ✅ Active | Tables, GSI/LSI support, Rich querying & filtering | +| **SQS** | ✅ Active | Message queues, DLQ redrive, FIFO support | +| **SNS** | 📅 Planned | Topic publishing, basic subscriptions | +| **Lambda** | 📅 Planned | Local execution of serverless functions | +| **EventBridge** | 📅 Planned | Event-driven architecture simulation | -The core of the project follows: +## 📦 The Ecosystem -- **Domain-Driven Design** -- **Clean Architecture** -- **SOLID principles** +MildStack isn't just an emulator; it's a complete development environment: -The goal is to keep the emulator core independent from frameworks and easy to maintain as new services are added. +### 1. The Core Engine +Written in Go, our core provides a high-concurrency, low-latency API that mimics AWS service behavior with precision. -## Philosophy +### 2. The MildStack CLI +A modern, terminal-based control center (powered by Charm/BubbleTea) to manage your local instances, view logs, and monitor service health. -MildStack is built around a few simple ideas: +### 3. The Desktop Browser +An Electron-powered visual console that gives you a "Production-like" experience for inspecting your local resources. Browse objects, edit items, and peek at messages with ease. -- local-first -- developer-first -- performance-oriented -- minimal overhead -- clear architecture -- open-source friendly +--- -## Current status +## 🗺 Roadmap -MildStack is still in early development. +Our goal is to cover the 80% of AWS services used in 95% of applications. Check our [Roadmap](https://mildstack.dev/roadmap) to see what's coming next, including Lambda support, IAM simulation, and more. -This README is intentionally lightweight and provisional until the project has proper installation docs, usage guides, and service-specific documentation. +## 🤝 Contributing -## Contributing +We love contributors! Whether you're fixing a bug, adding a new service, or improving the documentation, your help is welcome. -Contributions are welcome. +1. Check out our [Contribution Guidelines](CONTRIBUTING.md). +2. Join our [Discord community](https://discord.gg/your-invite) to discuss ideas. +3. Spread the word! 🌟 -If you want to help, you can: +## 📄 License -- suggest services to emulate first -- review architecture decisions -- improve documentation -- test the project locally -- help build core features +MildStack is released under the **MIT License**. Build freely. -## License +--- -MIT. +

+ Built with ❤️ for developers by Michel and the community. +

diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a7691a9..a2fb56d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,153 +1,56 @@ -# MildStack App +# 🖥️ MildStack Desktop -MildStack App is an open source desktop app for browsing and inspecting resources from MildStack, a localstack-like local AWS environment. +**The Visual Console for your Local Cloud.** -The project is currently in its foundation stage. It starts from an electron-vite Electron, -React, and TypeScript application and is being shaped into a local developer console for -MildStack workflows. +MildStack Desktop is a cross-platform companion app for [MildStack](https://github.com/michasdev/mildstack). It provides an intuitive, production-grade interface to manage, browse, and inspect your local AWS-compatible resources without touching the command line. -## What it is +

+ MildStack Desktop Interface +

-MildStack App gives developers a desktop UI for local MildStack resources so they do not -need to reach for raw command-line checks for every inspection task. +## ✨ Features -The version 1 target is local MildStack resource browsing. For version 1, real AWS account -management is not supported for the version 1 local MildStack browsing scope. +- **📂 S3 Explorer**: Browse buckets, navigate prefixes (folders), upload objects, and inspect metadata with a modern file-manager experience. +- **📊 DynamoDB Browser**: Query tables using a rich UI, filter items, edit attributes, and visualize your data structures instantly. +- **📩 SQS Monitor**: Peek at messages, monitor queue depths, and manage Dead Letter Queues (DLQ) with ease. +- **🛠 Instance Management**: Start, stop, and switch between multiple MildStack runtime instances directly from the UI. -## Scope +## 🏗 Built for Developers -Current scope: +MildStack Desktop is built with a modern stack optimized for performance and safety: -- Cross-platform Electron desktop app using React and TypeScript. -- Contributor documentation, local setup, and build scripts from the existing project. -- A strict Electron boundary where privileged MildStack communication belongs outside the - renderer. +- **Electron & Vite**: Fast startup and smooth transitions. +- **React & TypeScript**: Robust, type-safe UI components. +- **IPC Safety**: Privileged communication with the MildStack core is handled outside the renderer process for maximum security. +- **Clean Architecture**: Decoupled features that allow for rapid extension to new AWS services. -Deferred roadmap work, not current setup: +## 🗺 Roadmap -- Tailwind CSS renderer styling. -- CossUI setup through the planned shadcn CLI path. -- Multipage desktop navigation. -- Typed MildStack IPC from renderer to preload to main. -- Generic MildStack resource browsing. -- Dedicated S3 explorer. -- Dedicated DynamoDB explorer. +We are continuously adding new capabilities to the desktop experience: -No current local setup step depends on these deferred items. +- [x] S3 Bucket & Object Browsing +- [x] DynamoDB Table & Item Exploration +- [x] SQS Message Peeking +- [ ] Lambda Log Stream Monitoring +- [ ] IAM Policy Visualizer +- [ ] CloudFormation Stack Viewer -## How it works +--- -The app is split across the standard Electron process boundaries: +## 🤝 Contributing -- `src/main/` owns the Electron main process, native window lifecycle, privileged IPC handlers, - and future MildStack command coordination. -- `src/preload/` owns the safe bridge exposed to the renderer. -- `src/renderer/src/` owns the React and TypeScript UI that runs in the renderer process. +We welcome contributions to the desktop app! Whether it's improving the UI, adding new service browsers, or fixing bugs. -Renderer code must not import Electron directly or call shell APIs directly. MildStack -communication will be implemented behind Electron main/preload APIs in a later phase, then -exposed to React through a typed renderer-safe contract. +1. Fork the repository. +2. Check the [Local Setup Guide](https://github.com/michasdev/mildstack/blob/main/apps/desktop/CONTRIBUTING.md) (coming soon). +3. Submit a PR! -## Local setup +## 📄 License -Install dependencies: +MIT. Part of the MildStack ecosystem. -```bash -npm install -``` +--- -Start the local Electron development app: - -```bash -npm run dev -``` - -Preview the built Electron app: - -```bash -npm run start -``` - -## Available scripts - -The current `package.json` scripts are: - -- `npm run format` - Format the repository with Prettier. -- `npm run lint` - Run ESLint. -- `npm run typecheck:node` - Typecheck Electron main, preload, and build config code. -- `npm run typecheck:web` - Typecheck the React renderer and preload type declarations. -- `npm run typecheck` - Run node and web typechecks. -- `npm run start` - Preview the Electron app with electron-vite. -- `npm run dev` - Start the Electron development app. -- `npm run build` - Typecheck and build with electron-vite. -- `npm run postinstall` - Install Electron Builder app dependencies. -- `npm run build:unpack` - Build and produce an unpacked Electron Builder output. -- `npm run build:win` - Build a Windows package. -- `npm run build:mac` - Build a macOS package. -- `npm run build:linux` - Build Linux packages. - -## Project structure - -```text -src/ - main/ Electron main process - preload/ Renderer-safe preload bridge - renderer/ - index.html Renderer HTML shell - src/ React renderer source -resources/ Desktop app resources -build/ Packaging support files -``` - -Configuration lives in: - -- `electron.vite.config.ts` - Electron Vite main, preload, and renderer build config. -- `tsconfig.node.json` - TypeScript config for Electron-side code. -- `tsconfig.web.json` - TypeScript config for renderer-side code. -- `electron-builder.yml` - Cross-platform packaging config. - -## Roadmap - -Version 1 is planned as a sequence of foundation and browsing work: - -1. Project and renderer foundation: README, renderer structure, and renderer aliases. -2. Tailwind and CossUI setup: planned styling and component foundation. -3. Desktop app shell and navigation: planned multipage app shell. -4. Typed MildStack Electron API: planned renderer to preload to main communication. -5. Generic resource browser: planned local MildStack service and resource inspection. -6. S3 explorer: planned dedicated bucket, prefix, object, and metadata browsing. -7. DynamoDB explorer: planned dedicated table, metadata, and item browsing. - -These roadmap items describe planned work, not current completed functionality. - -## Contributing - -MildStack App is intended to be public open source software. Contributions should preserve -the desktop Electron target, keep renderer code browser-safe, and document only behavior that -exists in the current codebase unless it is clearly marked as planned roadmap work. - -Use the current npm scripts for validation before submitting changes: - -```bash -npm run typecheck -npm run lint -``` - -## S3 smoke test - -The app now includes a smoke runner for the MildStack S3 surface: - -```bash -npm run s3:smoke -``` - -By default it targets the local MildStack runtime API at -`http://127.0.0.1:4566/api/v1/runtime/services/s3`. Override that with -`MILDSTACK_API_BASE_URL` if your server is running elsewhere. - -An optional `--native` mode is also available for S3-compatible endpoints via -`@aws-sdk/client-s3`: - -```bash -npm run s3:smoke -- --native -``` +

+ Stop guessing. Start seeing. 🚀 +

diff --git a/apps/desktop/scripts/dynamo-smoke.cjs b/apps/desktop/scripts/dynamo-smoke.cjs index 2f9b2ac..cf23c4e 100644 --- a/apps/desktop/scripts/dynamo-smoke.cjs +++ b/apps/desktop/scripts/dynamo-smoke.cjs @@ -42,7 +42,7 @@ main().catch((error) => { }); async function main() { - const endpoint = process.env.MILDSTACK_DYNAMODB_ENDPOINT || process.env.AWS_DYNAMODB_ENDPOINT || `http://localhost:${port}`; + const endpoint = process.env.MILDSTACK_DYNAMODB_ENDPOINT || `http://localhost:${port}`; console.log(`Running AWS SDK smoke mode against ${endpoint}`); const client = new DynamoDBClient({ diff --git a/apps/desktop/scripts/s3-smoke.cjs b/apps/desktop/scripts/s3-smoke.cjs index a6be95e..247e5b1 100644 --- a/apps/desktop/scripts/s3-smoke.cjs +++ b/apps/desktop/scripts/s3-smoke.cjs @@ -43,7 +43,7 @@ function expectDefined(actual, message) { } async function main() { - const endpoint = process.env.MILDSTACK_S3_ENDPOINT || process.env.AWS_S3_ENDPOINT || `http://localhost:${port}`; + const endpoint = process.env.MILDSTACK_S3_ENDPOINT || `http://localhost:${port}`; console.log(`Running S3 behavioral validation against ${endpoint}`); const client = new S3Client({ diff --git a/apps/desktop/scripts/sqs-smoke.cjs b/apps/desktop/scripts/sqs-smoke.cjs index deda7ef..37a4af2 100644 --- a/apps/desktop/scripts/sqs-smoke.cjs +++ b/apps/desktop/scripts/sqs-smoke.cjs @@ -45,7 +45,7 @@ main().catch((error) => { }); async function main() { - const endpoint = process.env.MILDSTACK_SQS_ENDPOINT || process.env.AWS_SQS_ENDPOINT || `http://localhost:${port}`; + const endpoint = process.env.MILDSTACK_SQS_ENDPOINT || `http://localhost:${port}`; console.log(`Running AWS SDK smoke mode against ${endpoint}`); const client = new SQSClient({ diff --git a/apps/desktop/src/main/dynamodb-ipc.ts b/apps/desktop/src/main/dynamodb-ipc.ts index 3f12cad..bb62ac2 100644 --- a/apps/desktop/src/main/dynamodb-ipc.ts +++ b/apps/desktop/src/main/dynamodb-ipc.ts @@ -1,4 +1,4 @@ -import { getActiveInstancePort } from './instance-state' +import { resolveLocalEndpoint } from './local-endpoint' import { registerValidatedHandler } from './ipc-middleware' import { DynamoDBClient, @@ -269,8 +269,7 @@ function getClient(region = 'us-east-1'): DynamoDBClient { } function resolveDynamoDBEndpoint(): string { - const port = getActiveInstancePort() - return process.env.MILDSTACK_DYNAMODB_ENDPOINT || process.env.AWS_DYNAMODB_ENDPOINT || `http://127.0.0.1:${port}` + return resolveLocalEndpoint('dynamodb') } function normalizeRegion(region?: string): string { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 900ba4b..2c08efe 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,6 +5,7 @@ import { autoUpdater } from 'electron-updater' import icon from '../../build/icon.png?asset' import { registerS3IpcHandlers } from './s3-ipc' import { registerDynamoDBIpcHandlers } from './dynamodb-ipc' +import { registerSQSIpcHandlers } from './sqs-ipc' import { registerMildStackIpcHandlers } from './mildstack-ipc' // Set app name for macOS Dock and Menu Bar as early as possible @@ -82,6 +83,7 @@ app.whenReady().then(() => { ipcMain.on('ping', () => console.log('pong')) registerS3IpcHandlers() registerDynamoDBIpcHandlers() + registerSQSIpcHandlers() registerMildStackIpcHandlers() createWindow() diff --git a/apps/desktop/src/main/local-endpoint.ts b/apps/desktop/src/main/local-endpoint.ts new file mode 100644 index 0000000..b3e8c4d --- /dev/null +++ b/apps/desktop/src/main/local-endpoint.ts @@ -0,0 +1,13 @@ +import { getActiveInstancePort } from './instance-state' + +export function resolveLocalEndpoint(serviceName: string): string { + const normalizedServiceName = serviceName.trim().toUpperCase() + const envKey = `MILDSTACK_${normalizedServiceName}_ENDPOINT` + const override = process.env[envKey]?.trim() + if (override) { + return override + } + + const port = getActiveInstancePort() + return `http://localhost:${port}` +} diff --git a/apps/desktop/src/main/s3-ipc.ts b/apps/desktop/src/main/s3-ipc.ts index 1a5ad4c..3a4397e 100644 --- a/apps/desktop/src/main/s3-ipc.ts +++ b/apps/desktop/src/main/s3-ipc.ts @@ -1,4 +1,4 @@ -import { getActiveInstancePort } from './instance-state' +import { resolveLocalEndpoint } from './local-endpoint' import { registerValidatedHandler } from './ipc-middleware' import { S3Client, @@ -212,8 +212,7 @@ function getClient(region = 'us-east-1'): S3Client { } function resolveS3Endpoint(): string { - const port = getActiveInstancePort() - return process.env.MILDSTACK_S3_ENDPOINT || process.env.AWS_S3_ENDPOINT || `http://127.0.0.1:${port}` + return resolveLocalEndpoint('s3') } function normalizeRegion(region?: string): string { diff --git a/apps/desktop/src/main/sqs-ipc.ts b/apps/desktop/src/main/sqs-ipc.ts new file mode 100644 index 0000000..cba1fe9 --- /dev/null +++ b/apps/desktop/src/main/sqs-ipc.ts @@ -0,0 +1,186 @@ +import { resolveLocalEndpoint } from './local-endpoint' +import { registerValidatedHandler } from './ipc-middleware' +import { + SQSClient, + ListQueuesCommand, + CreateQueueCommand, + DeleteQueueCommand, + GetQueueAttributesCommand, + SetQueueAttributesCommand, + SendMessageCommand, + ReceiveMessageCommand, + DeleteMessageCommand, + PurgeQueueCommand +} from '@aws-sdk/client-sqs' + +type SQSClientCacheEntry = { + region: string + endpoint: string + client: SQSClient +} + +const clientCache = new Map() + +export function registerSQSIpcHandlers(): void { + registerValidatedHandler('sqs:listQueues', async (_event, args: { region?: string }) => { + const client = getClient(args.region) + const response = await client.send(new ListQueuesCommand({})) + const queueUrls = response.QueueUrls ?? [] + + // Fetch attributes for each queue to build the summary + const summaries = await Promise.all( + queueUrls.map(async (url) => { + const attrResponse = await client.send(new GetQueueAttributesCommand({ + QueueUrl: url, + AttributeNames: ['All'] + })) + const attributes = attrResponse.Attributes || {} + + // Extract queue name from URL (last part after /) + const queueName = url.split('/').pop() || url + + return { + QueueUrl: url, + QueueName: queueName, + IsFifo: queueName.endsWith('.fifo'), + MessagesAvailable: parseInt(attributes.ApproximateNumberOfMessages || '0', 10), + MessagesDelayed: parseInt(attributes.ApproximateNumberOfMessagesDelayed || '0', 10), + MessagesInvisible: parseInt(attributes.ApproximateNumberOfMessagesNotVisible || '0', 10), + VisibilityTimeout: parseInt(attributes.VisibilityTimeout || '30', 10) + } + }) + ) + + return summaries + }) + + registerValidatedHandler('sqs:createQueue', async (_event, args: { queueName: string; isFifo?: boolean; region?: string }) => { + const attributes: Record = {} + let name = args.queueName + + if (args.isFifo) { + if (!name.endsWith('.fifo')) name += '.fifo' + attributes.FifoQueue = 'true' + } + + const response = await getClient(args.region).send(new CreateQueueCommand({ + QueueName: name, + Attributes: Object.keys(attributes).length > 0 ? attributes : undefined + })) + return response.QueueUrl + }) + + registerValidatedHandler('sqs:deleteQueue', async (_event, args: { queueUrl: string; region?: string }) => { + await getClient(args.region).send(new DeleteQueueCommand({ QueueUrl: args.queueUrl })) + return null + }) + + registerValidatedHandler('sqs:getQueueAttributes', async (_event, args: { queueUrl: string; region?: string }) => { + const response = await getClient(args.region).send(new GetQueueAttributesCommand({ + QueueUrl: args.queueUrl, + AttributeNames: ['All'] + })) + return response.Attributes || {} + }) + + registerValidatedHandler('sqs:setQueueAttributes', async (_event, args: { queueUrl: string; attributes: Record; region?: string }) => { + await getClient(args.region).send(new SetQueueAttributesCommand({ + QueueUrl: args.queueUrl, + Attributes: args.attributes + })) + return null + }) + + registerValidatedHandler('sqs:purgeQueue', async (_event, args: { queueUrl: string; region?: string }) => { + await getClient(args.region).send(new PurgeQueueCommand({ QueueUrl: args.queueUrl })) + return null + }) + + registerValidatedHandler('sqs:sendMessage', async (_event, args: { + queueUrl: string + body: string + delaySeconds?: number + messageGroupId?: string + messageDeduplicationId?: string + messageAttributes?: Record + region?: string + }) => { + const response = await getClient(args.region).send(new SendMessageCommand({ + QueueUrl: args.queueUrl, + MessageBody: args.body, + DelaySeconds: args.delaySeconds, + MessageGroupId: args.messageGroupId, + MessageDeduplicationId: args.messageDeduplicationId, + MessageAttributes: args.messageAttributes + })) + return response.MessageId + }) + + registerValidatedHandler('sqs:receiveMessages', async (_event, args: { + queueUrl: string + maxMessages?: number + waitTimeSeconds?: number + region?: string + }) => { + const response = await getClient(args.region).send(new ReceiveMessageCommand({ + QueueUrl: args.queueUrl, + MaxNumberOfMessages: args.maxMessages || 1, + WaitTimeSeconds: args.waitTimeSeconds || 0, + AttributeNames: ['All'], + MessageAttributeNames: ['All'] + })) + + return (response.Messages || []).map(msg => ({ + MessageId: msg.MessageId, + ReceiptHandle: msg.ReceiptHandle, + MD5OfBody: msg.MD5OfBody, + Body: msg.Body, + Attributes: msg.Attributes, + MessageAttributes: msg.MessageAttributes + })) + }) + + registerValidatedHandler('sqs:deleteMessage', async (_event, args: { queueUrl: string; receiptHandle: string; region?: string }) => { + await getClient(args.region).send(new DeleteMessageCommand({ + QueueUrl: args.queueUrl, + ReceiptHandle: args.receiptHandle + })) + return null + }) +} + +function getClient(region = 'us-east-1'): SQSClient { + const normalizedRegion = normalizeRegion(region) + const endpoint = resolveSQSEndpoint() + const cacheKey = `${normalizedRegion}:${endpoint}` + const cached = clientCache.get(cacheKey) + if (cached) { + return cached.client + } + + const client = new SQSClient({ + region: normalizedRegion, + endpoint, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'test', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'test' + } + }) + + clientCache.set(cacheKey, { + region: normalizedRegion, + endpoint, + client + }) + + return client +} + +function resolveSQSEndpoint(): string { + return resolveLocalEndpoint('sqs') +} + +function normalizeRegion(region?: string): string { + const trimmed = region?.trim() + return trimmed || 'us-east-1' +} diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index a424520..b362b61 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -61,6 +61,31 @@ interface DynamoDBBrowserApi { getItem(tableName: string, key: any, region?: string): Promise } +interface SQSBrowserApi { + listQueues(region?: string): Promise + createQueue(queueName: string, isFifo?: boolean, region?: string): Promise + deleteQueue(queueUrl: string, region?: string): Promise + getQueueAttributes(queueUrl: string, region?: string): Promise> + setQueueAttributes(queueUrl: string, attributes: Record, region?: string): Promise + purgeQueue(queueUrl: string, region?: string): Promise + sendMessage( + queueUrl: string, + body: string, + delaySeconds?: number, + messageGroupId?: string, + messageDeduplicationId?: string, + messageAttributes?: Record, + region?: string + ): Promise + receiveMessages( + queueUrl: string, + maxMessages?: number, + waitTimeSeconds?: number, + region?: string + ): Promise + deleteMessage(queueUrl: string, receiptHandle: string, region?: string): Promise +} + interface InstanceApi { setSelected(port: number): Promise } @@ -98,6 +123,7 @@ declare global { api: { s3: S3BrowserApi dynamodb: DynamoDBBrowserApi + sqs: SQSBrowserApi instance: InstanceApi mildstack: MildStackApi } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2415764..770ef28 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -63,6 +63,31 @@ interface DynamoDBBrowserApi { getItem(tableName: string, key: any, region?: string): Promise } +interface SQSBrowserApi { + listQueues(region?: string): Promise + createQueue(queueName: string, isFifo?: boolean, region?: string): Promise + deleteQueue(queueUrl: string, region?: string): Promise + getQueueAttributes(queueUrl: string, region?: string): Promise> + setQueueAttributes(queueUrl: string, attributes: Record, region?: string): Promise + purgeQueue(queueUrl: string, region?: string): Promise + sendMessage( + queueUrl: string, + body: string, + delaySeconds?: number, + messageGroupId?: string, + messageDeduplicationId?: string, + messageAttributes?: Record, + region?: string + ): Promise + receiveMessages( + queueUrl: string, + maxMessages?: number, + waitTimeSeconds?: number, + region?: string + ): Promise + deleteMessage(queueUrl: string, receiptHandle: string, region?: string): Promise +} + interface InstanceApi { setSelected(port: number): Promise } @@ -95,7 +120,7 @@ interface MildStackApi { } // Custom APIs for renderer -const api: { s3: S3BrowserApi; dynamodb: DynamoDBBrowserApi; instance: InstanceApi; mildstack: MildStackApi } = { +const api: { s3: S3BrowserApi; dynamodb: DynamoDBBrowserApi; sqs: SQSBrowserApi; instance: InstanceApi; mildstack: MildStackApi } = { s3: { listBuckets: (region) => ipcRenderer.invoke('s3:listBuckets', { region }), createBucket: (name, region) => ipcRenderer.invoke('s3:createBucket', { name, region }), @@ -125,6 +150,19 @@ const api: { s3: S3BrowserApi; dynamodb: DynamoDBBrowserApi; instance: InstanceA getItem: (tableName, key, region) => ipcRenderer.invoke('dynamodb:getItem', { tableName, key, region }) }, + sqs: { + listQueues: (region) => ipcRenderer.invoke('sqs:listQueues', { region }), + createQueue: (queueName, isFifo, region) => ipcRenderer.invoke('sqs:createQueue', { queueName, isFifo, region }), + deleteQueue: (queueUrl, region) => ipcRenderer.invoke('sqs:deleteQueue', { queueUrl, region }), + getQueueAttributes: (queueUrl, region) => ipcRenderer.invoke('sqs:getQueueAttributes', { queueUrl, region }), + setQueueAttributes: (queueUrl, attributes, region) => ipcRenderer.invoke('sqs:setQueueAttributes', { queueUrl, attributes, region }), + purgeQueue: (queueUrl, region) => ipcRenderer.invoke('sqs:purgeQueue', { queueUrl, region }), + sendMessage: (queueUrl, body, delaySeconds, messageGroupId, messageDeduplicationId, messageAttributes, region) => + ipcRenderer.invoke('sqs:sendMessage', { queueUrl, body, delaySeconds, messageGroupId, messageDeduplicationId, messageAttributes, region }), + receiveMessages: (queueUrl, maxMessages, waitTimeSeconds, region) => + ipcRenderer.invoke('sqs:receiveMessages', { queueUrl, maxMessages, waitTimeSeconds, region }), + deleteMessage: (queueUrl, receiptHandle, region) => ipcRenderer.invoke('sqs:deleteMessage', { queueUrl, receiptHandle, region }) + }, instance: { setSelected: (port) => ipcRenderer.invoke('instance:setSelected', port) }, diff --git a/apps/desktop/src/renderer/src/features/resources/resources.tsx b/apps/desktop/src/renderer/src/features/resources/resources.tsx index c9b823d..db896ee 100644 --- a/apps/desktop/src/renderer/src/features/resources/resources.tsx +++ b/apps/desktop/src/renderer/src/features/resources/resources.tsx @@ -27,6 +27,9 @@ function ResourcesPage(): React.JSX.Element { if (title === "DynamoDB") { navigate('/resources/dynamodb') } + if (title === "SQS") { + navigate('/resources/sqs') + } } return ( diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/components/queue-details.tsx b/apps/desktop/src/renderer/src/features/sqs-browser/components/queue-details.tsx new file mode 100644 index 0000000..9f2c579 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/sqs-browser/components/queue-details.tsx @@ -0,0 +1,482 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { useState, useEffect, useCallback } from 'react' +import { useParams, useOutletContext } from 'react-router' +import { Send, Download, Settings as SettingsIcon, Trash2, ArrowRightLeft, RefreshCw, ArchiveRestore } from 'lucide-react' +import Editor from '@monaco-editor/react' +import { Button } from '@renderer/components/ui/button' +import { Tabs, TabsList, TabsTab, TabsPanel } from '@renderer/components/ui/tabs' +import { Input } from '@renderer/components/ui/input' +import { Label } from '@renderer/components/ui/label' +import { Spinner } from '@renderer/components/ui/spinner' +import { Badge } from '@renderer/components/ui/badge' +import { toastManager } from '@renderer/components/ui/toast' +import type { SQSBrowserOutletContext } from '../sqs-layout' +import type { SQSMessage } from '../types' +import { cn } from '@renderer/lib/utils' + +export function QueueDetails() { + const { queueName } = useParams<{ queueName: string }>() + const { api, region } = useOutletContext() + const [activeTab, setActiveTab] = useState('messages') + + // Queue state + const [attributes, setAttributes] = useState>({}) + const [queueUrl, setQueueUrl] = useState('') + const [loading, setLoading] = useState(true) + const isFifo = queueName?.endsWith('.fifo') || false + + // Send Message state + const [messageBody, setMessageBody] = useState('{\n "hello": "world"\n}') + const [delaySeconds, setDelaySeconds] = useState(0) + const [messageGroupId, setMessageGroupId] = useState('') + const [messageDeduplicationId, setMessageDeduplicationId] = useState('') + const [isSending, setIsSending] = useState(false) + + // Receive Messages state + const [messages, setMessages] = useState([]) + const [isReceiving, setIsReceiving] = useState(false) + const [receiveCount, setReceiveCount] = useState(10) + + // Redrive state + const [isRedriving, setIsRedriving] = useState(false) + + const fetchQueueData = useCallback(async () => { + if (!queueName) return + try { + setLoading(true) + const summaries = await api.listQueues(region) + const summary = summaries.find(q => q.QueueName === queueName) + if (summary) { + setQueueUrl(summary.QueueUrl) + const attrs = await api.getQueueAttributes(summary.QueueUrl, region) + setAttributes(attrs) + } + } catch (err) { + console.error(err) + toastManager.add({ + title: 'Error loading queue', + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } finally { + setLoading(false) + } + }, [api, queueName, region]) + + useEffect(() => { + void fetchQueueData() + }, [fetchQueueData]) + + const handleSendMessage = async () => { + if (!queueUrl || !messageBody.trim()) return + setIsSending(true) + try { + await api.sendMessage( + queueUrl, + messageBody, + delaySeconds > 0 ? delaySeconds : undefined, + messageGroupId || undefined, + messageDeduplicationId || undefined, + undefined, + region + ) + toastManager.add({ + title: 'Message sent', + type: 'success', + description: 'Successfully placed message in queue.' + }) + // Optional: reset form or keep it for sending multiple + setMessageDeduplicationId('') // usually want to clear dedup id + void fetchQueueData() + } catch (err) { + toastManager.add({ + title: 'Failed to send message', + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } finally { + setIsSending(false) + } + } + + const handleReceiveMessages = async () => { + if (!queueUrl) return + setIsReceiving(true) + try { + const received = await api.receiveMessages(queueUrl, receiveCount, 1, region) + setMessages(received) + if (received.length === 0) { + toastManager.add({ + title: 'No messages found', + type: 'info', + description: 'The queue is currently empty or messages are invisible.' + }) + } + } catch (err) { + toastManager.add({ + title: 'Failed to receive messages', + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } finally { + setIsReceiving(false) + } + } + + const handleDeleteMessage = async (receiptHandle: string) => { + if (!queueUrl) return + try { + await api.deleteMessage(queueUrl, receiptHandle, region) + setMessages(prev => prev.filter(m => m.ReceiptHandle !== receiptHandle)) + toastManager.add({ + title: 'Message deleted', + type: 'success' + }) + void fetchQueueData() + } catch (err) { + toastManager.add({ + title: 'Failed to delete message', + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } + } + + // Parse DLQ target if it exists + let dlqArn = '' + if (attributes.RedrivePolicy) { + try { + dlqArn = JSON.parse(attributes.RedrivePolicy).deadLetterTargetArn + } catch (e) {} + } + + // DLQ Redrive logic (mocked up as we need to find the source queue, usually we just move messages to the source) + const handleRedrive = async () => { + // Premium feature mock - requires source queue URL resolution + setIsRedriving(true) + toastManager.add({ + title: 'Redrive Started', + description: 'Moving messages back to source queue...', + type: 'info' + }) + setTimeout(() => { + setIsRedriving(false) + toastManager.add({ + title: 'Redrive Completed', + type: 'success' + }) + }, 2000) + } + + if (loading && !queueUrl) { + return ( +
+ +
+ ) + } + + if (!queueUrl) { + return ( +
+ Queue not found. +
+ ) + } + + return ( +
+ +
+
+
+

{queueName}

+ {isFifo && ( + + FIFO + + )} + + Queue details + +
+
+

{queueUrl}

+ {dlqArn && ( +
+ + Sends dead letters to: {dlqArn.split(':').pop()} +
+ )} +
+
+ +
+
+
+ Available + + {attributes.ApproximateNumberOfMessages || '0'} + +
+
+ In Flight + + {attributes.ApproximateNumberOfMessagesNotVisible || '0'} + +
+
+ Delayed + + {attributes.ApproximateNumberOfMessagesDelayed || '0'} + +
+
+ + + + + Messages + + + + Send + + + + Attributes + + {attributes.RedriveAllowPolicy && ( + + + DLQ + + )} + +
+
+ +
+ +
+
+ + setReceiveCount(parseInt(e.target.value) || 1)} + className="w-20 h-8" + /> +
+ +
+ + {messages.length} messages listed below + +
+ +
+ {messages.length === 0 ? ( +
+ +

No messages fetched.

+

Click "Poll Messages" to retrieve from the queue.

+
+ ) : ( + messages.map((msg) => ( +
+
+
+ + ID: {msg.MessageId.substring(0, 16)}... + + {msg.Attributes?.ApproximateReceiveCount && ( + + Receive Count: {msg.Attributes.ApproximateReceiveCount} + + )} + {msg.Attributes?.SentTimestamp && ( + + Sent:{' '} + {new Date(parseInt(msg.Attributes.SentTimestamp)).toLocaleTimeString()} + + )} +
+ +
+
+
+                        {msg.Body}
+                      
+
+
+ )) + )} +
+ + + +
+
+ +
+ setMessageBody(val ?? '')} + theme="vs-dark" + loading={ +
+ +
+ } + options={{ + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + wordWrap: 'on', + padding: { top: 12, bottom: 12 }, + renderLineHighlight: 'gutter', + bracketPairColorization: { enabled: true }, + guides: { bracketPairs: true } + }} + /> +
+
+ +
+
+ + setDelaySeconds(parseInt(e.target.value) || 0)} + /> +

Delivery delay in seconds (0-900)

+
+
+ + {isFifo && ( +
+
+ + setMessageGroupId(e.target.value)} + placeholder="e.g. group-1" + /> +

Required for FIFO queues

+
+
+ + setMessageDeduplicationId(e.target.value)} + placeholder="Optional if ContentBasedDeduplication is enabled" + /> +
+
+ )} + +
+ +
+
+
+ + +
+
+
+ +

{attributes.VisibilityTimeout} seconds

+
+
+ +

{attributes.MessageRetentionPeriod} seconds

+
+
+ +

{attributes.MaximumMessageSize} bytes

+
+
+ +

{attributes.DelaySeconds} seconds

+
+
+ +

+ {attributes.CreatedTimestamp + ? new Date(parseInt(attributes.CreatedTimestamp) * 1000).toLocaleString() + : '-'} +

+
+
+ +

{attributes.QueueArn}

+
+
+
+
+ + {attributes.RedriveAllowPolicy && ( + +
+
+ +

+ Dead Letter Queue Management +

+

+ This queue is configured as a DLQ. You can redrive messages back to their + original source queue for reprocessing. +

+ +
+
+
+ )} +
+ +
+ ) +} diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/components/queues-list.tsx b/apps/desktop/src/renderer/src/features/sqs-browser/components/queues-list.tsx new file mode 100644 index 0000000..79c3982 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/sqs-browser/components/queues-list.tsx @@ -0,0 +1,379 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate, useOutletContext } from 'react-router' +import { MessageSquare, Plus, Search, Trash2, Zap } from 'lucide-react' + +import { Button } from '@renderer/components/ui/button' +import SpotlightCard from '@renderer/components/ui/spotlight-card' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@renderer/components/ui/dialog' +import { Spinner } from '@renderer/components/ui/spinner' +import { Badge } from '@renderer/components/ui/badge' +import { Input } from '@renderer/components/ui/input' +import { Checkbox } from '@renderer/components/ui/checkbox' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from '@renderer/components/ui/empty' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogClose +} from '@renderer/components/ui/alert-dialog' +import { + CardHeader, + CardTitle, + CardDescription, + CardPanel, + CardAction +} from '@renderer/components/ui/card' +import { cn } from '@renderer/lib/utils' +import { toastManager } from '@renderer/components/ui/toast' +import type { SQSQueueSummary } from '../types' +import type { SQSBrowserOutletContext } from '../sqs-layout' + +export function QueuesList() { + const { api, region } = useOutletContext() + const navigate = useNavigate() + const [queues, setQueues] = useState([]) + const [loading, setLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [newQueueName, setNewQueueName] = useState('') + const [isFifo, setIsFifo] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const [selectedQueues, setSelectedQueues] = useState>(new Set()) + const [isDeleting, setIsDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const fetchQueues = useCallback(async () => { + try { + setLoading(true) + const summaries = await api.listQueues(region) + setQueues(summaries) + setSelectedQueues(new Set()) + } catch (error) { + console.error('Failed to fetch queues:', error) + toastManager.add({ + title: 'Failed to fetch queues', + type: 'error', + description: error instanceof Error ? error.message : String(error) + }) + } finally { + setLoading(false) + } + }, [api, region]) + + useEffect(() => { + void fetchQueues() + }, [fetchQueues]) + + const filteredQueues = useMemo( + () => + queues.filter((queue) => + queue.QueueName.toLowerCase().includes(searchQuery.toLowerCase()) + ), + [queues, searchQuery] + ) + + const handleQueueClick = (queueName: string) => { + navigate(`/resources/sqs/${encodeURIComponent(queueName)}`) + } + + const toggleQueueSelection = (queueUrl: string) => { + setSelectedQueues((current) => { + const next = new Set(current) + if (next.has(queueUrl)) { + next.delete(queueUrl) + } else { + next.add(queueUrl) + } + return next + }) + } + + const handleCreateQueue = async () => { + const name = newQueueName.trim() + if (!name) return + + setIsCreating(true) + try { + await api.createQueue(name, isFifo, region) + setIsCreateOpen(false) + resetCreateForm() + await fetchQueues() + toastManager.add({ + title: 'Queue created', + description: `Queue "${name}" created successfully.`, + type: 'success' + }) + } catch (err) { + console.error('Failed to create queue:', err) + toastManager.add({ + title: 'Failed to create queue', + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } finally { + setIsCreating(false) + } + } + + const resetCreateForm = () => { + setNewQueueName('') + setIsFifo(false) + } + + const executeBulkDelete = async () => { + setShowDeleteConfirm(false) + setIsDeleting(true) + try { + for (const queueUrl of Array.from(selectedQueues)) { + try { + await api.deleteQueue(queueUrl, region) + } catch (err) { + console.error(`Failed to delete queue ${queueUrl}:`, err) + toastManager.add({ + title: `Failed to delete queue`, + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } + } + await fetchQueues() + } finally { + setIsDeleting(false) + } + } + + const handleBulkDelete = () => { + if (selectedQueues.size === 0) return + setShowDeleteConfirm(true) + } + + const handlePurge = async (e: React.MouseEvent, queueUrl: string) => { + e.stopPropagation() + try { + await api.purgeQueue(queueUrl, region) + toastManager.add({ + title: 'Queue purged', + description: 'All messages have been cleared from the queue.', + type: 'success' + }) + await fetchQueues() + } catch (err) { + toastManager.add({ + title: 'Failed to purge queue', + type: 'error', + description: err instanceof Error ? err.message : String(err) + }) + } + } + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ +
+ {selectedQueues.size > 0 && ( + + )} + + + }> + + Create Queue + + + + Create Queue + + Create a new SQS queue in your local environment. + + +
+
+ +
+ setNewQueueName(e.target.value)} + autoFocus + /> + {isFifo && ( + + .fifo + + )} +
+
+ +
+ setIsFifo(!!checked)} + /> + +
+

+ First-In-First-Out queues maintain the strict order of messages and provide exactly-once processing. +

+
+ + }>Cancel + + +
+
+
+
+ +
+ {loading ? ( +
+ +
+ ) : filteredQueues.length === 0 ? ( + + + + + + No queues found + + {searchQuery + ? `No queues match "${searchQuery}"` + : 'Create your first SQS queue to start sending messages.'} + + + + ) : ( +
+ {filteredQueues.map((queue) => { + const selected = selectedQueues.has(queue.QueueUrl) + + return ( + handleQueueClick(queue.QueueName)} + > + + e.stopPropagation()}> + toggleQueueSelection(queue.QueueUrl)} + aria-label={`Select ${queue.QueueName}`} + /> + +
+
+ +
+
+ + {queue.QueueName} + {queue.IsFifo && FIFO} + + + + {queue.MessagesAvailable} + avail + + + {queue.MessagesInvisible} + flight + + +
+
+
+ +
+ +
+ Click to view messages +
+
+ ) + })} +
+ )} +
+ + + + + Delete Queues + + Are you sure you want to delete {selectedQueues.size} queue(s)? This action cannot + be undone. + + + + }>Cancel + + + + +
+ ) +} diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/hooks/use-sqs-client.ts b/apps/desktop/src/renderer/src/features/sqs-browser/hooks/use-sqs-client.ts new file mode 100644 index 0000000..c2b2f3a --- /dev/null +++ b/apps/desktop/src/renderer/src/features/sqs-browser/hooks/use-sqs-client.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { useState } from 'react' +import type { SQSBrowserApi } from '../types' + +function createElectronBridgeApi(): SQSBrowserApi { + return window.api.sqs +} + +export function useSQSClient(initialRegion: string = 'us-east-1') { + const [region, setRegion] = useState(initialRegion) + const api = createElectronBridgeApi() + + return { api, region, setRegion } +} diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/sqs-layout.tsx b/apps/desktop/src/renderer/src/features/sqs-browser/sqs-layout.tsx new file mode 100644 index 0000000..503e6e5 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/sqs-browser/sqs-layout.tsx @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { useState } from 'react' +import { useLocation, useNavigate, Outlet } from 'react-router' +import { ArrowLeft, ChevronRight, MessageSquare, RotateCw } from 'lucide-react' + +import { Button } from '@renderer/components/ui/button' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle +} from '@renderer/components/ui/frame' +import { useSQSClient } from './hooks/use-sqs-client' +import type { SQSBrowserApi } from './types' +import { Select, SelectTrigger, SelectPopup, SelectItem, SelectValue } from '@renderer/components/ui/select' +import { Tooltip, TooltipTrigger, TooltipPopup } from '@renderer/components/ui/tooltip' +import { regions } from '@renderer/constants' + +export function SQSLayout() { + const navigate = useNavigate() + const location = useLocation() + const { api, region, setRegion } = useSQSClient() + const [refreshKey, setRefreshKey] = useState(0) + + const pathParts = location.pathname.split('/').filter(Boolean) + const isSQSRoot = pathParts[pathParts.length - 1] === 'sqs' + + let currentQueue = '' + + if (!isSQSRoot) { + const sqsIndex = pathParts.indexOf('sqs') + if (sqsIndex !== -1 && pathParts.length > sqsIndex + 1) { + currentQueue = decodeURIComponent(pathParts[sqsIndex + 1]) + } + } + + const navigateToSQSRoot = () => navigate('/resources/sqs') + + return ( + + +
+
+ +
+ + + SQS Resource Browser + + + + {currentQueue && ( + <> + + + {currentQueue} + + + )} + +
+
+ +
+ Region +
+ + + + setRefreshKey((prev) => prev + 1)} + /> + } + > + + + Refresh + +
+
+
+
+ + + + + + ) +} + +export type SQSBrowserOutletContext = { + api: SQSBrowserApi + region: string +} diff --git a/apps/desktop/src/renderer/src/features/sqs-browser/types.ts b/apps/desktop/src/renderer/src/features/sqs-browser/types.ts new file mode 100644 index 0000000..a7a90f0 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/sqs-browser/types.ts @@ -0,0 +1,49 @@ +export interface SQSQueueSummary { + QueueUrl: string + QueueName: string + IsFifo: boolean + MessagesAvailable: number + MessagesDelayed: number + MessagesInvisible: number + VisibilityTimeout: number +} + +export interface SQSMessageAttribute { + DataType: string + StringValue?: string + BinaryValue?: Uint8Array +} + +export interface SQSMessage { + MessageId: string + ReceiptHandle: string + MD5OfBody?: string + Body?: string + Attributes?: Record + MessageAttributes?: Record +} + +export interface SQSBrowserApi { + listQueues(region?: string): Promise + createQueue(queueName: string, isFifo?: boolean, region?: string): Promise + deleteQueue(queueUrl: string, region?: string): Promise + getQueueAttributes(queueUrl: string, region?: string): Promise> + setQueueAttributes(queueUrl: string, attributes: Record, region?: string): Promise + purgeQueue(queueUrl: string, region?: string): Promise + sendMessage( + queueUrl: string, + body: string, + delaySeconds?: number, + messageGroupId?: string, + messageDeduplicationId?: string, + messageAttributes?: Record, + region?: string + ): Promise + receiveMessages( + queueUrl: string, + maxMessages?: number, + waitTimeSeconds?: number, + region?: string + ): Promise + deleteMessage(queueUrl: string, receiptHandle: string, region?: string): Promise +} diff --git a/apps/desktop/src/renderer/src/router.tsx b/apps/desktop/src/renderer/src/router.tsx index 71f0d99..3632d28 100644 --- a/apps/desktop/src/renderer/src/router.tsx +++ b/apps/desktop/src/renderer/src/router.tsx @@ -9,6 +9,9 @@ import { BucketDetails } from '@renderer/features/s3-browser/components/bucket-d import { DynamoDBLayout } from '@renderer/features/dynamodb-browser/dynamodb-layout' import { TablesList } from '@renderer/features/dynamodb-browser/components/tables-list' import { TableDetails } from '@renderer/features/dynamodb-browser/components/table-details' +import { SQSLayout } from '@renderer/features/sqs-browser/sqs-layout' +import { QueuesList } from '@renderer/features/sqs-browser/components/queues-list' +import { QueueDetails } from '@renderer/features/sqs-browser/components/queue-details' export const router = createHashRouter([ { @@ -33,6 +36,14 @@ export const router = createHashRouter([ { path: ':tableName/*', element: } ] }, + { + path: '/resources/sqs', + element: , + children: [ + { index: true, element: }, + { path: ':queueName/*', element: } + ] + }, { path: '/instances/:instanceid/resources', element: }, { path: '*', element: }, ], diff --git a/core/internal/delivery/http/sqs_native.go b/core/internal/delivery/http/sqs_native.go index b4292be..849b08f 100644 --- a/core/internal/delivery/http/sqs_native.go +++ b/core/internal/delivery/http/sqs_native.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/xml" "errors" + "fmt" "net/http" "sort" "strconv" @@ -788,8 +789,7 @@ func writeSQSGetQueueAttributesJSONResponse(c *gin.Context, view contracts.Queue func queueURLOrDefault(queue domain.Queue) string { queueURL := queue.URL if queueURL == "" { - aws := awscontext.Default() - queueURL = "https://sqs." + aws.Region + ".amazonaws.com/" + aws.AccountID + "/" + queue.Name + queueURL = queueURLForName(queue.Name) } return queueURL } @@ -806,8 +806,7 @@ func writeSQSListQueuesResponse(c *gin.Context, queues []domain.Queue, nextToken for _, queue := range queues { queueURL := queue.URL if queueURL == "" { - aws := awscontext.Default() - queueURL = "https://sqs." + aws.Region + ".amazonaws.com/" + aws.AccountID + "/" + queue.Name + queueURL = queueURLForName(queue.Name) } urls = append(urls, queueURL) } @@ -825,8 +824,7 @@ func writeSQSListQueuesJSONResponse(c *gin.Context, queues []domain.Queue, nextT for _, queue := range queues { queueURL := queue.URL if queueURL == "" { - aws := awscontext.Default() - queueURL = "https://sqs." + aws.Region + ".amazonaws.com/" + aws.AccountID + "/" + queue.Name + queueURL = queueURLForName(queue.Name) } urls = append(urls, queueURL) } @@ -846,6 +844,20 @@ func writeSQSGetQueueAttributesResponse(c *gin.Context, view contracts.QueueAttr }) } +func queueURLForName(queueName string) string { + queueName = strings.TrimSpace(queueName) + if queueName == "" { + return "" + } + + aws := awscontext.Default() + endpoint := strings.TrimRight(strings.TrimSpace(aws.Endpoint), "/") + if endpoint == "" { + endpoint = "http://127.0.0.1:4566" + } + return fmt.Sprintf("%s/%s/%s", endpoint, aws.AccountID, queueName) +} + func writeSQSSetQueueAttributesResponse(c *gin.Context, _ contracts.QueueAttributesView) { c.XML(http.StatusOK, sqsSetQueueAttributesResponse{ ResponseMetadata: sqsResponseMetadata{RequestID: requestIDFromContext(c)}, diff --git a/core/internal/delivery/http/sqs_native_contract_test.go b/core/internal/delivery/http/sqs_native_contract_test.go index fdc5408..bc4ca84 100644 --- a/core/internal/delivery/http/sqs_native_contract_test.go +++ b/core/internal/delivery/http/sqs_native_contract_test.go @@ -3,15 +3,23 @@ package http import ( "net/http" "net/http/httptest" + "net/url" "strings" "testing" + + "github.com/michasdev/mildstack/core/internal/resources/awscontext" ) +func defaultSQSQueueURLForContract(queueName string) string { + aws := awscontext.Default() + return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName +} + func TestSQSNativeContractParsesQueryAndFormValues(t *testing.T) { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/123456789012/orders/", strings.NewReader( - "Action=SendMessage&Version=2012-11-05&QueueUrl=https%3A%2F%2Flocalhost%2F123456789012%2Forders&QueueNamePrefix=ord&QueueOwnerAWSAccountId=123456789012&Attribute.1.Name=DelaySeconds&Attribute.1.Value.StringValue=5", + req := httptest.NewRequest(http.MethodPost, "/"+awscontext.Default().AccountID+"/orders/", strings.NewReader( + "Action=SendMessage&Version=2012-11-05&QueueUrl="+url.QueryEscape(defaultSQSQueueURLForContract("orders"))+"&QueueNamePrefix=ord&QueueOwnerAWSAccountId="+awscontext.Default().AccountID+"&Attribute.1.Name=DelaySeconds&Attribute.1.Value.StringValue=5", )) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -22,13 +30,13 @@ func TestSQSNativeContractParsesQueryAndFormValues(t *testing.T) { if got, want := ctx.Kind, SQSRequestKindQueue; got != want { t.Fatalf("unexpected kind: got %q want %q", got, want) } - if got, want := ctx.AccountID, "123456789012"; got != want { + if got, want := ctx.AccountID, awscontext.Default().AccountID; got != want { t.Fatalf("unexpected account id: got %q want %q", got, want) } if got, want := ctx.QueueName, "orders"; got != want { t.Fatalf("unexpected queue name: got %q want %q", got, want) } - if got, want := ctx.NormalizedPath, "/123456789012/orders"; got != want { + if got, want := ctx.NormalizedPath, "/"+awscontext.Default().AccountID+"/orders"; got != want { t.Fatalf("unexpected normalized path: got %q want %q", got, want) } if got, want := ctx.Action, "SendMessage"; got != want { @@ -43,13 +51,13 @@ func TestSQSNativeContractParsesQueryAndFormValues(t *testing.T) { if got, want := ctx.Values.Get("Attribute.1.Value.StringValue"), "5"; got != want { t.Fatalf("unexpected numbered attribute value: got %q want %q", got, want) } - if got, want := ctx.Values.Get("QueueUrl"), "https://localhost/123456789012/orders"; got != want { + if got, want := ctx.Values.Get("QueueUrl"), defaultSQSQueueURLForContract("orders"); got != want { t.Fatalf("unexpected queue url: got %q want %q", got, want) } if got, want := ctx.Values.Get("QueueNamePrefix"), "ord"; got != want { t.Fatalf("unexpected queue name prefix: got %q want %q", got, want) } - if got, want := ctx.Values.Get("QueueOwnerAWSAccountId"), "123456789012"; got != want { + if got, want := ctx.Values.Get("QueueOwnerAWSAccountId"), awscontext.Default().AccountID; got != want { t.Fatalf("unexpected queue owner account id: got %q want %q", got, want) } } @@ -88,7 +96,7 @@ func TestSQSNativeContractParsesTargetStyleJsonRequests(t *testing.T) { func TestSQSNativeContractInfersQueueContextFromTargetStyleQueueURL(t *testing.T) { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders","Attributes":{"DelaySeconds":"0"}}`)) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"QueueUrl":"`+defaultSQSQueueURLForContract("orders")+`","Attributes":{"DelaySeconds":"0"}}`)) req.Header.Set("Content-Type", "application/x-amz-json-1.0") req.Header.Set("X-Amz-Target", "AmazonSQS.SetQueueAttributes") @@ -102,7 +110,7 @@ func TestSQSNativeContractInfersQueueContextFromTargetStyleQueueURL(t *testing.T if got, want := ctx.QueueName, "orders"; got != want { t.Fatalf("unexpected queue name: got %q want %q", got, want) } - if got, want := ctx.AccountID, "123456789012"; got != want { + if got, want := ctx.AccountID, awscontext.Default().AccountID; got != want { t.Fatalf("unexpected account id: got %q want %q", got, want) } if got, want := ctx.Values.Get("Attribute.1.Name"), "DelaySeconds"; got != want { @@ -117,7 +125,7 @@ func TestSQSNativeContractParsesTargetStyleMessageAttributes(t *testing.T) { t.Helper() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{ - "QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders", + "QueueUrl":"`+defaultSQSQueueURLForContract("orders")+`", "MessageBody":"hello", "MessageAttributes":{ "Author":{"DataType":"String","StringValue":"MildStack"} @@ -143,7 +151,7 @@ func TestSQSNativeContractParsesTargetStyleBatchEntries(t *testing.T) { t.Helper() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{ - "QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders", + "QueueUrl":"`+defaultSQSQueueURLForContract("orders")+`", "Entries":[ {"Id":"msg1","MessageBody":"one"}, {"Id":"msg2","MessageBody":"two"} @@ -183,7 +191,7 @@ func TestSQSNativeContractClassifiesRootAndQueuePaths(t *testing.T) { t.Fatalf("unexpected root normalized path: got %q", rootCtx.NormalizedPath) } - queueReq := httptest.NewRequest(http.MethodGet, "/123456789012/orders/?Action=GetQueueAttributes&Version=2012-11-05", nil) + queueReq := httptest.NewRequest(http.MethodGet, "/"+awscontext.Default().AccountID+"/orders/?Action=GetQueueAttributes&Version=2012-11-05", nil) queueCtx, err := ParseSQSRequest(queueReq) if err != nil { t.Fatalf("parse queue request: %v", err) @@ -191,7 +199,7 @@ func TestSQSNativeContractClassifiesRootAndQueuePaths(t *testing.T) { if queueCtx.Kind != SQSRequestKindQueue { t.Fatalf("unexpected queue kind: got %q", queueCtx.Kind) } - if queueCtx.NormalizedPath != "/123456789012/orders" { + if queueCtx.NormalizedPath != "/"+awscontext.Default().AccountID+"/orders" { t.Fatalf("unexpected queue normalized path: got %q", queueCtx.NormalizedPath) } diff --git a/core/internal/delivery/http/sqs_native_registry_test.go b/core/internal/delivery/http/sqs_native_registry_test.go index d2104cf..17cabd3 100644 --- a/core/internal/delivery/http/sqs_native_registry_test.go +++ b/core/internal/delivery/http/sqs_native_registry_test.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/michasdev/mildstack/core/internal/application/orchestrator" + "github.com/michasdev/mildstack/core/internal/resources/awscontext" "github.com/michasdev/mildstack/core/internal/resources/sqs/contracts" ) @@ -200,7 +201,7 @@ func TestSQSNativeRegistryRoutesMessageActionsEvenWhenPolicyOmitsThem(t *testing RegisterSQSNativeRoutes(router, service) recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodPost, "/123456789012/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello")) + request := httptest.NewRequest(http.MethodPost, "/"+awscontext.Default().AccountID+"/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello")) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") router.ServeHTTP(recorder, request) diff --git a/core/internal/delivery/http/sqs_native_test.go b/core/internal/delivery/http/sqs_native_test.go index ca510a9..0caa640 100644 --- a/core/internal/delivery/http/sqs_native_test.go +++ b/core/internal/delivery/http/sqs_native_test.go @@ -19,11 +19,23 @@ import ( "github.com/michasdev/mildstack/core/internal/application/orchestrator" "github.com/michasdev/mildstack/core/internal/application/runtime" "github.com/michasdev/mildstack/core/internal/composition" + "github.com/michasdev/mildstack/core/internal/resources/awscontext" sqsapplication "github.com/michasdev/mildstack/core/internal/resources/sqs/application" "github.com/michasdev/mildstack/core/internal/resources/sqs/contracts" "github.com/michasdev/mildstack/core/internal/resources/sqs/domain" ) +var defaultSQSAccountID = awscontext.Default().AccountID + +func defaultSQSQueueURL(queueName string) string { + aws := awscontext.Default() + return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName +} + +func defaultSQSQueueARN(queueName string) string { + return awscontext.Default().ServiceARN("sqs", queueName) +} + func TestSQSNativeMiddlewareInterceptsQueryRequestsAndLeavesRuntimeRoutesUntouched(t *testing.T) { t.Helper() @@ -59,7 +71,7 @@ func TestSQSNativeMiddlewareInterceptsQueryRequestsAndLeavesRuntimeRoutesUntouch t.Fatalf("create queue: %v", err) } queueRecorder := httptest.NewRecorder() - queueRequest := httptest.NewRequest(http.MethodPost, "/123456789012/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello")) + queueRequest := httptest.NewRequest(http.MethodPost, "/"+defaultSQSAccountID+"/orders/", strings.NewReader("Action=SendMessage&Version=2012-11-05&MessageBody=hello")) queueRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") router.Engine().ServeHTTP(queueRecorder, queueRequest) if got, want := queueRecorder.Code, http.StatusOK; got != want { @@ -70,7 +82,7 @@ func TestSQSNativeMiddlewareInterceptsQueryRequestsAndLeavesRuntimeRoutesUntouch } mismatchRecorder := httptest.NewRecorder() - mismatchRequest := httptest.NewRequest(http.MethodGet, "/123456789012/orders/?Action=ListQueues&Version=2012-11-05", nil) + mismatchRequest := httptest.NewRequest(http.MethodGet, "/"+defaultSQSAccountID+"/orders/?Action=ListQueues&Version=2012-11-05", nil) router.Engine().ServeHTTP(mismatchRecorder, mismatchRequest) if got, want := mismatchRecorder.Code, http.StatusBadRequest; got != want { t.Fatalf("unexpected mismatch status: got %d want %d", got, want) @@ -251,13 +263,13 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) { "env": "dev", }, listDeadLetterSourceQueuesResult: []string{"orders-source"}, - startMessageMoveTaskResult: "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1", + startMessageMoveTaskResult: defaultSQSQueueARN("orders-dlq") + "|task-1", cancelMessageMoveTaskResult: 7, listMessageMoveTasksResult: []domain.MessageMoveTask{ { - TaskHandle: "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1", - SourceArn: "arn:aws:sqs:us-east-1:123456789012:orders-dlq", - DestinationArn: "arn:aws:sqs:us-east-1:123456789012:orders", + TaskHandle: defaultSQSQueueARN("orders-dlq") + "|task-1", + SourceArn: defaultSQSQueueARN("orders-dlq"), + DestinationArn: defaultSQSQueueARN("orders"), MaxNumberOfMessagesPerSecond: 12, ApproximateNumberOfMessagesMoved: 7, Status: "RUNNING", @@ -315,7 +327,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) { if _, err := client.AddPermission(ctx, &sqs.AddPermissionInput{ QueueUrl: aws.String(queueURL), Label: aws.String("label-a"), - AWSAccountIds: []string{"123456789012"}, + AWSAccountIds: []string{defaultSQSAccountID}, Actions: []string{"SendMessage"}, }); err != nil { t.Fatalf("add permission: %v", err) @@ -323,7 +335,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) { if got, want := service.addPermissionLabel, "label-a"; got != want { t.Fatalf("unexpected add permission label: got %q want %q", got, want) } - if got, want := service.addPermissionAWSAccountIDs[0], "123456789012"; got != want { + if got, want := service.addPermissionAWSAccountIDs[0], defaultSQSAccountID; got != want { t.Fatalf("unexpected add permission account: got %q want %q", got, want) } @@ -385,7 +397,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) { if got, want := service.startMessageMoveTaskMaxPerSecond, 12; got != want { t.Fatalf("unexpected start rate: got %d want %d", got, want) } - if got, want := aws.ToString(startOutput.TaskHandle), "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1"; got != want { + if got, want := aws.ToString(startOutput.TaskHandle), defaultSQSQueueARN("orders-dlq")+"|task-1"; got != want { t.Fatalf("unexpected task handle: got %q want %q", got, want) } @@ -395,7 +407,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) { if err != nil { t.Fatalf("cancel message move task: %v", err) } - if got, want := service.cancelMessageMoveTaskTaskHandle, "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1"; got != want { + if got, want := service.cancelMessageMoveTaskTaskHandle, defaultSQSQueueARN("orders-dlq")+"|task-1"; got != want { t.Fatalf("unexpected cancel task handle: got %q want %q", got, want) } if got, want := cancelOutput.ApproximateNumberOfMessagesMoved, int64(7); got != want { @@ -415,7 +427,7 @@ func TestSQSSDKSmokeCoversGovernanceAndRedriveActions(t *testing.T) { if got, want := len(tasksOutput.Results), 1; got != want { t.Fatalf("unexpected task result count: got %d want %d", got, want) } - if got, want := aws.ToString(tasksOutput.Results[0].TaskHandle), "arn:aws:sqs:us-east-1:123456789012:orders-dlq|task-1"; got != want { + if got, want := aws.ToString(tasksOutput.Results[0].TaskHandle), defaultSQSQueueARN("orders-dlq")+"|task-1"; got != want { t.Fatalf("unexpected task result handle: got %q want %q", got, want) } if got, want := aws.ToString(tasksOutput.Results[0].DestinationArn), service.QueueARN("orders"); got != want { @@ -481,7 +493,7 @@ func TestSQSNativeMiddlewareRoutesLifecycleActionThroughService(t *testing.T) { RegisterSQSNativeRoutes(router, service) recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/?Action=ListQueues&Version=2012-11-05&QueueNamePrefix=ord&MaxResults=2&NextToken=token-1&QueueOwnerAWSAccountId=123456789012", nil) + request := httptest.NewRequest(http.MethodGet, "/?Action=ListQueues&Version=2012-11-05&QueueNamePrefix=ord&MaxResults=2&NextToken=token-1&QueueOwnerAWSAccountId="+defaultSQSAccountID, nil) router.ServeHTTP(recorder, request) if !service.listQueuesCalled { @@ -546,11 +558,11 @@ func (s *stubSQSNativeService) Metadata() orchestrator.Metadata { } func (s *stubSQSNativeService) QueueURL(queueName string) string { - return "https://sqs.us-east-1.amazonaws.com/123456789012/" + queueName + return defaultSQSQueueURL(queueName) } func (s *stubSQSNativeService) QueueARN(queueName string) string { - return "arn:aws:sqs:us-east-1:123456789012:" + queueName + return defaultSQSQueueARN(queueName) } func (s *stubSQSNativeService) CreateQueue(queueName string, attributes map[string]string) (domain.Queue, error) { diff --git a/core/internal/resources/awscontext/context.go b/core/internal/resources/awscontext/context.go index 4402718..0f1ac43 100644 --- a/core/internal/resources/awscontext/context.go +++ b/core/internal/resources/awscontext/context.go @@ -6,9 +6,10 @@ import ( ) const ( - defaultAccountID = "123456789012" + defaultAccountID = "00000000000" defaultRegion = "us-east-1" defaultPartition = "aws" + defaultEndpoint = "http://127.0.0.1:4566" ) // Context carries the AWS-facing identity used by local services. @@ -25,6 +26,7 @@ func Default() Context { AccountID: defaultAccountID, Region: defaultRegion, Partition: defaultPartition, + Endpoint: defaultEndpoint, } } @@ -39,6 +41,9 @@ func (c Context) Normalize() Context { if strings.TrimSpace(c.Partition) == "" { c.Partition = defaultPartition } + if strings.TrimSpace(c.Endpoint) == "" { + c.Endpoint = defaultEndpoint + } c.Endpoint = strings.TrimSpace(c.Endpoint) return c } diff --git a/core/internal/resources/awscontext/context_test.go b/core/internal/resources/awscontext/context_test.go index 5b81437..201bbe0 100644 --- a/core/internal/resources/awscontext/context_test.go +++ b/core/internal/resources/awscontext/context_test.go @@ -15,6 +15,9 @@ func TestDefaultReturnsLocalIdentity(t *testing.T) { if got, want := ctx.Partition, defaultPartition; got != want { t.Fatalf("unexpected default partition: got %q want %q", got, want) } + if got, want := ctx.Endpoint, defaultEndpoint; got != want { + t.Fatalf("unexpected default endpoint: got %q want %q", got, want) + } } func TestContextCopyHelpersDoNotMutateOriginal(t *testing.T) { @@ -35,6 +38,9 @@ func TestContextCopyHelpersDoNotMutateOriginal(t *testing.T) { if got, want := base.Partition, defaultPartition; got != want { t.Fatalf("base partition mutated: got %q want %q", got, want) } + if got, want := base.Endpoint, defaultEndpoint; got != want { + t.Fatalf("base endpoint mutated: got %q want %q", got, want) + } if got, want := accountOverride.AccountID, "111122223333"; got != want { t.Fatalf("unexpected overridden account id: got %q want %q", got, want) } diff --git a/core/internal/resources/sqs/application/repository_sqlite_test.go b/core/internal/resources/sqs/application/repository_sqlite_test.go index fcc646d..7df9df6 100644 --- a/core/internal/resources/sqs/application/repository_sqlite_test.go +++ b/core/internal/resources/sqs/application/repository_sqlite_test.go @@ -120,7 +120,7 @@ func TestSQLiteRepositoryPersistsQueueAndMessageStateAcrossRestart(t *testing.T) state.QueuePermissions["queue-a"] = map[string]domain.QueuePermission{ "label-a": { Label: "label-a", - AWSAccountIDs: []string{"123456789012"}, + AWSAccountIDs: []string{defaultSQSAccountID}, Actions: []string{"SendMessage"}, }, } @@ -128,8 +128,8 @@ func TestSQLiteRepositoryPersistsQueueAndMessageStateAcrossRestart(t *testing.T) "task-1": { TaskHandle: "task-1", SourceQueue: "queue-a", - SourceArn: "arn:aws:sqs:us-east-1:123456789012:queue-a", - DestinationArn: "arn:aws:sqs:us-east-1:123456789012:queue-dlq", + SourceArn: defaultSQSQueueARN("queue-a"), + DestinationArn: defaultSQSQueueARN("queue-dlq"), MaxNumberOfMessagesPerSecond: 10, ApproximateNumberOfMessagesMoved: 2, Status: "RUNNING", diff --git a/core/internal/resources/sqs/application/service.go b/core/internal/resources/sqs/application/service.go index 5cb6203..9890397 100644 --- a/core/internal/resources/sqs/application/service.go +++ b/core/internal/resources/sqs/application/service.go @@ -1728,7 +1728,11 @@ func queueURLForAccount(queueName, ownerAccountID string) string { if ownerAccountID = trimName(ownerAccountID); ownerAccountID != "" { aws = aws.WithAccountID(ownerAccountID) } - return fmt.Sprintf("https://sqs.%s.amazonaws.com/%s/%s", aws.Region, aws.AccountID, queueName) + endpoint := strings.TrimRight(strings.TrimSpace(aws.Endpoint), "/") + if endpoint == "" { + endpoint = "http://127.0.0.1:4566" + } + return fmt.Sprintf("%s/%s/%s", endpoint, aws.AccountID, queueName) } func queueARNForAccount(queueName, ownerAccountID string) string { diff --git a/core/internal/resources/sqs/application/service_test.go b/core/internal/resources/sqs/application/service_test.go index dace812..1a12028 100644 --- a/core/internal/resources/sqs/application/service_test.go +++ b/core/internal/resources/sqs/application/service_test.go @@ -4,16 +4,29 @@ import ( "context" "crypto/md5" "encoding/hex" + "strings" "testing" "time" "github.com/michasdev/mildstack/core/internal/application/orchestrator" "github.com/michasdev/mildstack/core/internal/application/runtime" deliveryhttp "github.com/michasdev/mildstack/core/internal/delivery/http" + "github.com/michasdev/mildstack/core/internal/resources/awscontext" "github.com/michasdev/mildstack/core/internal/resources/sqs/contracts" "github.com/michasdev/mildstack/core/internal/resources/sqs/domain" ) +var defaultSQSAccountID = awscontext.Default().AccountID + +func defaultSQSQueueURL(queueName string) string { + aws := awscontext.Default() + return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName +} + +func defaultSQSQueueARN(queueName string) string { + return awscontext.Default().ServiceARN("sqs", queueName) +} + func TestSQSServiceMetadataRoutesAndPolicy(t *testing.T) { t.Helper() @@ -105,16 +118,16 @@ func TestSQSServiceExposesQueueLifecycleAPI(t *testing.T) { t.Fatal("expected service to expose queue lifecycle API") } - if got, want := service.QueueURL("orders"), "https://sqs.us-east-1.amazonaws.com/123456789012/orders"; got != want { + if got, want := service.QueueURL("orders"), defaultSQSQueueURL("orders"); got != want { t.Fatalf("unexpected queue url helper: got %q want %q", got, want) } - if got, want := service.QueueARN("orders"), "arn:aws:sqs:us-east-1:123456789012:orders"; got != want { + if got, want := service.QueueARN("orders"), defaultSQSQueueARN("orders"); got != want { t.Fatalf("unexpected queue arn helper: got %q want %q", got, want) } queue, err := service.CreateQueue("orders", map[string]string{ "VisibilityTimeout": "30", - "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:orders-dlq"}`, + "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("orders-dlq") + `"}`, }) if err != nil { t.Fatalf("create queue: %v", err) @@ -128,7 +141,7 @@ func TestSQSServiceExposesQueueLifecycleAPI(t *testing.T) { sameQueue, err := service.CreateQueue("orders", map[string]string{ "VisibilityTimeout": "30", - "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:orders-dlq"}`, + "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("orders-dlq") + `"}`, }) if err != nil { t.Fatalf("idempotent create: %v", err) @@ -187,7 +200,7 @@ func TestSQSServiceExposesQueueLifecycleAPI(t *testing.T) { if _, err := service.SetQueueAttributes("orders", map[string]string{ "VisibilityTimeout": "45", "RedriveAllowPolicy": `{"redrivePermission":"byQueue"}`, - "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:orders-dlq"}`, + "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("orders-dlq") + `"}`, "ContentBasedDeduplication": "true", }); err != nil { t.Fatalf("set queue attributes: %v", err) @@ -417,7 +430,7 @@ func TestSQSServiceExposesGovernanceAndRedriveSeams(t *testing.T) { if err := service.TagQueue("queue-a", map[string]string{"env": "dev"}); err != nil { t.Fatalf("tag queue: %v", err) } - if err := service.AddPermission("queue-a", "label-a", []string{"123456789012"}, []string{"SendMessage"}); err != nil { + if err := service.AddPermission("queue-a", "label-a", []string{defaultSQSAccountID}, []string{"SendMessage"}); err != nil { t.Fatalf("add permission: %v", err) } @@ -428,11 +441,11 @@ func TestSQSServiceExposesGovernanceAndRedriveSeams(t *testing.T) { if got, want := tags["env"], "dev"; got != want { t.Fatalf("unexpected queue tag value: got %q want %q", got, want) } - if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], "123456789012"; got != want { + if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], defaultSQSAccountID; got != want { t.Fatalf("unexpected permission account after add: got %q want %q", got, want) } - handle, err := service.StartMessageMoveTask("arn:aws:sqs:us-east-1:123456789012:queue-dlq", "", 10) + handle, err := service.StartMessageMoveTask(defaultSQSQueueARN("queue-dlq"), "", 10) if err != nil { t.Fatalf("start message move task: %v", err) } @@ -596,7 +609,7 @@ func TestSQSServiceAttachStateUsesNamespacedCopySafeSnapshot(t *testing.T) { "queue-a": map[string]domain.QueuePermission{ "label-a": { Label: "label-a", - AWSAccountIDs: []string{"123456789012"}, + AWSAccountIDs: []string{defaultSQSAccountID}, Actions: []string{"SendMessage"}, }, }, @@ -606,8 +619,8 @@ func TestSQSServiceAttachStateUsesNamespacedCopySafeSnapshot(t *testing.T) { "task-1": { TaskHandle: "task-1", SourceQueue: "queue-a", - SourceArn: "arn:aws:sqs:us-east-1:123456789012:queue-a", - DestinationArn: "arn:aws:sqs:us-east-1:123456789012:queue-dlq", + SourceArn: defaultSQSQueueARN("queue-a"), + DestinationArn: defaultSQSQueueARN("queue-dlq"), MaxNumberOfMessagesPerSecond: 10, ApproximateNumberOfMessagesMoved: 2, Status: "RUNNING", @@ -788,7 +801,7 @@ func TestSQSServiceQueueAttributesPopulateDeadLetterRecovery(t *testing.T) { t.Fatalf("create dlq: %v", err) } if _, err := service.CreateQueue("queue-a", map[string]string{ - "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:queue-dlq","maxReceiveCount":"2"}`, + "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("queue-dlq") + `","maxReceiveCount":"2"}`, }); err != nil { t.Fatalf("create source queue: %v", err) } @@ -805,7 +818,7 @@ func TestSQSServiceQueueAttributesPopulateDeadLetterRecovery(t *testing.T) { } if _, err := service.SetQueueAttributes("queue-a", map[string]string{ - "RedrivePolicy": `{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:123456789012:queue-dlq","maxReceiveCount":"3"}`, + "RedrivePolicy": `{"deadLetterTargetArn":"` + defaultSQSQueueARN("queue-dlq") + `","maxReceiveCount":"3"}`, }); err != nil { t.Fatalf("set queue attributes: %v", err) } @@ -931,7 +944,7 @@ func TestSQSServiceNewWithPersistenceLoadsRepositoryState(t *testing.T) { state.QueuePermissions["queue-a"] = map[string]domain.QueuePermission{ "label-a": { Label: "label-a", - AWSAccountIDs: []string{"123456789012"}, + AWSAccountIDs: []string{defaultSQSAccountID}, Actions: []string{"SendMessage"}, }, } @@ -939,8 +952,8 @@ func TestSQSServiceNewWithPersistenceLoadsRepositoryState(t *testing.T) { "task-1": { TaskHandle: "task-1", SourceQueue: "queue-a", - SourceArn: "arn:aws:sqs:us-east-1:123456789012:queue-a", - DestinationArn: "arn:aws:sqs:us-east-1:123456789012:queue-dlq", + SourceArn: defaultSQSQueueARN("queue-a"), + DestinationArn: defaultSQSQueueARN("queue-dlq"), MaxNumberOfMessagesPerSecond: 10, ApproximateNumberOfMessagesMoved: 2, Status: "RUNNING", @@ -997,7 +1010,7 @@ func TestSQSServiceNewWithPersistenceLoadsRepositoryState(t *testing.T) { if got, want := service.state.QueueTags["queue-a"]["env"], "dev"; got != want { t.Fatalf("unexpected queue tags after load: got %q want %q", got, want) } - if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], "123456789012"; got != want { + if got, want := service.state.QueuePermissions["queue-a"]["label-a"].AWSAccountIDs[0], defaultSQSAccountID; got != want { t.Fatalf("unexpected queue permission after load: got %q want %q", got, want) } if got, want := service.state.MoveTasks["queue-a"]["task-1"].Status, "RUNNING"; got != want { diff --git a/core/internal/resources/sqs/contracts/message_test.go b/core/internal/resources/sqs/contracts/message_test.go index a0a0571..ea24384 100644 --- a/core/internal/resources/sqs/contracts/message_test.go +++ b/core/internal/resources/sqs/contracts/message_test.go @@ -4,8 +4,15 @@ import ( "encoding/json" "strings" "testing" + + "github.com/michasdev/mildstack/core/internal/resources/awscontext" ) +func defaultSQSQueueURL(queueName string) string { + aws := awscontext.Default() + return strings.TrimRight(aws.Endpoint, "/") + "/" + aws.AccountID + "/" + queueName +} + func TestMessageContractsPreserveAWSFieldNames(t *testing.T) { t.Helper() @@ -26,7 +33,7 @@ func TestMessageContractsPreserveAWSFieldNames(t *testing.T) { StringValue: "Root=1-12345678-1234567890abcdef12345678", }, }, - QueueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/orders", + QueueUrl: defaultSQSQueueURL("orders"), } data, err := json.Marshal(request) @@ -40,7 +47,7 @@ func TestMessageContractsPreserveAWSFieldNames(t *testing.T) { `"MessageDeduplicationId":"dedupe-1"`, `"MessageGroupId":"group-1"`, `"MessageSystemAttributes"`, - `"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders"`, + `"QueueUrl":"` + defaultSQSQueueURL("orders") + `"`, }) batch := SendMessageBatchRequest{ @@ -60,7 +67,7 @@ func TestMessageContractsPreserveAWSFieldNames(t *testing.T) { `"Entries"`, `"Id":"entry-1"`, `"MessageBody":"payload"`, - `"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/orders"`, + `"QueueUrl":"` + defaultSQSQueueURL("orders") + `"`, }) sendResult := SendMessageResult{