commit 7e49be27f16ecf249edb261f859b1e5c7345fd0b Author: Zakaria Date: Tue Sep 22 16:13:21 2026 -0400 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2a48940 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +packages/*/node_modules +local +data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91b5317 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# floci-api config (packages/api) +FLOCI_ENDPOINT=http://localhost:4566 +FLOCI_AZURE_ENDPOINT=http://localhost:4577 +FLOCI_AZURE_ACCOUNT_NAME=devstoreaccount1 +FLOCI_AZURE_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +FLOCI_AZURE_RESOURCE_GROUP=floci-local +VITE_MOCK_MODE=false +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +PORT=4501 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..57ad0aa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Force LF endings for scripts that run inside Linux containers, regardless +# of the host OS or a contributor's core.autocrlf setting (Git for Windows +# defaults to core.autocrlf=true, which otherwise checks these out as CRLF +# and breaks `/bin/sh` parsing, e.g. "set -e" becoming "set -e\r"). +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d3a166 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Dependencies +node_modules/ + +# npm/yarn lockfiles are local noise for this pnpm workspace +package-lock.json +yarn.lock + +# The Release Cut toolchain is pinned; its lockfile must be committed +# (npm ci fails without it) despite the global package-lock.json ignore. +!.github/release-tooling/package-lock.json + +# Build output +dist/ +dist-ssr/ +build/ + +# Tool caches +.vite/ +.pnpm-store/ +.turbo/ +.parcel-cache/ +.cache/ +.eslintcache + +# Test and coverage output +coverage/ +playwright-report/ +test-results/ +.nyc_output/ + +# Environment files +.env +.env.* +!.env.example + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# TypeScript +*.tsbuildinfo + +# Local editor and IDE state +.idea/ +.vscode/ +*.swp +*.swo + +# Local assistant/tool state +.claude/ + +# Agent instruction symlinks — AGENTS.md is the canonical tracked file. +# Contributors symlink these locally (see CONTRIBUTING.md); don't commit them. +CLAUDE.md +GEMINI.md + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp + +data +local +playwright-report/ +test-results/ diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..41971c2 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,35 @@ +{ + "tagFormat": "${version}", + "branches": [ + "main" + ], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md", + "changelogTitle": "# Changelog\n\nEntries in this file are generated by [semantic-release](https://github.com/semantic-release/semantic-release) from Conventional Commit messages — do not edit it by hand; your commit message is the changelog entry. Historic sections at the bottom were maintained by hand in the Keep a Changelog format." + } + ], + [ + "@semantic-release/exec", + { + "prepareCmd": "npm version ${nextRelease.version} --no-git-tag-version && npm version ${nextRelease.version} --no-git-tag-version --prefix packages/frontend" + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "package.json", + "packages/frontend/package.json", + "CHANGELOG.md" + ], + "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" + } + ], + "@semantic-release/github" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1b64103 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,220 @@ +Guidance for AI coding agents working in the Floci UI repository. + +This file defines repository-specific operating rules for autonomous or semi-autonomous +coding agents. Follow these instructions unless a maintainer explicitly tells you otherwise. + +`AGENTS.md` is the canonical agent-instructions file for this repository, following the +[AGENTS.md standard](https://agents.md/). `CLAUDE.md`, `GEMINI.md`, and +`.github/copilot-instructions.md` are symlinks to this file — edit `AGENTS.md` only. + +--- + +## Project Overview + +Floci UI is the web console / DevTools for [Floci](https://floci.io), the local multi-cloud +emulator. It is an AWS-Console-style UI for a locally running cloud runtime. + +It does **not** emulate anything itself. The frontend renders cloud resources; the API +translates the UI's REST/JSON requests into cloud-SDK calls against the locally running +Floci emulators (AWS, Azure, GCP). + +- pnpm workspace monorepo, two packages: + - `packages/frontend` — React + Vite + TypeScript, served on port `4500` + - `packages/api` — Bun + Hono + AWS SDK v3, served on port `4501` +- Emulator endpoints it talks to: Floci core (AWS) `:4566`, Floci-AZ `:4577`, Floci-GCP `:4588` + +--- + +## First Principles + +When making changes, follow these priorities: + +1. Use real cloud-provider contracts — never invent custom backend endpoints for UI convenience +2. Reuse the schema-driven multi-cloud pattern instead of bespoke per-service code +3. Keep the frontend talking only to `/api/*`; never reach a cloud endpoint directly from the browser +4. Prefer real empty states over fake/sample data +5. Keep changes narrow and testable + +Critical rules: + +- Do not add custom protocols just for the UI unless the core project accepts that contract +- Do not have the frontend call AWS/Azure/GCP endpoints directly — always go through `packages/api` +- Do not introduce decorative data or fake operational metrics — unwired states stay empty +- Do not perform broad refactors unless the task explicitly requires them + +--- + +## Architecture + +``` +Browser (React/Vite :4500) + → /api/* (Hono, Bun :4501) + → CloudProxyService → CloudAdapterRegistry → CloudServiceAdapter + → AWS SDK v3 (:4566) | Floci-AZ HTTP (:4577) | Floci-GCP HTTP (:4588) +``` + +The repo is mid-migration from an older **AWS-only per-service** style to a newer +**schema-driven, multi-cloud generic explorer**. The generic pattern is the one to use +for all new work; the legacy routes survive only for deep EC2 panels and Secrets Manager. + +### The multi-cloud SPI (the part you will use most) + +- `packages/api/src/cloud-spi/serviceCatalog.ts` — **the single source of truth for which + services exist.** `CloudServiceType` derives from its keys; nav metadata (display name, + icon hint, group, route) is served to the frontend from here. +- `packages/api/src/cloud-spi/types.ts` — `CloudProvider` (`aws|azure|gcp`), the + `CloudServiceAdapter` interface, `ServiceSchema`, and the status shapes. +- `packages/api/src/cloud-spi/errors.ts` — the typed errors adapters throw; mapped to HTTP + once in `routes/clouds.ts` (with `adapter-aws/awsErrors.ts` for SDK failures). +- `packages/api/src/registry/CloudAdapterRegistry.ts` — registry keyed by `"cloud:service"`. + Availability is derived from it, so registering an adapter is what lights up the nav. +- `packages/api/src/service/CloudProxyService.ts` — the single dispatcher. +- `packages/api/src/service/runtimeProbe.ts` — per-runtime liveness probes. +- `packages/api/src/cloudProxy.ts` — where adapters are instantiated and registered. +- `packages/api/src/routes/clouds.ts` — the generic `/api/clouds/...` REST surface. +- `packages/api/src/cloudProxy.test.ts` — guards that no schema advertises a capability its + adapter cannot perform. + +A `ServiceSchema` (fields, `actions`, `capabilities`, `filters`, `columns`) drives the UI: +the frontend's `DynamicResourceView` renders list / create / delete / inspect generically +from the schema — most services need **no bespoke UI**. + +### Frontend layout + +- `packages/frontend/src/App.tsx` — routes (`/console/:cloud`, `/cloud-explorer/:cloud/:service`) +- `packages/frontend/src/components/Layout.tsx` — nav, rendered from `GET /clouds/:cloud/services` +- `packages/frontend/src/api/queries/cloudQueries.ts` — shared cloud/service/status queries +- `packages/frontend/src/components/serviceIcons.ts` — `iconKey` -> component, with a fallback +- `packages/frontend/src/components/DynamicResourceView.tsx` — schema → table/form/inspector orchestrator +- Reusable: `ResourceTable`, `DynamicFormRenderer`, `ResourceInspector`, `StorageObjectBrowser`, + `CosmosNoSqlPanel`, `EmptyState`, `lib/capabilities.ts` +- API client: `src/api/cloudProxyClient.ts`, `src/api/api.ts`, `src/api/HttpClient.ts` + +### Legacy (do not extend without reason) + +`packages/api/src/routes/{ec2,rds,eks,secretsmanager}.ts` and the matching +`features/ec2/*` frontend code. New services go through the generic SPI, not here. + +--- + +## Build & Run + + pnpm install + pnpm dev # API (:4501) + frontend (:4500) together + pnpm dev:api # API only + pnpm dev:web # frontend only + +Requires a running Floci core (`:4566`) — see `README.md` / `docker compose` (use the +`multicloud` profile to also start Azure + GCP). + +### Checks (run all before finishing) + + pnpm lint # eslint, frontend + pnpm type-check # tsc on both packages + pnpm test # bun test, packages/api + pnpm build # production build + +--- + +## Adding a New Service to the Cloud Explorer + +This is the canonical pattern (also referenced by the open service-coverage issues). + +**Backend (`packages/api`) — this is the whole change:** + +1. Add one row to `SERVICE_CATALOG` in `src/cloud-spi/serviceCatalog.ts` (only for a new + category). `CloudServiceType`, the route guard, and the nav metadata all derive from it. +2. `src/cloud-spi/Schema.ts` — export a per-cloud `Schema()` + returning a `ServiceSchema`. Model: `src/cloud-spi/storageSchema.ts`. Use `path` on a + column to surface a `metadata.*` field. +3. `src/adapter-/Adapter.ts implements CloudServiceAdapter` with a + `.test.ts` alongside. Model: `src/adapter-aws/AwsStorageAdapter.ts`. AWS adapters use AWS + SDK v3 against `FLOCI_ENDPOINT`; Azure/GCP adapters take the shared runtime client + (`AzureRuntimeClient` in `azure.ts`, `GcpRuntimeClient` in `gcp.ts`) — do not hand-roll fetch. +4. Register it in `src/cloudProxy.ts`. + +That is it. `services()` derives availability from the registry, `schema()` serves only +registered adapters, and the generic `/api/clouds/...` routes need no new handler. + +**Frontend (`packages/frontend`): normally nothing.** + +The nav, Console Home, and Cloud Explorer render `GET /clouds/:cloud/services`. Optional: +add an `iconKey` to `components/serviceIcons.ts` (an unknown key falls back to a generic +icon, so this is cosmetic), and add a `service === ''` panel in `DynamicResourceView` +only for deep UX (models: `ComputePanel`, `NetworkingPanel`, `CosmosNoSqlPanel`). + +Rules: + +- Copy an existing adapter + schema before introducing a new shape. +- Throw the typed errors in `src/cloud-spi/errors.ts`, never a bare `Error` — + `routes/clouds.ts` maps them to HTTP and no longer matches on message text. +- Never advertise a capability the adapter cannot perform. `src/cloudProxy.test.ts` + fails the build if a capability marked `available` has no adapter method, or if + anything not `available` lacks a `reason`. Use `descriptorOverride()` when the + adapter exists but the local runtime does not implement it. +- Regenerate the README table when navigation changes: + `cd packages/api && bun run scripts/service-matrix.ts`. + +--- + +## Code Style + +- TypeScript throughout; prefer self-explanatory code over comments +- Match the surrounding code's naming, structure, and idiom +- Frontend: function components + hooks; data fetching via the existing React Query wrappers +- Keep controllers/routes thin; put logic in adapters/services +- Follow existing project patterns; introduce new patterns only when they clearly improve clarity + +--- + +## Testing + +- API tests use `bun test` (`packages/api`); colocate `*.test.ts` next to the adapter +- Add or update tests for any change to request handling, response shape, or adapter behavior +- Documentation, formatting, or low-risk refactors may not need new tests, but the existing + suite plus `pnpm lint`, `pnpm type-check`, and `pnpm build` must still pass +- If you change behavior without adding coverage, say why in the PR + +--- + +## Pull Request Guidelines + +- Keep PRs focused — one feature or fix per PR; avoid unrelated refactors +- Branch off `main`; open the PR against `main` +- The PR title must follow [Conventional Commits](https://www.conventionalcommits.org/) + (it becomes the squash-merge commit). Scopes identify the package or service area + (`frontend`, `api`, `s3`, `serverless`, `docker`, `ci`, …) +- Keep README service-status notes accurate; add verification notes for newly wired operations + +Do not add `Co-Authored-By` trailers for AI tools in commit messages. Keep attribution +limited to human contributors. + +--- + +## Release Awareness + +Releases are tag-driven. Docker images are never published on PR merge — only when a +maintainer pushes an `X.Y.Z` tag, which triggers `.github/workflows/release.yml` to build +and push the multi-arch `floci/floci-ui` image. Treat release workflows as critical infra. + +--- + +## Common Mistakes + +- Adding custom backend endpoints instead of using real cloud contracts / the generic SPI +- Calling cloud endpoints directly from the frontend instead of through `/api/*` +- Adding fake/sample data instead of real empty states +- Extending the legacy `ec2/rds/eks/secretsmanager` routes for new work +- Forgetting to register the adapter in `cloudProxy.ts` — that registration *is* what makes + the service appear; do not hardcode availability in the frontend +- Advertising a schema capability the adapter cannot perform (`cloudProxy.test.ts` catches it) +- Throwing a bare `Error` from an adapter instead of a typed error from `cloud-spi/errors.ts` +- Skipping `pnpm type-check` / `pnpm test` before finishing + +--- + +## Human Handoff + +If behavior is unclear, prefer the real cloud-provider contract, then the existing Floci UI +convention, then the corresponding emulator's behavior. If a task would require broad +architectural change, stop and surface the tradeoffs instead of refactoring blindly. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a222604 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,130 @@ +# Changelog + +Entries in this file are generated by [semantic-release](https://github.com/semantic-release/semantic-release) from Conventional Commit messages — do not edit it by hand; your commit message is the changelog entry. Historic sections at the bottom were maintained by hand in the Keep a Changelog format. + +# [0.5.0](https://github.com/floci-io/floci-ui/compare/0.4.0...0.5.0) (2026-09-15) + + +### Bug Fixes + +* **ci:** allow floci-ui to run on older cpus ([#189](https://github.com/floci-io/floci-ui/issues/189)) ([1ee7b51](https://github.com/floci-io/floci-ui/commit/1ee7b517c5f8766470cc527bd5889a624f97a404)), closes [#188](https://github.com/floci-io/floci-ui/issues/188) +* **frontend:** align dynamic form fields to top ([#202](https://github.com/floci-io/floci-ui/issues/202)) ([9031bb6](https://github.com/floci-io/floci-ui/commit/9031bb607f079a16845c100a6f8b2dcd3ceda228)) +* **frontend:** derive create-button label from schema capability ([#165](https://github.com/floci-io/floci-ui/issues/165)) ([ffaddf0](https://github.com/floci-io/floci-ui/commit/ffaddf0b210ec9590b377d78d375b3752a968b98)), closes [#163](https://github.com/floci-io/floci-ui/issues/163) +* **s3:** include bucket tags in storage resource metadata ([#203](https://github.com/floci-io/floci-ui/issues/203)) ([234a933](https://github.com/floci-io/floci-ui/commit/234a93323e898521f9262751c874759ec84f14c5)) + + +### Features + +* **cloud-spi:** add vendor-neutral child collections with AWS CloudWatch Logs ([#170](https://github.com/floci-io/floci-ui/issues/170)) ([0de1513](https://github.com/floci-io/floci-ui/commit/0de15138882730de62c21835f9441b8abb834847)), closes [#160](https://github.com/floci-io/floci-ui/issues/160) [#162](https://github.com/floci-io/floci-ui/issues/162) +* **containers:** add a containers category with GCP Cloud Run ([#160](https://github.com/floci-io/floci-ui/issues/160)) ([95f0cc2](https://github.com/floci-io/floci-ui/commit/95f0cc20fd3f4419e374e7adf19b91918527f653)), closes [#156](https://github.com/floci-io/floci-ui/issues/156) [#157](https://github.com/floci-io/floci-ui/issues/157) [#158](https://github.com/floci-io/floci-ui/issues/158) [#159](https://github.com/floci-io/floci-ui/issues/159) +* **gcp:** add Cloud Scheduler adapter to Cloud Explorer ([#185](https://github.com/floci-io/floci-ui/issues/185)) ([eb3214f](https://github.com/floci-io/floci-ui/commit/eb3214f56937570f53e9b3ae65f86c958806ea12)), closes [#103](https://github.com/floci-io/floci-ui/issues/103) +* **k8s:** add an Azure AKS adapter ([#164](https://github.com/floci-io/floci-ui/issues/164)) ([12f344a](https://github.com/floci-io/floci-ui/commit/12f344a178549b642735af7e7d8bd9fd257eaec4)) +* **kms:** add an AWS KMS adapter ([#158](https://github.com/floci-io/floci-ui/issues/158)) ([c85d189](https://github.com/floci-io/floci-ui/commit/c85d1895a71babf879daf16ca368fa68a82935e0)), closes [#156](https://github.com/floci-io/floci-ui/issues/156) [#157](https://github.com/floci-io/floci-ui/issues/157) +* **nosql:** add record creation ([#208](https://github.com/floci-io/floci-ui/issues/208)) ([77cd563](https://github.com/floci-io/floci-ui/commit/77cd563004df4c0aabdd35bb80f6b179413df073)), closes [#207](https://github.com/floci-io/floci-ui/issues/207) +* **parameters:** add an AWS SSM Parameter Store adapter ([#159](https://github.com/floci-io/floci-ui/issues/159)) ([8a28c2f](https://github.com/floci-io/floci-ui/commit/8a28c2fe23743857ed5b9f1d78b9db603dde4711)), closes [#156](https://github.com/floci-io/floci-ui/issues/156) [#157](https://github.com/floci-io/floci-ui/issues/157) [#158](https://github.com/floci-io/floci-ui/issues/158) +* **rds:** implement RDS database and snapshot explorer ([#210](https://github.com/floci-io/floci-ui/issues/210)) ([e2c8d5c](https://github.com/floci-io/floci-ui/commit/e2c8d5c22d45f7db0c23a2c0050b7452fc405efa)), closes [#105](https://github.com/floci-io/floci-ui/issues/105) [#211](https://github.com/floci-io/floci-ui/issues/211) +* **rds:** support RDS instance updates and edit modal ([#211](https://github.com/floci-io/floci-ui/issues/211)) ([e5cd879](https://github.com/floci-io/floci-ui/commit/e5cd879992d5ab1fccc48c8e8ff2bfc0691c9960)), closes [#210](https://github.com/floci-io/floci-ui/issues/210) [#210](https://github.com/floci-io/floci-ui/issues/210) +* **secrets:** add a secrets category with Secrets Manager and Secret Manager ([#156](https://github.com/floci-io/floci-ui/issues/156)) ([a655e88](https://github.com/floci-io/floci-ui/commit/a655e8849083fbddf30cbac3a1ad5c8b1a53506c)), closes [#155](https://github.com/floci-io/floci-ui/issues/155) [#157](https://github.com/floci-io/floci-ui/issues/157) [#157](https://github.com/floci-io/floci-ui/issues/157) +* **workflows:** add an AWS Step Functions adapter ([#167](https://github.com/floci-io/floci-ui/issues/167)) ([d443ddc](https://github.com/floci-io/floci-ui/commit/d443ddcf06b39ea59c4cc34f29d5235a3cf18c8a)), closes [#154](https://github.com/floci-io/floci-ui/issues/154) [#154](https://github.com/floci-io/floci-ui/issues/154) [#154](https://github.com/floci-io/floci-ui/issues/154) + +# [0.4.0](https://github.com/floci-io/floci-ui/compare/0.3.0...0.4.0) (2026-09-01) + + +### Bug Fixes + +* **ec2:** include catalog AMIs in launch selector ([#191](https://github.com/floci-io/floci-ui/issues/191)) ([b72135d](https://github.com/floci-io/floci-ui/commit/b72135d7daa08f5fe73973855958493daf6a3229)) + + +### Features + +* **aws:** add CloudFormation adapter to Cloud Explorer ([#184](https://github.com/floci-io/floci-ui/issues/184)) ([f3d6105](https://github.com/floci-io/floci-ui/commit/f3d6105c01be9ffef57525e031790fd0c1a9c351)), closes [#81](https://github.com/floci-io/floci-ui/issues/81) [#75](https://github.com/floci-io/floci-ui/issues/75) [#81](https://github.com/floci-io/floci-ui/issues/81) [#75](https://github.com/floci-io/floci-ui/issues/75) [#81](https://github.com/floci-io/floci-ui/issues/81) +* **aws:** add EventBridge explorer ([#146](https://github.com/floci-io/floci-ui/issues/146)) ([42944e9](https://github.com/floci-io/floci-ui/commit/42944e93843792ca829f6e9b5c49d6b8f7a4c57a)), closes [#85](https://github.com/floci-io/floci-ui/issues/85) +* **aws:** add IAM to Cloud Explorer ([#145](https://github.com/floci-io/floci-ui/issues/145)) ([fc50d19](https://github.com/floci-io/floci-ui/commit/fc50d19e4517667d7deb26be5ef72a898f26a714)), closes [#79](https://github.com/floci-io/floci-ui/issues/79) +* **aws:** add Secrets Manager resource adapter ([#193](https://github.com/floci-io/floci-ui/issues/193)) ([4811866](https://github.com/floci-io/floci-ui/commit/481186623386f2b6d46b2ddbc3a0b2dc2a8ce0e0)) +* **azure:** add databases and split Cosmos NoSQL ([#149](https://github.com/floci-io/floci-ui/issues/149)) ([2e704f4](https://github.com/floci-io/floci-ui/commit/2e704f471d4f1e60c0de8bffa287ac0b4baf9623)), closes [#92](https://github.com/floci-io/floci-ui/issues/92) [#67](https://github.com/floci-io/floci-ui/issues/67) [floci-io/floci-az#138](https://github.com/floci-io/floci-az/issues/138) [#143](https://github.com/floci-io/floci-ui/issues/143) +* **azure:** add Service Bus explorer ([#144](https://github.com/floci-io/floci-ui/issues/144)) ([a9f0d06](https://github.com/floci-io/floci-ui/commit/a9f0d067472ab7e76bb6029b751a1facd3a1f2c5)), closes [#89](https://github.com/floci-io/floci-ui/issues/89) +* **eks:** Manage nodegroups and Fargate profiles via Cloud Proxy ([#194](https://github.com/floci-io/floci-ui/issues/194)) ([3091cc9](https://github.com/floci-io/floci-ui/commit/3091cc9e40129ad7be7efcabf8d3c91b3ec660a7)), closes [#106](https://github.com/floci-io/floci-ui/issues/106) +* **loadbalancing:** add an AWS Elastic Load Balancing adapter ([#168](https://github.com/floci-io/floci-ui/issues/168)) ([10d4298](https://github.com/floci-io/floci-ui/commit/10d429877a08244a3d7f36721ccdcb36ee1299cf)), closes [#162](https://github.com/floci-io/floci-ui/issues/162) [#162](https://github.com/floci-io/floci-ui/issues/162) [#156](https://github.com/floci-io/floci-ui/issues/156) +* **messaging:** add a messaging category with SQS and Pub/Sub ([#157](https://github.com/floci-io/floci-ui/issues/157)) ([dfa6d1c](https://github.com/floci-io/floci-ui/commit/dfa6d1cf67e82d2397e5eeb0e5cccdc7c013b931)), closes [#155](https://github.com/floci-io/floci-ui/issues/155) [#155](https://github.com/floci-io/floci-ui/issues/155) [#156](https://github.com/floci-io/floci-ui/issues/156) [#156](https://github.com/floci-io/floci-ui/issues/156) +* **secretsmanager:** add JSON key-value editor for secret values ([#195](https://github.com/floci-io/floci-ui/issues/195)) ([8e88961](https://github.com/floci-io/floci-ui/commit/8e889610f43d00146e77bbc93fcbe88a2277bdc5)), closes [#151](https://github.com/floci-io/floci-ui/issues/151) +* **ses:** Add AWS SES mailbox to Cloud Explorer ([#196](https://github.com/floci-io/floci-ui/issues/196)) ([6389a56](https://github.com/floci-io/floci-ui/commit/6389a568cd65649b4fd175aebbd3ac3d86c03aa8)), closes [#130](https://github.com/floci-io/floci-ui/issues/130) + +# [0.3.0](https://github.com/floci-io/floci-ui/compare/0.2.0...0.3.0) (2026-08-18) + + +### Bug Fixes + +* **docker:** frontend dev container missing rolldown native binding ([#152](https://github.com/floci-io/floci-ui/issues/152)) ([a531a1b](https://github.com/floci-io/floci-ui/commit/a531a1b320aff61711b746a53a562c30cb444179)) +* **docker:** init scripts failing on Windows due to CRLF line endings ([#138](https://github.com/floci-io/floci-ui/issues/138)) ([238e924](https://github.com/floci-io/floci-ui/commit/238e92446d965f8ba08a1afe2f4cec01ce1ecd2a)) +* **docker:** mount the Docker socket so Lambda and Cloud SQL can run ([#154](https://github.com/floci-io/floci-ui/issues/154)) ([8ee3715](https://github.com/floci-io/floci-ui/commit/8ee37152f9e5380c2c2781f1aa59d77ba46c03fb)) +* **frontend:** colour the connection dot from the connection state ([#181](https://github.com/floci-io/floci-ui/issues/181)) ([0e0682d](https://github.com/floci-io/floci-ui/commit/0e0682de8d6b5498b6d8850bb2af5fbade62b95a)) +* **frontend:** show app-level validation messages in dynamic create form ([#150](https://github.com/floci-io/floci-ui/issues/150)) ([69ba389](https://github.com/floci-io/floci-ui/commit/69ba38928fd94810d6ba855920dc24a4c1a25957)), closes [#112](https://github.com/floci-io/floci-ui/issues/112) + + +### Features + +* **api:** derive service availability from a single catalog ([#153](https://github.com/floci-io/floci-ui/issues/153)) ([2d62143](https://github.com/floci-io/floci-ui/commit/2d621434dc974399aec2db3e1b6050686597bcf7)) +* **aws:** add API Gateway to Cloud Explorer ([#169](https://github.com/floci-io/floci-ui/issues/169)) ([07fe1c6](https://github.com/floci-io/floci-ui/commit/07fe1c6081ba945dd776cc96864b0beceec0c786)) +* **aws:** add DynamoDB Cloud Explorer ([#143](https://github.com/floci-io/floci-ui/issues/143)) ([074eef3](https://github.com/floci-io/floci-ui/commit/074eef3e5bbd2dd4f88d20a85a2746a772e830f6)) +* **azure:** add Key Vault explorer ([#147](https://github.com/floci-io/floci-ui/issues/147)) ([90dd60e](https://github.com/floci-io/floci-ui/commit/90dd60e993b5dee0fe030fab0310e52ee5f6a46b)) +* **compute:** add an Azure Virtual Machines adapter ([#161](https://github.com/floci-io/floci-ui/issues/161)) ([06541a5](https://github.com/floci-io/floci-ui/commit/06541a508845b762b279cbcbd1f19592f4b4d612)) +* **foundation:** add iac service-type category ([#178](https://github.com/floci-io/floci-ui/issues/178)) ([c9e9717](https://github.com/floci-io/floci-ui/commit/c9e9717896797b9dd42676d961c6b9335efbcccf)) +* **frontend:** collapsible sidebar with icon rail ([#180](https://github.com/floci-io/floci-ui/issues/180)) ([2f05f46](https://github.com/floci-io/floci-ui/commit/2f05f46e47ae285e0fda32d5c63a678bd0f74022)) +* **gcp:** add Cloud SQL and GKE adapters ([#155](https://github.com/floci-io/floci-ui/issues/155)) ([fd3bd2f](https://github.com/floci-io/floci-ui/commit/fd3bd2fcdf4b3a79e4832e55af5ed5bcf78e8dd1)) +* **release:** one-button release cut from main ([#187](https://github.com/floci-io/floci-ui/issues/187)) ([d7e940b](https://github.com/floci-io/floci-ui/commit/d7e940bd7c223a5d2762dc41d48a14735d7bc32c)) +* **s3:** add inline preview for images, video, audio and text objects ([#177](https://github.com/floci-io/floci-ui/issues/177)) ([8b945fd](https://github.com/floci-io/floci-ui/commit/8b945fd4880d66701d3acfd44e2bd0b752020de9)) +* **serverless:** Add loading, empty, and retry states to Azure Functions view ([#136](https://github.com/floci-io/floci-ui/issues/136)) ([c4bd78f](https://github.com/floci-io/floci-ui/commit/c4bd78f7785eb6ad3f044349d02436d3d189a277)), closes [#114](https://github.com/floci-io/floci-ui/issues/114) +* **serverless:** improve Azure Functions metadata [#113](https://github.com/floci-io/floci-ui/issues/113) ([#140](https://github.com/floci-io/floci-ui/issues/140)) ([bcd21a3](https://github.com/floci-io/floci-ui/commit/bcd21a3f586d3e6106c4aee74aa82bb4bb0fb868)) + +## [0.2.0] - 2026-07-08 + +### Added + +- AWS Secrets Manager console page: list, inspect, create, and delete secrets (scheduled or forced deletion). +- Azure Functions in the Cloud Explorer: list, inspect, create, delete, and invoke. +- GCP Cloud Functions in the Cloud Explorer, including invoke support. +- Serverless invoke panel payload tooling: pre-invoke validation plus format, sample, and clear payload actions. +- Account switcher scoping the Cloud Explorer to a selectable AWS account. +- Service information dialog describing each service's capabilities, runtime adapter, and connection state. + +### Changed + +- Decluttered the Cloud Explorer service view: the resource table now leads the page, diagnostics moved into a compact topbar ⓘ info dialog, and the resource inspector only renders when a resource is selected. +- Migrated the Secrets Manager frontend to the shared `HttpClient`/`ApiRegistry` pattern and wired it into the navigation shell. +- Updated all JavaScript dependencies and adapted the codebase to TypeScript 6 and stricter ESLint rules. +- Streamlined the local development setup. + +### Fixed + +- Secrets drawer opening partially off-screen. +- Duplicate serverless invoke client export. + +## [0.1.0] - 2026-06-14 + +### Added + +- Theme-aware Floci brand logo (light/dark) and a brand-aligned indigo color palette sourced from floci.io. +- `multicloud` Docker Compose profile to start the Azure and GCP emulators alongside the AWS runtime. +- Continuous Integration workflow running lint, type-check, test, and build on pull requests. +- Multi-architecture (`amd64` + `arm64`) Docker release workflow that publishes `floci/floci-ui` on version tags. +- End-to-end integration workflow that runs the full stack against the real `floci/floci` runtime image. +- Conventional Commits validation on pull requests. +- Contributor tooling: `CONTRIBUTING.md`, issue and pull request templates, `CODEOWNERS`, and Dependabot configuration. + +### Changed + +- **Breaking:** standardized local ports to the Floci `45xx` range — UI now on `4500` and API on `4501` (were `3000`/`3001`). +- Consolidated `docker-compose.dev.yml` into a single `docker-compose.yml`. +- Reorganized Dockerfiles under `docker/` and added a packaging image that bundles CI-built artifacts for releases. +- Upgraded the frontend stack: React 19, Vite 8, React Router 7, and ESLint 10 (migrated to flat config), plus grouped dependency updates. +- Upgraded the API dependencies: AWS SDK and Hono. +- Bumped pinned GitHub Actions (`checkout`, `setup-node`, `pnpm/action-setup`, `setup-qemu`). +- Revamped the README with the brand logo, status badges, a connected-console screenshot, and a quick-start section. +- Moved the README logo assets into `docs/images/`. + +### Removed + +- `docker-compose.dev.yml` (folded into `docker-compose.yml`). + +[0.2.0]: https://github.com/floci-io/floci-ui/compare/0.1.0...0.2.0 +[0.1.0]: https://github.com/floci-io/floci-ui/releases/tag/0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a04b925 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,233 @@ +# Contributing to Floci UI + +Thank you for your interest in contributing! Floci UI is the web console / DevTools +for the [Floci](https://floci.io) local cloud emulator. It is a community-driven +project and all contributions are welcome. + +## Ways to Contribute + +- **Bug reports**: open an issue with a minimal reproduction (steps, expected vs. actual) +- **Feature requests**: open an issue describing the console feature or service page you need +- **Pull requests**: bug fixes, new service UI, or UX improvements +- **Service coverage**: wire a new Floci-backed service into the console + +## Project Layout + +This is a pnpm workspace monorepo with two packages: + +| Package | Stack | Responsibility | +|---|---|---| +| `packages/frontend` | React + Vite + TypeScript | The console UI served on port `4500` | +| `packages/api` | Bun + Hono + AWS SDK v3 | Backend on port `4501` that translates the UI's REST/JSON requests into AWS SDK calls against Floci core | + +The frontend talks only to `/api/*` (proxied to `packages/api`); the API talks to the +Floci emulators. Never have the frontend reach a cloud endpoint directly. + +## Getting Started + +### Prerequisites + +- Node.js 20+ +- pnpm 9+ +- [Bun](https://bun.sh/) (required by `packages/api`) +- Docker (optional, only for running the full stack via `docker compose`) + +### Install + +```bash +git clone https://github.com/floci-io/floci-ui.git +cd floci-ui +pnpm install +``` + +### Run + +The UI needs three components running: Floci core (`:4566`), the API (`:4501`), and the +frontend (`:4500`). + +**Option A, Docker Compose (recommended):** + +```bash +docker compose up # AWS-only +docker compose --profile multicloud up # adds Azure + GCP emulators +``` + +**Option B, local dev (three terminals):** + +```bash +# 1. Floci core (see README for the docker run / local-clone options) +# 2. API backend +pnpm dev:api +# 3. Frontend +pnpm dev +``` + +Open the UI at http://127.0.0.1:4500/. See [README.md](README.md) for full setup, +environment variables, and troubleshooting. + +### Checks + +Run these before opening a PR: + +```bash +pnpm lint # eslint (frontend) +pnpm type-check # tsc on both packages +pnpm test # bun test (api) +pnpm build # production build +``` + +## Branching Model + +Floci UI uses a **tag-driven release model**. Docker images are never published on PR +merge, only when a version tag is pushed, which the Release Cut workflow does. + +| Branch / ref | Purpose | Docker published? | +|---|---|---| +| `main` | Integration branch: all PRs merge here. Treated as unstable. | No | +| `X.Y.Z` tag | Signals a release. Triggers the multi-arch Docker publish pipeline. | Yes (`floci/floci-ui:x.y.z`, `floci/floci-ui:latest`) | + +## Commit Message Format + +This project uses [Conventional Commits](https://www.conventionalcommits.org/). + +> **The PR title should follow this format**, since it becomes the squash-merge commit message. + +### Format + +``` +[optional scope]: +``` + +- **type**: one of the values in the table below (lowercase) +- **scope**: optional, in parentheses, identifies the package or service area + (e.g. `frontend`, `api`, `s3`, `ec2`, `serverless`, `docker`, `ci`) +- **description**: short summary in the imperative mood, no trailing period +- Append `!` before the colon to signal a breaking change: `feat(api)!:` + +| Type | When to use | Version bump | +|------|-------------|--------------| +| `feat` | New console feature, service page, or API route | minor | +| `fix` | Bug fix or compatibility correction | patch | +| `perf` | Performance improvement | patch | +| `revert` | Reverts a previous commit | patch | +| `docs` | Documentation only | none | +| `style` | Formatting, whitespace, no logic change | none | +| `chore` | Build, CI, dependencies, housekeeping | none | +| `refactor` | Code restructure without behavior change | none | +| `test` | Adding or updating tests | none | +| `build` | Build system or tooling changes | none | +| `ci` | CI workflow changes | none | +| `BREAKING CHANGE` | Footer or `!` suffix, incompatible change | major | + +### Valid examples ✅ + +``` +feat(s3): add object preview panel to the bucket explorer +fix(api): handle missing region in EC2 status call +perf(frontend): memoize the service catalog query +chore: bump vite to 5.4 +docs: update README with multicloud compose profile +refactor(serverless): extract lambda env-var mapping +test(api): cover the clouds/aws/status route +feat(api)!: change resource list response shape +ci: add multi-arch release workflow +``` + +### Invalid examples ❌ + +``` +Add object preview # missing type +Feature: add something # "Feature" is not a valid type +feat : space before colon # space before colon +feat(s3)add missing colon # missing colon +FIX(api): uppercase type # type must be lowercase +feat(my scope): scope has spaces # scope cannot contain spaces +wip: still working on this # "wip" is not a recognised type +``` + +Do not include `Co-Authored-By` trailers for AI tools in commit messages. Attribution +should be limited to human contributors. + +## Architecture & Agent Instructions + +See [AGENTS.md](AGENTS.md) for a detailed description of the architecture (frontend → +`/api/*` → Cloud Proxy → adapters), the schema-driven multi-cloud model, and the canonical +pattern for adding a new service to the Cloud Explorer. + +`AGENTS.md` is the canonical agent-instructions file for this repository, following the +[AGENTS.md standard](https://agents.md/). The agent-specific filenames are symlinks to it, +so there is a single source of truth. If your coding agent expects a different filename, +create a local symlink to `AGENTS.md` instead of copying the file: + +```bash +ln -s AGENTS.md CLAUDE.md +ln -s AGENTS.md GEMINI.md +ln -s ../AGENTS.md .github/copilot-instructions.md +``` + +## Adding a New Service UI + +When wiring a new service into the console, follow these rules: + +- Use existing Floci AWS-compatible endpoints. Do not add custom backend endpoints + just for the UI unless the core project explicitly accepts that contract. +- In `packages/api`, add a route that calls the appropriate AWS SDK v3 client against + Floci core. Never invent a custom protocol. +- In `packages/frontend`, add the service page and register it in the navigation. +- Prefer real empty states over sample data; show placeholders when a service is not + wired yet. No decorative data or fake operational metrics. +- Keep service status notes in the README accurate. +- Add verification notes for any newly wired operations. + +## Pull Request Guidelines + +1. Branch off `main`: `git checkout -b feat/my-feature` +2. Open a PR targeting `main`. +3. Make sure `pnpm lint`, `pnpm type-check`, `pnpm test`, and `pnpm build` all pass before requesting review. +4. Keep PRs focused: one feature or fix per PR. +5. Reference any related issues in the PR description. + +Docker images are never built on contributor PRs, so merging to `main` is always cheap. + +## Release Process (maintainers) + +Releases are cut when there is something worth shipping. Unlike the emulators, floci-ui is +not on a fixed train. + +Releases are cut from `main` with the **Release Cut** workflow (Actions -> Release Cut -> +Run workflow). semantic-release analyzes the Conventional Commits since the last tag, bumps +the version in `package.json` and `packages/frontend/package.json`, regenerates +`CHANGELOG.md`, commits, tags, and publishes the GitHub Release. Use the `dry-run` input to +preview the next version and release notes without releasing anything. + +The tag push then triggers `.github/workflows/release.yml`, which builds the frontend bundle +and the API binary, then packages and pushes a multi-arch (`linux/amd64,linux/arm64`) +image as `floci/floci-ui:x.y.z` and `floci/floci-ui:latest`. + +Publishing requires the `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` repository secrets, and +the Release Cut workflow requires `GH_TOKEN` to be a fine-grained PAT with contents:write. +The default `GITHUB_TOKEN` cannot be used: tags pushed with it do not trigger other +workflows, so `release.yml` would never run. + +`CHANGELOG.md` is generated. Do not edit it by hand; a genuine correction goes in a PR +carrying the `changelog-edit` label. + +Nothing is published on PR merge: only a release cut produces artifacts. + +## Testing Policy for Pull Requests + +As a project policy: + +- Pull requests that introduce new behavior should include tests that validate it + (`bun test` in `packages/api`), or a clear note on why they can't be tested in isolation. +- Pull requests that fix bugs should include a regression test whenever realistic. +- Documentation, formatting, dependency housekeeping, or low-risk refactors may not + require new tests, but `pnpm lint`, `pnpm type-check`, and the existing suite must + still pass. + +All checks (`pnpm lint`, `pnpm type-check`, `pnpm test`, `pnpm build`) must pass before merge. + +## Reporting Security Issues + +Please do **not** open public issues for security vulnerabilities. Report them privately +using [GitHub private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..17cc88f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hector Ventura + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..e2642f2 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,15 @@ +# Maintainers + +This file lists the people responsible for this repository. See the org-wide +[GOVERNANCE.md](https://github.com/floci-io/.github/blob/main/GOVERNANCE.md) for roles and the +path to becoming a Maintainer. + +## Lead Maintainer +- Hector Ventura (@hectorvent): final authority across the project. + +## Maintainers (this repository) +- Fred Peña (@fredpena): the web console, `packages/frontend` and `packages/api`. +- Hector Ventura (@hectorvent): release infrastructure and the emulator contracts the API talks to. + +## Emeritus +- (none yet) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ddc76c --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: up up-multicloud down logs build push dev dev-build dev-down dev-logs dev-api dev-frontend install + +up: + docker compose up -d + +up-multicloud: + docker compose --profile multicloud up -d + +down: + docker compose --profile multicloud down + +logs: + docker compose logs -f + +build: + docker build -f docker/Dockerfile -t floci/floci-ui:latest . + +push: build + docker push floci/floci-ui:latest + +install: + cd packages/frontend && npm install + cd packages/api && ~/.bun/bin/bun install + +dev-build: + docker compose build + +dev: + docker compose up + +dev-down: + docker compose down + +dev-logs: + docker compose logs -f + +dev-api: + cd packages/api && ~/.bun/bin/bun run --watch src/index.ts + +dev-frontend: + cd packages/frontend && npm run dev diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e18209 --- /dev/null +++ b/README.md @@ -0,0 +1,602 @@ +

+ Floci UI + Floci UI +

+ +

+ Any Cloud. Locally.
+ A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators. +

+ +

+ Latest Release + CI Status + Docker Pulls + Docker Image Size + License: MIT +

+ +

+ Floci UI console +

+ +Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet. + +No fake resources, no demo rows, and no mock operational data are shown in normal mode. + +## Quick Start + +AWS-only stack: + +```bash +docker compose up +``` + +Full multi-cloud stack: + +```bash +docker compose --profile multicloud up +``` + +Open [http://localhost:4500](http://localhost:4500). + +## What The UI Actually Exposes Today + +The sidebar and Console Home are rendered from `GET /api/clouds/:cloud/services`, +so this table is derived from the service catalog and the adapter registry rather +than maintained by hand. Regenerate it after any change to either: + +```bash +cd packages/api && bun run scripts/service-matrix.ts +``` + +| Group | Service | AWS | Azure | GCP | +|---|---|---|---|---| +| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No | +| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) | +| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | +| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) | +| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | +| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | +| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | +| Networking | Networking | Yes (list) | No | No | +| Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No | +| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | +| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | +| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | +| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | +| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | +| Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No | +| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No | +| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No | +| Security | Identity | Yes (list, create, delete, inspect) | No | No | +| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | +| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No | +| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No | +| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No | + +Console Home is available for all three clouds. + +Runtime gaps — an adapter exists but the local runtime does not implement it: + +- Azure Serverless: the Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint. + +Services marked `No` render as a disabled sidebar row whose tooltip carries the +server-supplied reason. Adding one is a catalog row in +`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change. + +

+ Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons +

+ +Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled +`AKS` for this provider, and every unavailable service carries a reason — Serverless +reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions, +even though an adapter is registered. + +## Current Capability Snapshot + +
+Storage + +Cloud Explorer storage is the most complete unified category today. + +- AWS S3 buckets are normalized as `storage` resources with type `bucket`. +- Azure Blob containers are normalized as `storage` resources with type `container`. +- GCP Cloud Storage buckets are normalized as `storage` resources with type `bucket`. +- Shared resource table, shared inspector, runtime status strip, and schema-driven create/delete flows. +- Object/blob browser with prefix navigation. +- Upload, download, delete, copy, and create-folder-prefix actions. +- Azure folder markers are hidden and rendered as folders in the browser. +- Size and last-modified metadata are shown when returned by the runtime. + +Current gaps: + +- No bulk multi-select actions yet. +- No tag/policy/version management in the unified view. +- Folder creation is prefix-based, not a real filesystem directory. + +
+ +
+k8s Engine + +All three clouds, through the unified shell. + +- AWS EKS and Azure AKS clusters can be listed and inspected. +- A selected EKS cluster lists its managed nodegroups and Fargate profiles. +- Create and delete managed nodegroups, including role, subnets, instance types, and scaling configuration. +- Create and delete Fargate profiles, including pod execution role, selectors, labels, and optional subnets. +- These nested EKS operations use the unified Cloud Proxy, not the legacy `/api/eks/*` routes. +- GCP GKE clusters can additionally be created and deleted. +- Cluster metadata, node groups, and related details are surfaced when returned by the runtime. + +Current gaps: + +- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice: + the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s + with, so a created cluster never leaves `provisioningState: Failed`. + +
+ +
+Database + +Relational and document database workflows across providers: + +- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because the current local Floci RDS emulator does not support modifying them. +- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation. +- Azure Cosmos DB NoSQL: database, container, and document workflows. +- AWS DynamoDB: table management, item browsing, and Add record. +- Azure SQL and PostgreSQL Flexible Server: instance management and SQL query editor. +- GCP Cloud SQL: list, inspect, create, and delete database instances. + +Cosmos DB includes: + +- List, create, and delete databases. +- List, create, and delete containers. +- Create, edit, and delete documents/items. +- SQL query editor for documents. + +Choose **Explore data** beside a supported resource to open its dedicated workspace at +`/cloud-explorer/:cloud/:service/:resourceId/data`. DynamoDB records, Cosmos containers +and documents, and Azure SQL/PostgreSQL tables and query results use this workspace. +Cosmos container and SQL database/schema/table selections remain in the URL for bookmarks +and browser history. SQL credentials remain in memory and must be entered again after a reload. +**Back to** returns to the service's resource-management list. + +Frontend regression tests use mocked `/api/*` responses and need no running emulator: +`pnpm --filter @floci/frontend exec playwright install chromium`, then +`pnpm --filter @floci/frontend test:e2e`. + +Current gaps: + +- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list. +- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime. + +
+ +
+Compute + +AWS only, through the unified shell plus AWS-specific panels where the workflow is too rich for a flat generic form. + +- List EC2 instances and AMIs as normalized resources. +- Launch instances. +- Start, stop, reboot, and terminate instances. +- Create AMIs. +- Edit tags. +- View console output. + +Current gaps: + +- No Azure VM or GCP compute adapter yet. +- Compute creation still uses an AWS-specific panel because it needs dependent selectors. + +
+ +
+Networking + +AWS only, through the unified shell plus an AWS-specific networking panel. + +- VPC list and inspect through the unified resource table. +- VPC creation and delete, the VPC wizard, subnets, security groups, internet + gateways, NAT gateways, route tables, and Elastic IP workflows — all in the + Networking panel. + +Current gaps: + +- No Azure VNet or GCP VPC adapter yet. +- Create and delete are advertised as `partial` in the unified schema and are + handled by the Networking panel, because they need dependent selectors that a + flat generic form cannot express. +- Advanced multi-cloud networking normalization is still pending. + +
+ +
+Identity + +AWS only, through the generic identity service category. + +- List and inspect IAM users. +- Create and delete IAM users. +- IAM user paths are supported during creation. + +Current gaps: + +- Roles, groups, policies, access keys, and other advanced IAM workflows are not exposed yet. +- No Azure or GCP identity adapter yet. + +
+ +
+API Gateway + +AWS only, through the generic apigateway service category. + +- List and inspect REST APIs. +- Create and delete REST APIs. + +Current gaps: + +- Resources, methods, deployments, and stages are not yet exposed. +- No Azure or GCP API Gateway adapter yet. + +
+ +
+Email / SES Mailbox + +AWS SES email capture through the unified Cloud Explorer. + +- Lists emails actually captured by Floci SES. +- Filters by subject, sender, and recipient. +- Inspects sender, recipients, timestamp, and message type. +- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data. +- Clears the captured inbox after an explicit confirmation. + +Manual verification with the AWS CLI: + +```bash +export AWS_ACCESS_KEY_ID=test +export AWS_SECRET_ACCESS_KEY=test +export AWS_DEFAULT_REGION=us-east-1 + +aws ses send-email \ + --endpoint-url http://localhost:4566 \ + --from sender@example.test \ + --destination 'ToAddresses=recipient@example.test' \ + --message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="

Hello from Floci

This should render in the SES preview.

",Charset=utf-8}}' +``` + +The email is captured by the local Floci runtime; it is not delivered externally. Open +`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and +Raw views. You can also inspect the captured messages directly with: + +```bash +curl http://localhost:4566/_aws/ses +``` + +Current gaps: + +- Sending a test email from the UI is not wired yet; applications continue to send through their AWS SES SDK. +- SES identities, templates, bulk email, configuration sets, and suppression lists are not exposed yet. +- No Azure or GCP email adapter yet. + +
+ +
+Serverless + +AWS and GCP, both through the unified shell. + +- AWS Lambda and GCP Cloud Functions list, create, inspect, and delete. +- AWS Lambda invoke is wired, including the tailed execution log and handler errors. +- Lambda creation packages inline code into a real deployment archive. +- The navigation entry appears for any cloud with a registered adapter. + +Current gaps: + +- Azure Functions is registered but the Floci-AZ runtime answers 501 NotImplemented, + so it reports `coming_soon` with that reason rather than appearing available. +- GCP Cloud Functions invoke is not wired yet; the capability is advertised as + `coming_soon` instead of being silently missing. +- Old AWS Lambda page is gone; all future work should stay in the unified model. + +
+ +
+Containers + +GCP Cloud Run, through the unified shell. + +- List, inspect, deploy, and delete Cloud Run services. +- Image, container port, URL, traffic split, and generation are surfaced. +- Deploying really starts a container: the runtime launches the requested image. + +Readiness is reported honestly. A deploy settles at `PENDING` and then becomes +`SUCCEEDED` or `FAILED`; the runtime's own explanation is kept in +`metadata.terminalMessage`. The usual cause of `FAILED` is a container that does +not listen on the port given by `$PORT` (8080 by default) — `nginx:alpine` +listens on 80 and fails for exactly that reason, so the create form says so. + +Current gaps: + +- No AWS ECS or Azure Container Apps adapter yet. +- No revision history, traffic splitting, or scaling controls. +- Deleting a service while it is still `PENDING` can race the create; deleting a + settled service is durable. + +
+ +
+Secrets Manager + +This is the only dedicated AWS page still outside Cloud Explorer. + +- List secrets. +- Inspect metadata. +- Reveal current value on demand. +- Create secrets. +- Update values. +- Delete secrets, including force delete. + +Current gaps: + +- Not migrated into the Cloud Explorer contract yet. +- No Azure or GCP secret adapter yet. + +
+ +## Product Direction + +Floci UI is evolving toward a metadata-driven, cloud-aware console where one web app can render multiple local runtimes through the same shell. + +The guiding rules are: + +- The UI does not know clouds. +- The proxy does not know internal implementations. +- The SPI defines the contracts. +- The adapters perform the translation. +- The runtimes execute the real behavior. + +## Architecture + +![Floci Unified UI Multi-Cloud Architecture](docs/images/floci-unified-ui-architecture.png) + +Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md). + +## Project Structure + +```text +packages/ + api/ + src/ + cloud-spi/ + registry/ + adapter-aws/ + adapter-azure/ + adapter-gcp/ + routes/ + service/ + frontend/ + src/ + api/ + components/ + features/ + pages/ +``` + +High-level runtime flow: + +```text +Browser + -> frontend (React/Vite) + -> /api/clouds/* + -> Cloud Adapter Registry + -> provider adapter + -> local runtime +``` + +## Setup + +### Docker Compose + +Default compose stack: + +- `floci-ui` on `http://localhost:4500` +- `floci-api` on `http://localhost:4501` +- `floci` on `http://localhost:4566` + +Start AWS-only: + +```bash +docker compose up +``` + +Start AWS + Azure + GCP: + +```bash +docker compose --profile multicloud up +``` + +Convenience targets: + +```bash +make up +make up-multicloud +make down +make logs +``` + +### Manual Local Development + +Prerequisites: + +- Node.js 20+ +- pnpm 9+ +- Bun +- A running local runtime: Floci core, and optionally Floci-AZ / Floci-GCP + +Install dependencies: + +```bash +pnpm install +``` + +Configure the API environment: + +```bash +cp .env.example packages/api/.env +``` + +Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`. + +Start Floci AWS Core with Docker: + +```bash +docker run -d --name floci \ + -p 4566:4566 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e FLOCI_DEFAULT_REGION=us-east-1 \ + -u root \ + floci/floci:latest +``` + +Or from a local clone: + +```bash +git clone https://github.com/floci-io/floci.git ../floci +cd ../floci +./mvnw clean quarkus:dev +``` + +Optional local runtimes: + +- Floci-AZ on `http://localhost:4577` +- Floci-GCP on `http://localhost:4588` + +Start the UI stack: + +```bash +pnpm dev +``` + +That starts: + +- frontend on `http://localhost:4500` +- API on `http://localhost:4501` + +Split commands: + +```bash +pnpm dev:api +pnpm dev:web +``` + +## Environment + +Default API environment values: + +```bash +FLOCI_ENDPOINT=http://localhost:4566 +FLOCI_AZURE_ENDPOINT=http://localhost:4577 +FLOCI_AZURE_ACCOUNT_NAME=devstoreaccount1 +FLOCI_GCP_ENDPOINT=http://localhost:4588 +FLOCI_GCP_PROJECT=floci-local +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +PORT=4501 +``` + +`VITE_MOCK_MODE=false` is kept in `.env.example`, but the current app is intended to run against real local runtimes. + +## Verification + +```bash +pnpm lint +pnpm type-check +pnpm test +pnpm build +``` + +## Troubleshooting + +### `http proxy error` or `ECONNREFUSED` on `/api/*` + +The frontend is up, but the API is not reachable on `http://localhost:4501`. + +Check: + +```bash +pnpm dev:api +curl http://localhost:4501/api/clouds +``` + +### `EADDRINUSE` on port `4501` + +Another API process is already running. Stop it first or kill the process holding port `4501`. + +### Runtime shows `Not connected` or `Runtime unavailable` + +Check the runtime directly: + +```bash +curl http://localhost:4566/_floci/health +curl http://localhost:4577/_floci/health +curl http://localhost:4588/_floci-gcp/health +curl http://localhost:4501/api/clouds/aws/status +curl http://localhost:4501/api/clouds/azure/status +curl http://localhost:4501/api/clouds/gcp/status +``` + +### A single service shows as unavailable while the cloud is connected + +Cloud status reflects the runtime; each service is probed separately. Ask which +service is failing and why: + +```bash +curl http://localhost:4501/api/clouds/azure/status?services=all +curl http://localhost:4501/api/clouds/azure/services/serverless/status +``` + +`errorCode` distinguishes the cases: `operation_not_implemented` means the local +runtime does not implement that service, `runtime_unavailable` means it cannot be +reached, and `operation_not_supported` means no adapter is registered. + +### Credentials or endpoint mismatch + +For AWS local development, keep API credentials aligned with the runtime: + +```bash +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +``` + +## Contributing + +When adding new UI surface: + +- Prefer the Cloud Explorer and Cloud Proxy model over new legacy pages. +- Reuse the SPI contracts before creating provider-specific response shapes. +- Keep placeholders explicit instead of inventing fake data. +- Update this README when the visible UI surface changes. + +## Community Projects + +Floci UI is the first-party console, but it is not the only one. The community builds +consoles for Floci too: + +- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard + for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image + and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also + covers Azure and GCP, so pick whichever fits your stack. + +Building something for Floci? Open a PR to add it here. + +## License + +[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..36afce3 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1963 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "floci-ui", + }, + "packages/api": { + "name": "@floci/api", + "dependencies": { + "@aws-sdk/client-api-gateway": "^3.1076.0", + "@aws-sdk/client-appconfig": "^3.1076.0", + "@aws-sdk/client-cloudformation": "^3.1111.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1096.0", + "@aws-sdk/client-dynamodb": "^3.1088.0", + "@aws-sdk/client-ec2": "^3.1076.0", + "@aws-sdk/client-eks": "^3.1076.0", + "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", + "@aws-sdk/client-eventbridge": "^3.1076.0", + "@aws-sdk/client-iam": "^3.1076.0", + "@aws-sdk/client-kms": "^3.1096.0", + "@aws-sdk/client-lambda": "^3.1076.0", + "@aws-sdk/client-rds": "^3.1076.0", + "@aws-sdk/client-s3": "^3.1076.0", + "@aws-sdk/client-secrets-manager": "^3.1076.0", + "@aws-sdk/client-sfn": "^3.1096.0", + "@aws-sdk/client-sqs": "^3.1076.0", + "@aws-sdk/client-ssm": "^3.1096.0", + "@aws-sdk/util-dynamodb": "^3.996.7", + "dotenv": "^17.4.2", + "hono": "^4.12.27", + "mssql": "^12.7.0", + "pg": "^8.22.0", + }, + "devDependencies": { + "@types/bun": "^1.3.2", + "@types/mssql": "^12.3.0", + "@types/node": "^26.1.2", + "@types/pg": "^8.20.0", + "typescript": "^6.0.3", + }, + }, + "packages/frontend": { + "name": "@floci/frontend", + "version": "0.5.0", + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "date-fns": "^4.1.0", + "lucide-react": "^1.22.0", + "react": "^19.2.6", + "react-dom": "^19.2.0", + "react-router-dom": "^7.18.1", + "tailwind-merge": "^3.6.0", + "zustand": "^5.0.13", + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "^26.1.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.2", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.7.0", + "jsdom": "^30.0.1", + "postcss": "^8.5.16", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.3", + "vitest": "^4.1.10", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@6.0.7", "", { "dependencies": { "@csstools/css-calc": "^3.3.0", "@csstools/css-color-parser": "^4.1.10", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.5.2" } }, "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@8.3.2", "", { "dependencies": { "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2" } }, "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q=="], + + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.13", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg=="], + + "@aws-sdk/client-api-gateway": ["@aws-sdk/client-api-gateway@3.1096.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/credential-provider-node": "^3.972.73", "@aws-sdk/middleware-sdk-api-gateway": "^3.972.25", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/fetch-http-handler": "^5.6.10", "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g=="], + + "@aws-sdk/client-appconfig": ["@aws-sdk/client-appconfig@3.1131.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-zkssz1s2Dwfx8a/DTjGhU0f6TITrfLS8Xz07GfIW6ll8vScwMGlMV6PobbxN9Cb0iMk+q+CxWQmU1ePnL4E02Q=="], + + "@aws-sdk/client-cloudformation": ["@aws-sdk/client-cloudformation@3.1111.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ=="], + + "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.1131.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog=="], + + "@aws-sdk/client-dynamodb": ["@aws-sdk/client-dynamodb@3.1112.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/dynamodb-codec": "^3.973.43", "@aws-sdk/middleware-endpoint-discovery": "^3.972.29", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ=="], + + "@aws-sdk/client-ec2": ["@aws-sdk/client-ec2@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/middleware-sdk-ec2": "^3.972.42", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-sDob1bPeCAU80M85xJdKZkTaEvHTD6HwwxfnKsy6hKGReTDw9IGJJ7tzjsdvSzp2+MRT5Vf6GHgRchGblPC4yQ=="], + + "@aws-sdk/client-eks": ["@aws-sdk/client-eks@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-tN9O6f9vc9ns/uaPsN6vzEOrD7ftkWOMgzXoxY1J438WM2M8qcGbRJkhD2c+PvaZvZY3MVF7843mdIVrOA3zzQ=="], + + "@aws-sdk/client-elastic-load-balancing-v2": ["@aws-sdk/client-elastic-load-balancing-v2@3.1122.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.81", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7uMkQNqmVer3ST7cAOenfswJFxWM/KLe+bFFPQDfLO9HS8v7fDBdPKHVUMSFrBRsyN3A87K9nDQoYMChaBwHWQ=="], + + "@aws-sdk/client-eventbridge": ["@aws-sdk/client-eventbridge@3.1121.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.81", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-+zT6z91ZxHozsKGWf5wOJfxEebht3GmPBoXedlWoumHeAvzAueM1KwbWkzClLWjpi76HXuCIx7R3x4pvSVGqIw=="], + + "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.1120.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.81", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-w1UNruAHPJ2l6+fNsW3wBYa725ALR1FRQe+rLheeziNK3EmGK6wxTm39dgKyehPmeSXxEQYCg5gcrYq3Ew3CzQ=="], + + "@aws-sdk/client-kms": ["@aws-sdk/client-kms@3.1131.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-IQ5pG+aY5bM+1tyduaVa0m2AmPa/G9IZKbtAgtBP0pcM6wRkOlM3QyQEmi2UOoGd6L+ma738yOa6MmH6V3OLxw=="], + + "@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-rTitNyvjgBrJzjDkzLRcLi1QcxSomomLRjhYXJWID97uaw1uxtmer8wOVi8l6UFxzlRVa83laZ3srAeWRs509A=="], + + "@aws-sdk/client-rds": ["@aws-sdk/client-rds@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/middleware-sdk-rds": "^3.972.42", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-nkUu5ZN2TYTWslADZxMxNHMwl9NGdRGyWR+ZOPWL2MlDGbid3a2b7QAku6eaCoiDkWHonKbaHZYNtGoalkmyGA=="], + + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1080.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.13", "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/middleware-sdk-s3": "^3.972.59", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-erKuxbwhYLKs0ZviBeTDvXxC0E4AtugHXLbt5kFk8u9/rQMglh/QJNK5f9KuLjlMrbFSJgkBmjNSY5O03YoK4A=="], + + "@aws-sdk/client-secrets-manager": ["@aws-sdk/client-secrets-manager@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-lgrJFeDMdFz7ly0Jn7sTs2wNODJrvq8s1oiiCJFfCiRmMgS2IKk7b73zCC9y0ofKHtp+P+AbqnqUsMabXijmMw=="], + + "@aws-sdk/client-sfn": ["@aws-sdk/client-sfn@3.1123.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-C9O/NfDJCXCyXqMh4qrEQ03K4YLXlJOxvwrpcIB+6GJ9HsxRVjFhS73E1m9uO6d5A6Quk+efTDXfAbKmwVvKlw=="], + + "@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.1121.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.81", "@aws-sdk/middleware-sdk-sqs": "^3.972.42", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-T9NYR8Gcs30TUMbwf4QS2UyLMFheps3Kqgr//t1XfpIqgkpuBNkSiXa5LunlJL3MCTDT0bL01PkuhdHcXBwuVg=="], + + "@aws-sdk/client-ssm": ["@aws-sdk/client-ssm@3.1131.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-node": "^3.972.83", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-WT5keqh6qdK+g6aQ4R15xmPcy/u1LiIZseiFJAyiRB37V16akc56zYx+ZjcMXKFJRG1o9srXHuJNe+ZTYGhrmA=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.977.1", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.8", "@smithy/signature-v4": "^5.6.9", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BkDrk2cNjed31IKin/Oksb2ziF+gfuyRskFVuT4EU9Mep7M8Y/d8DJG4+anHme4Vuse7CwaEscwEfGyR6mzBhQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.64", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/fetch-http-handler": "^5.6.10", "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Wj1FGK2IxY5EccQCvH+niTYhIvDoDujJf2CpRRgS3NpYNEgiFNVItNbJYQjINRlu7fG7jSsXkKV0UWKriEplrw=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.7", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/credential-provider-env": "^3.972.62", "@aws-sdk/credential-provider-http": "^3.972.64", "@aws-sdk/credential-provider-login": "^3.972.69", "@aws-sdk/credential-provider-process": "^3.972.62", "@aws-sdk/credential-provider-sso": "^3.973.6", "@aws-sdk/credential-provider-web-identity": "^3.972.68", "@aws-sdk/nested-clients": "^3.997.36", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/credential-provider-imds": "^4.4.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/nested-clients": "^3.997.36", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-gM3j0Ie9+FoLNTYODY+QWbg3vCRBc7mR9cRdntxTMkFYIrwfRmuucfavP6HNBlYSuaYww54TNJGej4GFgoPZAg=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.73", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.62", "@aws-sdk/credential-provider-http": "^3.972.64", "@aws-sdk/credential-provider-ini": "^3.973.7", "@aws-sdk/credential-provider-process": "^3.972.62", "@aws-sdk/credential-provider-sso": "^3.973.6", "@aws-sdk/credential-provider-web-identity": "^3.972.68", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/credential-provider-imds": "^4.4.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-VTzdbf8Ukjdb9yUubZzRI678CWZvKovhE8Nv3qihwhC187sRMGls+r9N8Wuht5q1xjKx2nmpS48ar8ppupjkCA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zXYU9UWNL66gtMgNLhmxlrvEokuI7r6G2q7FRGu41Bya4iS30JLelUipJX9SV4zhyCPWJhI9Li54R1d9H8Tq6A=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.6", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/nested-clients": "^3.997.36", "@aws-sdk/token-providers": "3.1096.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.68", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/nested-clients": "^3.997.36", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bq+yTt+uWJx60VVp/OIAX5xqUAu/K2Uc3eknWnWl+KtfcU2CQe0uNw6lySrn2t5GKHq7jsV0Z63HiBGVtzr/lg=="], + + "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg=="], + + "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.11", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw=="], + + "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.29", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.11", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-cXW7QNIOhUSfccZmwJEjn9EdCAjzdOtt/+MtO5HWgxIZdhzyC1kNuQKIgGEDkgabxiXymWw0/VFWdqxqeLbgMA=="], + + "@aws-sdk/middleware-sdk-api-gateway": ["@aws-sdk/middleware-sdk-api-gateway@3.972.25", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-MPWjnRuwHGszFwHrwWluot1f6TU8BFI9Z+SGgWupAmNIEZUBGZTHTszzm59KYEh3xMnq78H8757AJaZV/y73IQ=="], + + "@aws-sdk/middleware-sdk-ec2": ["@aws-sdk/middleware-sdk-ec2@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-wer9jjaEwGMJlWnwnE3cgd/YAwC+kLbacW1OzAvtnM6lnoxsieQKRxapizLJDAVEjiRfHWPPmAjmF6xBcAz4Jw=="], + + "@aws-sdk/middleware-sdk-rds": ["@aws-sdk/middleware-sdk-rds@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA=="], + + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw=="], + + "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.42", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-D/O6iHAqlm0b430vIGewanSsr5RzaWbSDFlHYdKLWlZb4kaMKBmb44ReNHAFEKj5tNRY8pysw0qfciosB/VcJg=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.36", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/fetch-http-handler": "^5.6.10", "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-b71Suv7L+DnhM0MsQHU4WO42I32kxLZi96PbVhZbxMYIoKnEZz3v+LSrG8fupAoA4cBSshCk1Dl/PeRz49qUSg=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1096.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.1", "@aws-sdk/nested-clients": "^3.997.36", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hdUS2hDppy3vkWeFl5y86RLNU6OWH2mQB09yOSsRefwhhGTSFPkaZvfLDD/9vFcvMzlr8QFQFw3fw2FtrurVQA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], + + "@aws-sdk/util-dynamodb": ["@aws-sdk/util-dynamodb@3.996.9", "", { "dependencies": { "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1111.0" } }, "sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@azure-rest/core-client": ["@azure-rest/core-client@2.8.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.24.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw=="], + + "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="], + + "@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="], + + "@azure/core-client": ["@azure/core-client@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA=="], + + "@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="], + + "@azure/core-paging": ["@azure/core-paging@1.7.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="], + + "@azure/core-util": ["@azure/core-util@1.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw=="], + + "@azure/identity": ["@azure/identity@4.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw=="], + + "@azure/keyvault-common": ["@azure/keyvault-common@2.1.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.10.0", "@azure/logger": "^1.1.4", "tslib": "^2.2.0" } }, "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw=="], + + "@azure/keyvault-keys": ["@azure/keyvault-keys@4.10.2", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-lro": "^2.7.2", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/keyvault-common": "^2.1.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ=="], + + "@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="], + + "@azure/msal-browser": ["@azure/msal-browser@5.18.0", "", { "dependencies": { "@azure/msal-common": "16.12.0" } }, "sha512-SPTeHYZghdEdRddJzNjhH+CI5MSQtquNYwGJnYXfOHIBRXCmrWimBS85OhwXpXFIlrCtNTbBPm5mPAWRNEoktA=="], + + "@azure/msal-common": ["@azure/msal-common@16.12.0", "", {}, "sha512-hgLgfRdbG2AmhXPygebf1KYJEvse86+ZZLWufdiTKaGRYEUqOzHdlf6AS1IiuUCHWbynkgbHc451jSNkbfhWlg=="], + + "@azure/msal-node": ["@azure/msal-node@5.5.0", "", { "dependencies": { "@azure/msal-common": "16.12.0", "jsonwebtoken": "^9.0.0" } }, "sha512-A/2WIsuH0vsC6JVkkafjS4kHpi2LDR4AzDT0kJ+oIRtXYeYtvGQ2pwN2X88thQPhSek+82ela3MprsKXWQRrhQ=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.4.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.3", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.4.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.14", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + + "@floci/api": ["@floci/api@workspace:packages/api"], + + "@floci/frontend": ["@floci/frontend@workspace:packages/frontend"], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-joda/core": ["@js-joda/core@6.1.0", "", {}, "sha512-H8NTMRDJqad/leyv/D/A3kSOsf5/58Ydj4DJGDyaCWk9OU/zuZOLhndVffJgQjsgrn5GC0znHMHie7TfvPPG4w=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@smithy/core": ["@smithy/core@3.31.0", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.15", "", { "dependencies": { "@smithy/core": "^3.31.0", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.0", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.12", "", { "dependencies": { "@smithy/core": "^3.31.0", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.6.11", "", { "dependencies": { "@smithy/core": "^3.31.0", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw=="], + + "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.2", "", {}, "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.2", "", { "dependencies": { "@tanstack/query-devtools": "5.101.2" }, "peerDependencies": { "@tanstack/react-query": "^5.101.2", "react": "^18 || ^19" } }, "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ=="], + + "@tediousjs/connection-string": ["@tediousjs/connection-string@1.1.0", "", {}, "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mssql": ["@types/mssql@12.3.0", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-+jy+AJtfuTDI5+nhh0hDNcir1p8P+pf+qsHXpUpYvg7EikxUUePBe+a+Kr6j/Xs89o4EbHlVzrh0HOxbqWM31Q=="], + + "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/readable-stream": ["@types/readable-stream@4.0.24", "", { "dependencies": { "@types/node": "*" } }, "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.63.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/type-utils": "8.63.0", "@typescript-eslint/utils": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.63.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.8", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "autoprefixer": ["autoprefixer@10.5.2", "", { "dependencies": { "browserslist": "^4.28.4", "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], + + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + + "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001802", "", {}, "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.387", "", {}, "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ=="], + + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "hono": ["hono@4.12.28", "", {}, "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsdom": ["jsdom@30.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.7", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mssql": ["mssql@12.7.0", "", { "dependencies": { "@tediousjs/connection-string": "^1.0.0", "commander": "^11.0.0", "debug": "^4.3.3", "tarn": "^3.0.2", "tedious": "^19.2.2 || ^20.0.0" }, "bin": { "mssql": "bin/mssql" } }, "sha512-J6SJKXi1jYbhHjjooLNtPnX7+s3cq5IJ701Wgy/UW1SXRpgFlJJsYi3IPve9RVgCUkq0Cqv2aaaSJ4IXtIF3mg=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + + "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-router": ["react-router@7.18.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg=="], + + "react-router-dom": ["react-router-dom@7.18.1", "", { "dependencies": { "react-router": "7.18.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + + "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], + + "tarn": ["tarn@3.1.2", "", {}, "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg=="], + + "tedious": ["tedious@20.0.0", "", { "dependencies": { "@azure/core-auth": "^1.10.1", "@azure/identity": "^4.13.1", "@azure/keyvault-keys": "^4.10.2", "@js-joda/core": "^6.0.1", "@types/node": ">=22", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-bTR0aou0Ghucf0ytvZUJjnKHGKDV8tT57jPYtEkSpfTWFe++4uR1wxJLQ4mh5wlSvAYXzmgBxbI0vaE56qigXw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "tldts": ["tldts@7.4.13", "", { "dependencies": { "tldts-core": "^7.4.13" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA=="], + + "tldts-core": ["tldts-core@7.4.13", "", {}, "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.63.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.63.0", "@typescript-eslint/parser": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ=="], + + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@17.1.1", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + + "@aws-sdk/checksums/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/checksums/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/checksums/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/checksums/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-appconfig/@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/client-appconfig/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-appconfig/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-appconfig/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-appconfig/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-appconfig/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/core": ["@aws-sdk/core@3.977.8", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.80", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.69", "@aws-sdk/credential-provider-http": "^3.972.71", "@aws-sdk/credential-provider-ini": "^3.973.14", "@aws-sdk/credential-provider-process": "^3.972.69", "@aws-sdk/credential-provider-sso": "^3.973.13", "@aws-sdk/credential-provider-web-identity": "^3.972.75", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@aws-sdk/client-cloudformation/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-cloudformation/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-Gw8I2wsBNyEB+FtXsMVOCa6FiZofuyxn8Zlo0XFdIZ8PnPskiN2kssPn1U/mGyBSmlh7Q9Ul3qwgGSB/dExrNg=="], + + "@aws-sdk/client-cloudformation/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-n65cxwrcluF4IziYVD+etGq8L03E6Nrx4WNSvhP2rRjooRNOsy2n2qD0RmSNUyI0n5kpLcILxEIba3epQjkAeQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-cloudwatch-logs/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-cloudwatch-logs/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-cloudwatch-logs/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-cloudwatch-logs/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/core": ["@aws-sdk/core@3.977.8", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.80", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.69", "@aws-sdk/credential-provider-http": "^3.972.71", "@aws-sdk/credential-provider-ini": "^3.973.14", "@aws-sdk/credential-provider-process": "^3.972.69", "@aws-sdk/credential-provider-sso": "^3.973.13", "@aws-sdk/credential-provider-web-identity": "^3.972.75", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@aws-sdk/client-dynamodb/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-dynamodb/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-Gw8I2wsBNyEB+FtXsMVOCa6FiZofuyxn8Zlo0XFdIZ8PnPskiN2kssPn1U/mGyBSmlh7Q9Ul3qwgGSB/dExrNg=="], + + "@aws-sdk/client-dynamodb/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-n65cxwrcluF4IziYVD+etGq8L03E6Nrx4WNSvhP2rRjooRNOsy2n2qD0RmSNUyI0n5kpLcILxEIba3epQjkAeQ=="], + + "@aws-sdk/client-dynamodb/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-ec2/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.63", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-ini": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw=="], + + "@aws-sdk/client-ec2/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/client-ec2/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/client-ec2/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@aws-sdk/client-ec2/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@aws-sdk/client-ec2/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-eks/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.63", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-ini": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw=="], + + "@aws-sdk/client-eks/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/client-eks/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/client-eks/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@aws-sdk/client-eks/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@aws-sdk/client-eks/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.81", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.81", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-eventbridge/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-eventbridge/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-eventbridge/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-eventbridge/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-iam/@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.81", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw=="], + + "@aws-sdk/client-iam/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-iam/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-iam/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-iam/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-iam/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-kms/@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/client-kms/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-kms/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-kms/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-kms/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-kms/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-lambda/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.63", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-ini": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw=="], + + "@aws-sdk/client-lambda/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/client-lambda/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/client-lambda/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@aws-sdk/client-lambda/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@aws-sdk/client-lambda/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-rds/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.63", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-ini": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw=="], + + "@aws-sdk/client-rds/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/client-rds/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/client-rds/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@aws-sdk/client-rds/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@aws-sdk/client-rds/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.63", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-ini": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw=="], + + "@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.63", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-ini": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/client-secrets-manager/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/client-secrets-manager/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="], + + "@aws-sdk/client-secrets-manager/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], + + "@aws-sdk/client-secrets-manager/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/client-sfn/@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.82", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA=="], + + "@aws-sdk/client-sfn/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-sfn/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-sfn/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-sfn/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-sfn/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-sqs/@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.81", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw=="], + + "@aws-sdk/client-sqs/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-sqs/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-sqs/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-sqs/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-sqs/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/client-ssm/@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/client-ssm/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/client-ssm/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/client-ssm/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@aws-sdk/client-ssm/@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@aws-sdk/client-ssm/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core": ["@aws-sdk/core@3.977.8", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg=="], + + "@aws-sdk/dynamodb-codec/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/dynamodb-codec/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/middleware-endpoint-discovery/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@aws-sdk/middleware-endpoint-discovery/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/middleware-endpoint-discovery/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/middleware-sdk-ec2/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/middleware-sdk-ec2/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/middleware-sdk-ec2/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/middleware-sdk-ec2/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/middleware-sdk-ec2/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/middleware-sdk-rds/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/middleware-sdk-rds/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/middleware-sdk-rds/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/middleware-sdk-rds/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/middleware-sdk-rds/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="], + + "@aws-sdk/middleware-sdk-s3/@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="], + + "@aws-sdk/middleware-sdk-s3/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@aws-sdk/middleware-sdk-sqs/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/middleware-sdk-sqs/@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@aws-sdk/middleware-sdk-sqs/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.42", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q=="], + + "@aws-sdk/signature-v4-multi-region/@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/signature-v4-multi-region/@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@babel/helper-compilation-targets/browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@types/mssql/@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], + + "@types/pg/@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], + + "@types/readable-stream/@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "bun-types/@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], + + "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "jsonwebtoken/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "tedious/@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], + + "@aws-sdk/checksums/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/checksums/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-appconfig/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.39", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-env": "^3.972.69", "@aws-sdk/credential-provider-http": "^3.972.71", "@aws-sdk/credential-provider-login": "^3.972.76", "@aws-sdk/credential-provider-process": "^3.972.69", "@aws-sdk/credential-provider-sso": "^3.973.13", "@aws-sdk/credential-provider-web-identity": "^3.972.75", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.13", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/token-providers": "3.1111.0", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/types/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@smithy/core/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@smithy/fetch-http-handler/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@smithy/node-http-handler/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.39", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/core/@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-env": "^3.972.69", "@aws-sdk/credential-provider-http": "^3.972.71", "@aws-sdk/credential-provider-login": "^3.972.76", "@aws-sdk/credential-provider-process": "^3.972.69", "@aws-sdk/credential-provider-sso": "^3.973.13", "@aws-sdk/credential-provider-web-identity": "^3.972.75", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.13", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/token-providers": "3.1111.0", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-ec2/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/client-ec2/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-login": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/token-providers": "3.1080.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@aws-sdk/client-eks/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/client-eks/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-login": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/token-providers": "3.1080.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-iam/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-iam/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-kms/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-kms/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-lambda/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/client-lambda/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-login": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/token-providers": "3.1080.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@aws-sdk/client-rds/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/client-rds/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-login": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/token-providers": "3.1080.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-login": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/token-providers": "3.1080.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-env": "^3.972.54", "@aws-sdk/credential-provider-http": "^3.972.56", "@aws-sdk/credential-provider-login": "^3.972.60", "@aws-sdk/credential-provider-process": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.60", "@aws-sdk/credential-provider-web-identity": "^3.972.60", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/token-providers": "3.1080.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="], + + "@aws-sdk/client-sfn/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-sfn/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-sqs/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-sqs/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/client-ssm/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws-sdk/client-ssm/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-8UYAvCeKsO8bpvjpwjcDkXscAkSWItbUHkric1PRJtIucpUTNK+va0uQIQHO1Jt2I1jhFQdlGrRTxWymncVsXA=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.39", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + + "@aws-sdk/middleware-sdk-ec2/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/middleware-sdk-rds/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/middleware-sdk-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@babel/helper-compilation-targets/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="], + + "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], + + "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.351", "", {}, "sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA=="], + + "@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + + "@types/mssql/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@types/pg/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@types/readable-stream/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "tedious/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@aws-sdk/client-appconfig/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-appconfig/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/core/@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1111.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-cloudwatch-logs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/core/@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1111.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-elastic-load-balancing-v2/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-eventbridge/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-iam/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-iam/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-iam/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-kms/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-kms/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-kms/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], + + "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], + + "@aws-sdk/client-sfn/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-sfn/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-sfn/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-sqs/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-sqs/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-ssm/@aws-sdk/core/@smithy/signature-v4/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-ssm/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/core": ["@smithy/core@3.33.1", "", { "dependencies": { "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-h5BsgsPCJhhFLPYaoFhJYitOMZ49UCT4drE4BcvUl/xTHAQzjBK2utAfyFkHU1tG2geYXut8b9inlEDRZZfnwA=="], + + "@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@aws-sdk/types/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@aws-sdk/xml-builder/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/dynamodb-codec/@aws-sdk/core/@smithy/signature-v4/@smithy/types": ["@smithy/types@4.17.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1pGxMPydRIZ8zeDUndh5iyX1DyYfdrCn96dpAV+JxQgzYwHmn4TCgyjYlFndvPA8YbPrG0K8820FuRPuV6H4w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-cloudformation/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-dynamodb/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.7.1", "", { "dependencies": { "@smithy/core": "^3.33.1", "@smithy/types": "^4.17.1", "tslib": "^2.6.2" } }, "sha512-F1e1qACqF/CODey2t/A+/8akr5O80TUcEkILoXAunEok7Tj5lN7LBvPOQjtGon44a1Y6V+1L+q21ycbaJCfw6w=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-ec2/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-eks/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-rds/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + + "@aws-sdk/client-secrets-manager/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3a0cde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,106 @@ +services: + floci-ui: + build: + context: ./packages/frontend + dockerfile: Dockerfile.dev + volumes: + - ./packages/frontend/src:/app/src + - ./packages/frontend/index.html:/app/index.html + - ./packages/frontend/vite.config.ts:/app/vite.config.ts + - ./packages/frontend/tsconfig.json:/app/tsconfig.json + - ./packages/frontend/tsconfig.node.json:/app/tsconfig.node.json + ports: + - "4500:4500" + environment: + API_TARGET: http://floci-api:4501 + VITE_USE_POLLING: "true" + depends_on: + - floci-api + networks: + - floci_default + + floci-api: + build: + context: ./packages/api + dockerfile: Dockerfile.dev + volumes: + - ./packages/api/src:/app/src + ports: + - "4501:4501" + environment: + FLOCI_ENDPOINT: http://floci:4566 + FLOCI_AZURE_ENDPOINT: http://floci-az:4577 + AWS_REGION: us-east-1 + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + FLOCI_GCP_ENDPOINT: http://floci-gcp:4588 + FLOCI_GCP_PROJECT: floci-local + PORT: "4501" + depends_on: + - floci + networks: + - floci_default + + floci: + image: floci/floci:latest-compat + ports: + - "4566:4566" + volumes: + # Floci runs Lambda functions in throwaway containers, so it needs the host + # Docker daemon. Without this, invoke fails with "Failed to start Lambda + # container". This grants the emulator control of host Docker — acceptable + # for a local dev stack, and the same mount the README's docker run uses. + - /var/run/docker.sock:/var/run/docker.sock + - ./data:/app/data + - ./init/ready.d:/etc/floci/init/ready.d:ro + - ./init/files:/etc/floci/init/files:ro + environment: + FLOCI_STORAGE_MODE: persistent + networks: + floci_default: + aliases: + - localhost.floci.io + + # Multi-cloud emulators — enabled with the "multicloud" profile: + # docker compose --profile multicloud up + floci-az: + image: floci/floci-az + profiles: ["multicloud"] + ports: + - "4577:4577" + networks: + - floci_default + + floci-gcp: + image: floci/floci-gcp + profiles: ["multicloud"] + ports: + - "4588:4588" + volumes: + # Cloud SQL instances are real Postgres containers; see the note on `floci`. + - /var/run/docker.sock:/var/run/docker.sock + networks: + - floci_default + + floci-seed: + build: + context: ./init + dockerfile: Dockerfile.seed + profiles: ["multicloud"] + command: ["/bin/sh", "/etc/floci/init/seed-clouds.sh"] + volumes: + - ./init:/etc/floci/init:ro + environment: + FLOCI_AZURE_ENDPOINT: http://floci-az:4577 + FLOCI_AZURE_ACCOUNT_NAME: devstoreaccount1 + FLOCI_GCP_ENDPOINT: http://floci-gcp:4588 + FLOCI_GCP_PROJECT: floci-local + depends_on: + - floci-az + - floci-gcp + networks: + - floci_default + +networks: + floci_default: + name: floci_default \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..162a75b --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,25 @@ +# Stage 1 — build frontend +FROM node:20-alpine AS frontend-build +WORKDIR /app +COPY packages/frontend/package.json ./ +RUN npm install +COPY packages/frontend/ . +RUN npm run build + +# Stage 2 — compile API to a self-contained binary +FROM oven/bun:1-alpine AS api-build +WORKDIR /app +COPY packages/api/package.json packages/api/bun.lock* ./ +RUN bun install +COPY packages/api/src ./src +RUN bun build --compile --minify src/index.ts --outfile server + +# Stage 3 — minimal runtime (no bun, no node_modules) +FROM alpine:3 +RUN apk add --no-cache ca-certificates libstdc++ +WORKDIR /app +COPY --from=api-build /app/server ./server +COPY --from=frontend-build /app/dist ./public +ENV PORT=4500 +EXPOSE 4500 +CMD ["./server"] diff --git a/docker/Dockerfile.package b/docker/Dockerfile.package new file mode 100644 index 0000000..12d1e78 --- /dev/null +++ b/docker/Dockerfile.package @@ -0,0 +1,23 @@ +# Single-stage runtime image. Nothing is compiled here — the frontend bundle +# and the API binary are built in CI (see .github/workflows/release.yml) and +# copied in. Mirrors the floci-io/floci "native-package" approach. +# +# Expected build context layout: +# public/ frontend production bundle (arch-independent) +# native/${TARGETARCH}/server bun-compiled API binary, one per arch +FROM alpine:3 + +# Automatically set by Docker BuildKit to "amd64" or "arm64". +ARG TARGETARCH + +RUN apk add --no-cache ca-certificates libstdc++ + +WORKDIR /app + +COPY --chmod=755 native/${TARGETARCH}/server ./server +COPY public/ ./public/ + +ENV PORT=4500 +EXPOSE 4500 + +CMD ["./server"] diff --git a/docs/images/floci-black.svg b/docs/images/floci-black.svg new file mode 100644 index 0000000..00b231f --- /dev/null +++ b/docs/images/floci-black.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/images/floci-ui-console-azure.png b/docs/images/floci-ui-console-azure.png new file mode 100644 index 0000000..9bd2d00 Binary files /dev/null and b/docs/images/floci-ui-console-azure.png differ diff --git a/docs/images/floci-ui-console.png b/docs/images/floci-ui-console.png new file mode 100644 index 0000000..0810e44 Binary files /dev/null and b/docs/images/floci-ui-console.png differ diff --git a/docs/images/floci-unified-ui-architecture.png b/docs/images/floci-unified-ui-architecture.png new file mode 100644 index 0000000..2fd0586 Binary files /dev/null and b/docs/images/floci-unified-ui-architecture.png differ diff --git a/docs/images/floci-white.svg b/docs/images/floci-white.svg new file mode 100644 index 0000000..3a62781 --- /dev/null +++ b/docs/images/floci-white.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md new file mode 100644 index 0000000..78efaef --- /dev/null +++ b/docs/implementation-notes.md @@ -0,0 +1,58 @@ +# Implementation Notes + +This document tracks the current architectural direction behind the new Floci UI shell. + +## What Changed + +- Introduced the Cloud Proxy API under `/api/clouds/*`. +- Added shared SPI contracts in `packages/api/src/cloud-spi`. +- Added the `CloudAdapterRegistry` to resolve cloud + service pairs. +- Moved the main UX toward `Console Home` and `Cloud Explorer`. +- Kept `Secrets Manager` as a dedicated AWS page during the transition. + +## Adapters Currently Registered + +- AWS Storage +- AWS k8s +- AWS Database +- AWS DynamoDB +- AWS Compute +- AWS Networking +- AWS Serverless +- AWS EventBridge and EventBridge Scheduler +- Azure Storage +- Azure Database +- Azure Serverless +- GCP Storage + +## Current UI Surface + +The frontend currently exposes: + +- `Console Home` +- `Cloud Explorer / storage` +- `Cloud Explorer / k8s` +- `Cloud Explorer / database` +- `Cloud Explorer / nosql` +- `Cloud Explorer / compute` +- `Cloud Explorer / networking` +- `Cloud Explorer / serverless` +- `Cloud Explorer / messaging` +- `/secretsmanager` + +Not every registered adapter is already promoted into the visible sidebar for every provider. The README reflects the user-visible surface, not only what is registered in the backend. + +## Active Transitional State + +The codebase is in a hybrid stage: + +- Unified shell and metadata-driven proxy are the default direction. +- Some AWS workflows still depend on provider-specific panels inside the new shell. +- `Secrets Manager` remains outside Cloud Explorer for now. +- Old AWS legacy pages were intentionally removed instead of being carried forward. + +## Next Cleanup Targets + +- Move remaining dedicated AWS workflows into Cloud Explorer where practical. +- Keep provider-neutral contracts ahead of new provider-specific UI. +- Continue reducing README drift whenever the visible navigation changes. diff --git a/init/Dockerfile.seed b/init/Dockerfile.seed new file mode 100644 index 0000000..8fe2e09 --- /dev/null +++ b/init/Dockerfile.seed @@ -0,0 +1,3 @@ +FROM alpine:3.22 + +RUN apk add --no-cache curl diff --git a/init/files/config.json b/init/files/config.json new file mode 100644 index 0000000..29f9f3f --- /dev/null +++ b/init/files/config.json @@ -0,0 +1,9 @@ +{ + "app": "floci-demo", + "version": "1.0.0", + "environment": "local", + "features": { + "notifications": true, + "logging": true + } +} diff --git a/init/files/index.html b/init/files/index.html new file mode 100644 index 0000000..78c8c92 --- /dev/null +++ b/init/files/index.html @@ -0,0 +1,10 @@ + + + + + Floci Static Assets + + +

Hello from Floci S3

+ + diff --git a/init/ready.d/01-setup.sh b/init/ready.d/01-setup.sh new file mode 100755 index 0000000..be63f9a --- /dev/null +++ b/init/ready.d/01-setup.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +export AWS_ACCESS_KEY_ID=test +export AWS_SECRET_ACCESS_KEY=test +export AWS_DEFAULT_REGION=us-east-1 +export AWS_ENDPOINT_URL=http://localhost:4566 + +echo "=== Creating S3 buckets ===" +aws s3 mb s3://my-app-bucket || true +aws s3 mb s3://logs-bucket || true +aws s3 mb s3://static-assets || true + +echo "=== Uploading files ===" +aws s3 cp /etc/floci/init/files/config.json s3://my-app-bucket/config.json +aws s3 cp /etc/floci/init/files/index.html s3://static-assets/index.html + +echo "=== Creating SQS queues ===" +aws --endpoint-url http://localhost:4566 --region us-east-1 sqs create-queue --queue-name orders-queue || true +aws --endpoint-url http://localhost:4566 --region us-east-1 sqs create-queue --queue-name notifications-queue || true +aws --endpoint-url http://localhost:4566 --region us-east-1 sqs create-queue --queue-name dead-letter-queue || true + +echo "=== Done ===" diff --git a/init/seed-clouds.sh b/init/seed-clouds.sh new file mode 100644 index 0000000..ebf5bfb --- /dev/null +++ b/init/seed-clouds.sh @@ -0,0 +1,118 @@ +#!/bin/sh +set -eu + +AZURE_ENDPOINT="${FLOCI_AZURE_ENDPOINT:-http://floci-az:4577}" +AZURE_ACCOUNT_NAME="${FLOCI_AZURE_ACCOUNT_NAME:-devstoreaccount1}" +GCP_ENDPOINT="${FLOCI_GCP_ENDPOINT:-http://floci-gcp:4588}" +GCP_PROJECT="${FLOCI_GCP_PROJECT:-floci-local}" +FILES_DIR="${FLOCI_SEED_FILES_DIR:-/etc/floci/init/files}" + +wait_for() { + name="$1" + url="$2" + attempts=30 + while [ "$attempts" -gt 0 ]; do + if curl -sS -o /dev/null "$url"; then + echo "=== $name reachable ===" + return 0 + fi + attempts=$((attempts - 1)) + sleep 1 + done + echo "ERROR: $name is not reachable at $url" >&2 + return 1 +} + +accept_status() { + status="$1" + case "$status" in + 2*|409) return 0 ;; + *) return 1 ;; + esac +} + +create_azure_container() { + container="$1" + status="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \ + "$AZURE_ENDPOINT/$AZURE_ACCOUNT_NAME/$container?restype=container" \ + -H 'x-ms-version: 2021-12-02')" + if accept_status "$status"; then + echo "Azure container ready: $container" + else + echo "ERROR: failed to create Azure container $container (HTTP $status)" >&2 + return 1 + fi +} + +put_azure_blob() { + container="$1" + key="$2" + file="$3" + content_type="$4" + status="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \ + "$AZURE_ENDPOINT/$AZURE_ACCOUNT_NAME/$container/$key" \ + -H 'x-ms-version: 2021-12-02' \ + -H 'x-ms-blob-type: BlockBlob' \ + -H "Content-Type: $content_type" \ + --data-binary "@$file")" + if accept_status "$status"; then + echo "Azure blob uploaded: $container/$key" + else + echo "ERROR: failed to upload Azure blob $container/$key (HTTP $status)" >&2 + return 1 + fi +} + +create_gcp_bucket() { + bucket="$1" + status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + "$GCP_ENDPOINT/storage/v1/b?project=$GCP_PROJECT" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"$bucket\"}")" + if accept_status "$status"; then + echo "GCP bucket ready: $bucket" + else + echo "ERROR: failed to create GCP bucket $bucket (HTTP $status)" >&2 + return 1 + fi +} + +put_gcp_object() { + bucket="$1" + key="$2" + file="$3" + content_type="$4" + status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ + "$GCP_ENDPOINT/upload/storage/v1/b/$bucket/o?uploadType=media&name=$key" \ + -H "Content-Type: $content_type" \ + --data-binary "@$file")" + if accept_status "$status"; then + echo "GCP object uploaded: $bucket/$key" + else + echo "ERROR: failed to upload GCP object $bucket/$key (HTTP $status)" >&2 + return 1 + fi +} + +wait_for "Floci-AZ" "$AZURE_ENDPOINT/$AZURE_ACCOUNT_NAME?comp=list" +wait_for "Floci-GCP" "$GCP_ENDPOINT/storage/v1/b?project=$GCP_PROJECT" + +echo "=== Creating Azure containers ===" +create_azure_container azure-app-container +create_azure_container azure-logs-container +create_azure_container azure-static-assets + +echo "=== Uploading Azure blobs ===" +put_azure_blob azure-app-container config.json "$FILES_DIR/config.json" application/json +put_azure_blob azure-static-assets index.html "$FILES_DIR/index.html" text/html + +echo "=== Creating GCP buckets ===" +create_gcp_bucket gcp-app-bucket +create_gcp_bucket gcp-logs-bucket +create_gcp_bucket gcp-static-assets + +echo "=== Uploading GCP objects ===" +put_gcp_object gcp-app-bucket config.json "$FILES_DIR/config.json" application/json +put_gcp_object gcp-static-assets index.html "$FILES_DIR/index.html" text/html + +echo "=== Multi-cloud seed done ===" diff --git a/package.json b/package.json new file mode 100644 index 0000000..06368c5 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "floci-ui", + "version": "0.5.0", + "description": "Any Cloud. Locally. — the local cloud console for Floci, an AWS-Console-style UI for your multi-cloud runtime.", + "private": true, + "packageManager": "pnpm@9.2.0", + "workspaces": [ + "packages/*" + ], + "scripts": { + "dev": "sh -c 'PORT=4501 pnpm dev:api & pnpm dev:web'", + "dev:web": "pnpm --filter @floci/frontend dev", + "build": "pnpm --filter @floci/frontend build", + "lint": "pnpm --filter @floci/frontend lint", + "type-check": "pnpm --filter @floci/api type-check && pnpm --filter @floci/frontend type-check", + "test": "pnpm --filter @floci/api test", + "dev:api": "sh -c 'PORT=${PORT:-4501} pnpm --filter @floci/api dev'", + "start:api": "sh -c 'PORT=${PORT:-4501} pnpm --filter @floci/api start'" + } +} diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile new file mode 100644 index 0000000..ab7432f --- /dev/null +++ b/packages/api/Dockerfile @@ -0,0 +1,12 @@ +FROM oven/bun:1 AS build +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile +COPY src ./src + +FROM oven/bun:1-slim +WORKDIR /app +COPY --from=build /app /app +ENV PORT=4501 +EXPOSE 4501 +CMD ["bun", "run", "src/index.ts"] diff --git a/packages/api/Dockerfile.dev b/packages/api/Dockerfile.dev new file mode 100644 index 0000000..8a98d86 --- /dev/null +++ b/packages/api/Dockerfile.dev @@ -0,0 +1,5 @@ +FROM oven/bun:1 +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install +CMD ["bun", "run", "--watch", "src/index.ts"] diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..f20e511 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,44 @@ +{ + "name": "@floci/api", + "description": "Floci UI backend — translates the console's REST/JSON requests into cloud SDK calls against Floci runtimes.", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "type-check": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@aws-sdk/client-api-gateway": "^3.1076.0", + "@aws-sdk/client-appconfig": "^3.1076.0", + "@aws-sdk/client-cloudformation": "^3.1111.0", + "@aws-sdk/client-cloudwatch-logs": "^3.1096.0", + "@aws-sdk/client-ec2": "^3.1076.0", + "@aws-sdk/client-dynamodb": "^3.1088.0", + "@aws-sdk/client-eks": "^3.1076.0", + "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", + "@aws-sdk/client-eventbridge": "^3.1076.0", + "@aws-sdk/client-iam": "^3.1076.0", + "@aws-sdk/client-kms": "^3.1096.0", + "@aws-sdk/client-lambda": "^3.1076.0", + "@aws-sdk/client-rds": "^3.1076.0", + "@aws-sdk/client-s3": "^3.1076.0", + "@aws-sdk/client-secrets-manager": "^3.1076.0", + "@aws-sdk/client-sfn": "^3.1096.0", + "@aws-sdk/client-sqs": "^3.1076.0", + "@aws-sdk/client-ssm": "^3.1096.0", + "@aws-sdk/util-dynamodb": "^3.996.7", + "dotenv": "^17.4.2", + "hono": "^4.12.27", + "mssql": "^12.7.0", + "pg": "^8.22.0" + }, + "devDependencies": { + "@types/bun": "^1.3.2", + "@types/mssql": "^12.3.0", + "@types/node": "^26.1.2", + "@types/pg": "^8.20.0", + "typescript": "^6.0.3" + } +} diff --git a/packages/api/scripts/service-matrix.ts b/packages/api/scripts/service-matrix.ts new file mode 100644 index 0000000..992e407 --- /dev/null +++ b/packages/api/scripts/service-matrix.ts @@ -0,0 +1,58 @@ +/** + * Print the service-coverage matrix as markdown, derived from the service + * catalog and the adapter registry. + * + * The README table used to be hand-maintained and drifted from the code. Paste + * this output into the README whenever navigation changes: + * + * bun run scripts/service-matrix.ts + * + * Reads the registry only — no runtime calls — so it works offline. + */ + +import {createCloudAdapterRegistry} from '../src/cloudProxy' +import {SERVICE_CATALOG_ENTRIES, displayNameFor} from '../src/cloud-spi/serviceCatalog' +import type {CloudProvider} from '../src/cloud-spi/types' + +const CLOUDS: CloudProvider[] = ['aws', 'azure', 'gcp'] +const CLOUD_LABELS: Record = {aws: 'AWS', azure: 'Azure', gcp: 'GCP'} + +const registry = createCloudAdapterRegistry() + +function cell(cloud: CloudProvider, service: string): string { + const entry = SERVICE_CATALOG_ENTRIES.find((candidate) => candidate.service === service) + const legacy = entry?.legacyAvailability?.[cloud] + if (legacy === 'available') return 'Yes (legacy page)' + + const adapter = registry.get(cloud, service as never) + if (!adapter) return 'No' + + const override = adapter.descriptorOverride?.() + if (override?.availability === 'coming_soon') return 'Runtime gap' + + const actions = adapter.schema().actions + return `Yes (${actions.join(', ')})` +} + +const rows = SERVICE_CATALOG_ENTRIES.map((entry) => { + const names = new Set(CLOUDS.map((cloud) => displayNameFor(entry, cloud))) + // Show per-cloud names inline when they differ, e.g. EKS / AKS / GKE. + const label = names.size === 1 ? entry.displayName : [...names].join(' / ') + return `| ${entry.group} | ${label} | ${CLOUDS.map((cloud) => cell(cloud, entry.service)).join(' | ')} |` +}) + +console.log(`| Group | Service | ${CLOUDS.map((cloud) => CLOUD_LABELS[cloud]).join(' | ')} |`) +console.log(`|---|---|${CLOUDS.map(() => '---').join('|')}|`) +console.log(rows.join('\n')) + +const runtimeGaps = CLOUDS.flatMap((cloud) => + SERVICE_CATALOG_ENTRIES.flatMap((entry) => { + const override = registry.get(cloud, entry.service as never)?.descriptorOverride?.() + return override?.reason ? [`- ${CLOUD_LABELS[cloud]} ${displayNameFor(entry, cloud)}: ${override.reason}`] : [] + }), +) + +if (runtimeGaps.length > 0) { + console.log('\nRuntime gaps:\n') + console.log(runtimeGaps.join('\n')) +} diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts new file mode 100644 index 0000000..7ed60ea --- /dev/null +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts @@ -0,0 +1,189 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateDeploymentCommand, + CreateResourceCommand, + CreateRestApiCommand, + DeleteRestApiCommand, + GetRestApiCommand, + GetRestApisCommand, + GetResourcesCommand, + PutIntegrationCommand, + PutMethodCommand, + type APIGatewayClient, +} from '@aws-sdk/client-api-gateway' +import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda' +import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter' + +function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient { + return { + send: async (command: unknown) => handler(command), + } as unknown as APIGatewayClient +} + +function fakeLambda(handler: (command: unknown) => unknown): LambdaClient { + return { + send: async (command: unknown) => handler(command), + } as unknown as LambdaClient +} + +const orders = { + id: 'a1b2c3', + name: 'orders-api', + description: 'Orders REST API', + createdDate: new Date('2026-01-02T03:04:05.000Z'), + version: '1.0', + endpointConfiguration: {types: ['REGIONAL']}, + rootResourceId: 'root123', +} + +describe('AwsApiGatewayAdapter', () => { + test('lists every page and maps REST APIs to normalized resources', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command') + if (!command.input.position) return {items: [orders], position: 'next'} + return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]} + })) + + const result = await adapter.list() + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + id: 'a1b2c3', + name: 'orders-api', + cloud: 'aws', + service: 'apigateway', + type: 'rest-api', + region: null, + createdAt: '2026-01-02T03:04:05.000Z', + }) + expect(result[0].metadata).toMatchObject({ + apigatewayService: 'rest', + description: 'Orders REST API', + rootResourceId: 'root123', + }) + }) + + test('filters APIs by search term', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({ + items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}], + }))) + + const result = await adapter.list({search: 'ORDERS'}) + + expect(result.map((resource) => resource.name)).toEqual(['orders-api']) + }) + + test('gets and maps one REST API', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(GetRestApiCommand) + return orders + })) + + const result = await adapter.get('a1b2c3') + + expect(result?.id).toBe('a1b2c3') + expect(result?.metadata.rootResourceId).toBe('root123') + }) + + test('returns null when API Gateway reports a missing REST API', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient(() => { + throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) + })) + + await expect(adapter.get('missing')).resolves.toBeNull() + }) + + test('creates a REST API with an optional description', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(CreateRestApiCommand) + expect((command as CreateRestApiCommand).input).toEqual({name: 'orders-api', description: 'Orders REST API'}) + return orders + })) + + const result = await adapter.create({values: {name: 'orders-api', description: 'Orders REST API'}}) + + expect(result.id).toBe('a1b2c3') + }) + + test('rejects a missing name before calling API Gateway', async () => { + let called = false + const adapter = new AwsApiGatewayAdapter(fakeClient(() => { + called = true + return {} + })) + + await expect(adapter.create({values: {}})).rejects.toThrow('name is required') + expect(called).toBeFalse() + }) + + test('deletes the requested REST API', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(DeleteRestApiCommand) + expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3') + return {} + })) + + await adapter.delete('a1b2c3') + }) + + test('creates and deploys a Lambda proxy route', async () => { + const apiCommands: unknown[] = [] + const lambdaCommands: unknown[] = [] + const adapter = new AwsApiGatewayAdapter( + fakeClient((command) => { + apiCommands.push(command) + if (command instanceof GetResourcesCommand) return {items: [{id: 'root123', path: '/'}]} + if (command instanceof CreateResourceCommand) return {id: 'resource456'} + if (command instanceof CreateDeploymentCommand) return {id: 'deployment789'} + return {} + }), + fakeLambda((command) => { + lambdaCommands.push(command) + if (command instanceof GetFunctionCommand) { + return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello'}} + } + return {} + }), + '000000000000', + 'us-east-1', + ) + + const result = await adapter.createApiGatewayLambdaRoute('a1b2c3', { + path: '/hello', method: 'GET', stage: 'dev', functionName: 'hello', + }) + + expect(apiCommands.map((command) => (command as {constructor: {name: string}}).constructor.name)).toEqual([ + 'GetResourcesCommand', 'CreateResourceCommand', 'PutMethodCommand', + 'PutIntegrationCommand', 'CreateDeploymentCommand', + ]) + expect((apiCommands[1] as CreateResourceCommand).input).toMatchObject({ + restApiId: 'a1b2c3', parentId: 'root123', pathPart: 'hello', + }) + expect((apiCommands[2] as PutMethodCommand).input).toMatchObject({ + httpMethod: 'GET', authorizationType: 'NONE', + }) + expect((apiCommands[3] as PutIntegrationCommand).input).toMatchObject({ + type: 'AWS_PROXY', integrationHttpMethod: 'POST', + uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations', + }) + expect((lambdaCommands[1] as AddPermissionCommand).input).toMatchObject({ + FunctionName: 'hello', Principal: 'apigateway.amazonaws.com', + SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:a1b2c3/dev/GET/hello', + }) + expect(result).toEqual({ + resourceId: 'resource456', deploymentId: 'deployment789', + invokeUrl: 'http://localhost:4566/execute-api/a1b2c3/dev/hello', + }) + }) + + test('returns the AWS API Gateway schema', () => { + const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) + + expect(adapter.schema()).toMatchObject({ + cloud: 'aws', + service: 'apigateway', + displayName: 'AWS API Gateway', + actions: ['list', 'create', 'delete', 'inspect'], + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts new file mode 100644 index 0000000..b681b56 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts @@ -0,0 +1,198 @@ +import { + CreateDeploymentCommand, + CreateResourceCommand, + CreateRestApiCommand, + DeleteRestApiCommand, + GetRestApiCommand, + GetRestApisCommand, + GetResourcesCommand, + PutIntegrationCommand, + PutMethodCommand, + type APIGatewayClient, + type RestApi, +} from '@aws-sdk/client-api-gateway' +import {AddPermissionCommand, GetFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda' +import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws' +import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema' +import {ValidationError} from '../cloud-spi/errors' +import {awsEndpoint} from '../service/runtimeProbe' +import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' + +export class AwsApiGatewayAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'apigateway' as const + + constructor( + private readonly apiGateway: APIGatewayClient = defaultApiGateway, + private readonly lambda: LambdaClient = defaultLambda, + private readonly accountId = defaultAccountId(), + private readonly region = process.env.AWS_REGION ?? 'us-east-1', + ) {} + + schema(): ServiceSchema { + return awsApiGatewaySchema() + } + + async list(query: ResourceQuery = {}): Promise { + const apis: RestApi[] = [] + let position: string | undefined + + do { + const res = await this.apiGateway.send(new GetRestApisCommand({position})) + apis.push(...(res.items ?? [])) + position = res.position + } while (position) + + return filterBySearch(apis.map(toResource), query.search) + } + + async get(id: string): Promise { + try { + const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id})) + return toResource(res) + } catch (error) { + if (hasHttpStatus(error, 404)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.name) + const description = stringValue(input.values.description) + if (!name) throw new ValidationError('name is required') + + const res = await this.apiGateway.send(new CreateRestApiCommand({ + name, + description: description || undefined, + })) + return toResource(res) + } + + async delete(id: string): Promise { + await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id})) + } + + async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise { + const pathPart = routePathPart(input.path) + const method = input.method.toUpperCase() + const stage = stageName(input.stage) + const functionName = stringValue(input.functionName) + if (!functionName) throw new ValidationError('functionName is required') + if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) { + throw new ValidationError('method must be GET, POST, PUT, or DELETE') + } + + const root = await this.rootResource(apiId) + const resource = await this.apiGateway.send(new CreateResourceCommand({ + restApiId: apiId, + parentId: root.id, + pathPart, + })) + const resourceId = resource.id + if (!resourceId) throw new ValidationError('API Gateway did not return a resource id') + + await this.apiGateway.send(new PutMethodCommand({ + restApiId: apiId, + resourceId, + httpMethod: method, + authorizationType: 'NONE', + })) + + const functionResult = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName})) + const functionArn = functionResult.Configuration?.FunctionArn + if (!functionArn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`) + await this.apiGateway.send(new PutIntegrationCommand({ + restApiId: apiId, + resourceId, + httpMethod: method, + type: 'AWS_PROXY', + integrationHttpMethod: 'POST', + uri: `arn:aws:apigateway:${this.region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`, + })) + + await this.lambda.send(new AddPermissionCommand({ + FunctionName: functionName, + StatementId: `apigateway-${apiId}-${stage}-${method}-${pathPart}`, + Action: 'lambda:InvokeFunction', + Principal: 'apigateway.amazonaws.com', + SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/${stage}/${method}/${pathPart}`, + })) + + const deployment = await this.apiGateway.send(new CreateDeploymentCommand({ + restApiId: apiId, + stageName: stage, + description: `Lambda proxy route ${method} /${pathPart}`, + })) + if (!deployment.id) throw new ValidationError('API Gateway did not return a deployment id') + + return { + resourceId, + deploymentId: deployment.id, + invokeUrl: `${awsEndpoint().replace(/\/$/, '')}/execute-api/${apiId}/${stage}/${pathPart}`, + } + } + + private async rootResource(apiId: string) { + let position: string | undefined + do { + const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position})) + const root = result.items?.find((resource) => resource.path === '/') + if (root?.id) return root + position = result.position + } while (position) + throw new ValidationError('API Gateway root resource was not found') + } +} + +function routePathPart(path: string): string { + const value = stringValue(path).replace(/^\//, '') + if (!/^[A-Za-z0-9._~-]+$/.test(value)) { + throw new ValidationError('path must be one URL segment, for example /orders') + } + return value +} + +function stageName(stage: string): string { + const value = stringValue(stage) + if (!/^[A-Za-z0-9_-]+$/.test(value)) { + throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens') + } + return value +} + +function toResource(api: RestApi): CloudResource { + const id = api.id ?? '' + return { + id, + name: api.name ?? '', + cloud: 'aws', + service: 'apigateway', + type: 'rest-api', + region: null, + createdAt: api.createdDate?.toISOString() ?? null, + metadata: { + provider: 'aws', + apigatewayService: 'rest', + description: api.description, + version: api.version, + endpointTypes: api.endpointConfiguration?.types, + rootResourceId: api.rootResourceId, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function hasHttpStatus(error: unknown, status: number): boolean { + if (typeof error !== 'object' || error === null) return false + const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata + return metadata?.httpStatusCode === status +} diff --git a/packages/api/src/adapter-aws/AwsAppConfigAdapter.test.ts b/packages/api/src/adapter-aws/AwsAppConfigAdapter.test.ts new file mode 100644 index 0000000..9a6cc0c --- /dev/null +++ b/packages/api/src/adapter-aws/AwsAppConfigAdapter.test.ts @@ -0,0 +1,206 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateApplicationCommand, + CreateEnvironmentCommand, + CreateHostedConfigurationVersionCommand, + DeleteEnvironmentCommand, + GetApplicationCommand, + GetHostedConfigurationVersionCommand, + ListApplicationsCommand, + ListConfigurationProfilesCommand, + ListDeploymentStrategiesCommand, + ListEnvironmentsCommand, + StartDeploymentCommand, + type AppConfigClient, +} from '@aws-sdk/client-appconfig' +import {AwsAppConfigAdapter} from './AwsAppConfigAdapter' +import {ValidationError} from '../cloud-spi/errors' + +const APPLICATION = {Id: 'abc123', Name: 'orders', Description: 'Order service'} +const ENVIRONMENT = {ApplicationId: 'abc123', Id: 'env777', Name: 'dev', State: 'READY_FOR_DEPLOYMENT'} +const PROFILE = {ApplicationId: 'abc123', Id: 'prof999', Name: 'settings', LocationUri: 'hosted', Type: 'AWS.Freeform'} +const STRATEGY = {Id: 'strat555', Name: 'immediate', DeploymentDurationInMinutes: 0, GrowthFactor: 100} +const DEPLOYMENT = { + ApplicationId: 'abc123', + EnvironmentId: 'env777', + DeploymentStrategyId: 'strat555', + ConfigurationProfileId: 'prof999', + ConfigurationVersion: '1', + DeploymentNumber: 3, + State: 'COMPLETE', +} + +function stubAppConfig(handlers: Record object> = {}) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + for (const [name, handler] of Object.entries(handlers)) { + if (command.constructor.name === name) return handler() + } + return {} + }, + } as unknown as AppConfigClient + return {client, sent} +} + +describe('AwsAppConfigAdapter', () => { + test('identifies itself as the AWS configuration adapter', () => { + const adapter = new AwsAppConfigAdapter(stubAppConfig().client) + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('configuration') + expect(adapter.schema().displayName).toBe('AppConfig') + expect(adapter.schema().actions).toEqual(['list', 'create', 'delete', 'inspect']) + }) + + test('allows AWS AppConfig application names up to 255 characters', () => { + const field = new AwsAppConfigAdapter(stubAppConfig().client).schema().fields[0] + expect(field.validation?.maxLength).toBe(255) + }) + + test('lists applications as top-level resources', async () => { + const {client} = stubAppConfig({ListApplicationsCommand: () => ({Items: [APPLICATION]})}) + const [resource] = await new AwsAppConfigAdapter(client).list() + + expect(resource).toMatchObject({ + id: 'abc123', + name: 'orders', + cloud: 'aws', + service: 'configuration', + type: 'appconfig-application', + }) + expect(resource.metadata.description).toBe('Order service') + }) + + test('returns null when an application is missing', async () => { + const missing = () => { + const error = new Error('not found') + error.name = 'ResourceNotFoundException' + throw error + } + const {client} = stubAppConfig({GetApplicationCommand: missing}) + await expect(new AwsAppConfigAdapter(client).get('nope')).resolves.toBeNull() + }) + + test('creates an application from name and description', async () => { + const {client, sent} = stubAppConfig({CreateApplicationCommand: () => APPLICATION}) + const resource = await new AwsAppConfigAdapter(client).create({values: {name: 'orders', description: 'Order service'}}) + + expect(resource.id).toBe('abc123') + expect((sent[0] as CreateApplicationCommand).input).toEqual({Name: 'orders', Description: 'Order service'}) + }) + + test('rejects an application create without a name', async () => { + const adapter = new AwsAppConfigAdapter(stubAppConfig().client) + await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('lists environments scoped to an application', async () => { + const {client, sent} = stubAppConfig({ListEnvironmentsCommand: () => ({Items: [ENVIRONMENT]})}) + const [environment] = await new AwsAppConfigAdapter(client).listAppConfigEnvironments('abc123') + + expect(environment).toEqual({ + id: 'env777', + applicationId: 'abc123', + name: 'dev', + description: null, + state: 'READY_FOR_DEPLOYMENT', + }) + expect((sent[0] as ListEnvironmentsCommand).input.ApplicationId).toBe('abc123') + }) + + test('creates an environment under an application', async () => { + const {client, sent} = stubAppConfig({CreateEnvironmentCommand: () => ENVIRONMENT}) + const environment = await new AwsAppConfigAdapter(client).createAppConfigEnvironment('abc123', {values: {name: 'dev'}}) + + expect(environment.id).toBe('env777') + expect((sent[0] as CreateEnvironmentCommand).input).toEqual({ApplicationId: 'abc123', Name: 'dev'}) + }) + + test('deletes an environment by application and environment id', async () => { + const {client, sent} = stubAppConfig() + await new AwsAppConfigAdapter(client).deleteAppConfigEnvironment('abc123', 'env777') + + expect((sent[0] as DeleteEnvironmentCommand).input).toEqual({ApplicationId: 'abc123', EnvironmentId: 'env777'}) + }) + + test('lists configuration profiles with type and location', async () => { + const {client} = stubAppConfig({ListConfigurationProfilesCommand: () => ({Items: [PROFILE]})}) + const [profile] = await new AwsAppConfigAdapter(client).listAppConfigConfigurationProfiles('abc123') + + expect(profile).toEqual({ + id: 'prof999', + applicationId: 'abc123', + name: 'settings', + description: null, + locationUri: 'hosted', + type: 'AWS.Freeform', + }) + }) + + test('decodes hosted configuration version content to text', async () => { + const {client} = stubAppConfig({ + GetHostedConfigurationVersionCommand: () => ({ + ...PROFILE, + ConfigurationProfileId: 'prof999', + VersionNumber: 1, + ContentType: 'application/json', + Content: new TextEncoder().encode('{"theme":"dark"}'), + }), + }) + const version = await new AwsAppConfigAdapter(client).getAppConfigHostedConfigurationVersion('abc123', 'prof999', 1) + + expect(version?.content).toBe('{"theme":"dark"}') + expect(version?.contentType).toBe('application/json') + }) + + test('encodes hosted configuration content as bytes', async () => { + const {client, sent} = stubAppConfig({CreateHostedConfigurationVersionCommand: () => ({VersionNumber: 2})}) + await new AwsAppConfigAdapter(client).createAppConfigHostedConfigurationVersion('abc123', 'prof999', { + values: {content: '\n{"theme":"dark"}\n', contentType: 'application/json'}, + }) + + const input = (sent[0] as CreateHostedConfigurationVersionCommand).input + expect(input.ApplicationId).toBe('abc123') + expect(input.ConfigurationProfileId).toBe('prof999') + expect(input.ContentType).toBe('application/json') + expect(input.Content).toEqual(new TextEncoder().encode('\n{"theme":"dark"}\n')) + }) + + test('lists deployment strategies without an application scope', async () => { + const {client} = stubAppConfig({ListDeploymentStrategiesCommand: () => ({Items: [STRATEGY]})}) + const [strategy] = await new AwsAppConfigAdapter(client).listAppConfigDeploymentStrategies() + + expect(strategy).toEqual({ + id: 'strat555', + name: 'immediate', + description: null, + deploymentDurationInMinutes: 0, + growthType: null, + growthFactor: 100, + finalBakeTimeInMinutes: null, + replicateTo: null, + }) + }) + + test('starts a deployment with the selected ids', async () => { + const {client, sent} = stubAppConfig({StartDeploymentCommand: () => DEPLOYMENT}) + const deployment = await new AwsAppConfigAdapter(client).startAppConfigDeployment('abc123', 'env777', { + values: { + configurationProfileId: 'prof999', + configurationVersion: '1', + deploymentStrategyId: 'strat555', + }, + }) + + expect(deployment.deploymentNumber).toBe(3) + expect(deployment.state).toBe('COMPLETE') + expect((sent[0] as StartDeploymentCommand).input).toEqual({ + ApplicationId: 'abc123', + EnvironmentId: 'env777', + DeploymentStrategyId: 'strat555', + ConfigurationProfileId: 'prof999', + ConfigurationVersion: '1', + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsAppConfigAdapter.ts b/packages/api/src/adapter-aws/AwsAppConfigAdapter.ts new file mode 100644 index 0000000..1696b4f --- /dev/null +++ b/packages/api/src/adapter-aws/AwsAppConfigAdapter.ts @@ -0,0 +1,388 @@ +import { + CreateApplicationCommand, + CreateConfigurationProfileCommand, + CreateDeploymentStrategyCommand, + CreateEnvironmentCommand, + CreateHostedConfigurationVersionCommand, + DeleteApplicationCommand, + DeleteConfigurationProfileCommand, + DeleteDeploymentStrategyCommand, + DeleteEnvironmentCommand, + DeleteHostedConfigurationVersionCommand, + GetApplicationCommand, + GetDeploymentCommand, + GetHostedConfigurationVersionCommand, + ListApplicationsCommand, + ListConfigurationProfilesCommand, + ListDeploymentStrategiesCommand, + ListEnvironmentsCommand, + ListHostedConfigurationVersionsCommand, + StartDeploymentCommand, + type AppConfigClient, + type Application, + type ConfigurationProfile, + type ConfigurationProfileSummary, + type Deployment, + type DeploymentStrategy, + type Environment, + type HostedConfigurationVersion, +} from '@aws-sdk/client-appconfig' +import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' +import {ValidationError} from '../cloud-spi/errors' +import type { + AppConfigConfigurationProfile, + AppConfigDeployment, + AppConfigDeploymentStrategy, + AppConfigEnvironment, + AppConfigHostedConfigurationVersion, + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** AWS AppConfig applications and their nested configuration resources. */ +export class AwsAppConfigAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'configuration' as const + + constructor(private readonly appConfig: AppConfigClient) {} + + schema(): ServiceSchema { + return awsAppConfigSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const applications: Application[] = [] + let nextToken: string | undefined + do { + const response = await this.appConfig.send(new ListApplicationsCommand({NextToken: nextToken})) + applications.push(...(response.Items ?? [])) + nextToken = response.NextToken + } while (nextToken) + + const resources = applications.map(applicationResource) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + try { + return applicationResource(await this.appConfig.send(new GetApplicationCommand({ApplicationId: id}))) + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredValue(input.values.name, 'name') + const description = stringValue(input.values.description) || undefined + return applicationResource( + await this.appConfig.send(new CreateApplicationCommand({Name: name, Description: description})), + ) + } + + async delete(id: string): Promise { + await this.appConfig.send(new DeleteApplicationCommand({ApplicationId: id})) + } + + async listAppConfigEnvironments(applicationId: string): Promise { + const environments: Environment[] = [] + let nextToken: string | undefined + do { + const response = await this.appConfig.send( + new ListEnvironmentsCommand({ApplicationId: applicationId, NextToken: nextToken}), + ) + environments.push(...(response.Items ?? [])) + nextToken = response.NextToken + } while (nextToken) + return environments.map((environment) => environmentResource(environment, applicationId)) + } + + async createAppConfigEnvironment(applicationId: string, input: CreateResourceInput): Promise { + const name = requiredValue(input.values.name, 'name') + const description = stringValue(input.values.description) || undefined + return environmentResource( + await this.appConfig.send(new CreateEnvironmentCommand({ApplicationId: applicationId, Name: name, Description: description})), + applicationId, + ) + } + + async deleteAppConfigEnvironment(applicationId: string, environmentId: string): Promise { + await this.appConfig.send(new DeleteEnvironmentCommand({ApplicationId: applicationId, EnvironmentId: environmentId})) + } + + async listAppConfigConfigurationProfiles(applicationId: string): Promise { + const profiles: ConfigurationProfileSummary[] = [] + let nextToken: string | undefined + do { + const response = await this.appConfig.send( + new ListConfigurationProfilesCommand({ApplicationId: applicationId, NextToken: nextToken}), + ) + profiles.push(...(response.Items ?? [])) + nextToken = response.NextToken + } while (nextToken) + return profiles.map((profile) => profileResource(profile, applicationId)) + } + + async createAppConfigConfigurationProfile(applicationId: string, input: CreateResourceInput): Promise { + const name = requiredValue(input.values.name, 'name') + const locationUri = requiredValue(input.values.locationUri, 'locationUri') + const description = stringValue(input.values.description) || undefined + const type = stringValue(input.values.type) || 'AWS.Freeform' + return profileResource( + await this.appConfig.send(new CreateConfigurationProfileCommand({ + ApplicationId: applicationId, + Name: name, + Description: description, + LocationUri: locationUri, + Type: type, + })), + applicationId, + ) + } + + async deleteAppConfigConfigurationProfile(applicationId: string, profileId: string): Promise { + await this.appConfig.send(new DeleteConfigurationProfileCommand({ApplicationId: applicationId, ConfigurationProfileId: profileId})) + } + + async listAppConfigHostedConfigurationVersions(applicationId: string, profileId: string): Promise { + const versions: HostedConfigurationVersion[] = [] + let nextToken: string | undefined + do { + const response = await this.appConfig.send( + new ListHostedConfigurationVersionsCommand({ + ApplicationId: applicationId, + ConfigurationProfileId: profileId, + NextToken: nextToken, + }), + ) + versions.push(...(response.Items ?? [])) + nextToken = response.NextToken + } while (nextToken) + return versions.map((version) => versionResource(version, applicationId, profileId)) + } + + async getAppConfigHostedConfigurationVersion( + applicationId: string, + profileId: string, + versionNumber: number, + ): Promise { + try { + const response = await this.appConfig.send(new GetHostedConfigurationVersionCommand({ + ApplicationId: applicationId, + ConfigurationProfileId: profileId, + VersionNumber: versionNumber, + })) + return versionResource(response, applicationId, profileId) + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + async createAppConfigHostedConfigurationVersion( + applicationId: string, + profileId: string, + input: CreateResourceInput, + ): Promise { + const content = input.values.content + if (typeof content !== 'string' || !content.trim()) throw new ValidationError('content is required') + const contentType = requiredValue(input.values.contentType, 'contentType') + const description = stringValue(input.values.description) || undefined + const response = await this.appConfig.send(new CreateHostedConfigurationVersionCommand({ + ApplicationId: applicationId, + ConfigurationProfileId: profileId, + Content: new TextEncoder().encode(content), + ContentType: contentType, + Description: description, + })) + return versionResource(response, applicationId, profileId) + } + + async deleteAppConfigHostedConfigurationVersion(applicationId: string, profileId: string, versionNumber: number): Promise { + await this.appConfig.send(new DeleteHostedConfigurationVersionCommand({ + ApplicationId: applicationId, + ConfigurationProfileId: profileId, + VersionNumber: versionNumber, + })) + } + + async listAppConfigDeploymentStrategies(): Promise { + const strategies: DeploymentStrategy[] = [] + let nextToken: string | undefined + do { + const response = await this.appConfig.send(new ListDeploymentStrategiesCommand({NextToken: nextToken})) + strategies.push(...(response.Items ?? [])) + nextToken = response.NextToken + } while (nextToken) + return strategies.map(strategyResource) + } + + async createAppConfigDeploymentStrategy(input: CreateResourceInput): Promise { + const name = requiredValue(input.values.name, 'name') + const deploymentDurationInMinutes = requiredNumber(input.values.deploymentDurationInMinutes, 'deploymentDurationInMinutes') + const growthFactor = requiredNumber(input.values.growthFactor, 'growthFactor') + const description = stringValue(input.values.description) || undefined + const finalBakeTimeInMinutes = numberValue(input.values.finalBakeTimeInMinutes) + const response = await this.appConfig.send(new CreateDeploymentStrategyCommand({ + Name: name, + Description: description, + DeploymentDurationInMinutes: deploymentDurationInMinutes, + FinalBakeTimeInMinutes: finalBakeTimeInMinutes ?? 0, + GrowthFactor: growthFactor, + ReplicateTo: 'NONE', + })) + return strategyResource(response) + } + + async deleteAppConfigDeploymentStrategy(strategyId: string): Promise { + await this.appConfig.send(new DeleteDeploymentStrategyCommand({DeploymentStrategyId: strategyId})) + } + + async startAppConfigDeployment(applicationId: string, environmentId: string, input: CreateResourceInput): Promise { + const configurationProfileId = requiredValue(input.values.configurationProfileId, 'configurationProfileId') + const configurationVersion = requiredValue(input.values.configurationVersion, 'configurationVersion') + const deploymentStrategyId = requiredValue(input.values.deploymentStrategyId, 'deploymentStrategyId') + const description = stringValue(input.values.description) || undefined + return deploymentResource(await this.appConfig.send(new StartDeploymentCommand({ + ApplicationId: applicationId, + EnvironmentId: environmentId, + DeploymentStrategyId: deploymentStrategyId, + ConfigurationProfileId: configurationProfileId, + ConfigurationVersion: configurationVersion, + Description: description, + }))) + } + + async getAppConfigDeployment(applicationId: string, environmentId: string, deploymentNumber: number): Promise { + try { + return deploymentResource(await this.appConfig.send(new GetDeploymentCommand({ + ApplicationId: applicationId, + EnvironmentId: environmentId, + DeploymentNumber: deploymentNumber, + }))) + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + async health(): Promise { + await this.appConfig.send(new ListApplicationsCommand({MaxResults: 1})) + } +} + +function applicationResource(application: Application): CloudResource { + return { + id: application.Id ?? '', + name: application.Name ?? '', + cloud: 'aws', + service: 'configuration', + type: 'appconfig-application', + region: null, + createdAt: null, + status: null, + metadata: {description: application.Description}, + } +} + +function environmentResource(environment: Environment, applicationId: string): AppConfigEnvironment { + return { + id: environment.Id ?? '', + applicationId: environment.ApplicationId ?? applicationId, + name: environment.Name ?? '', + description: environment.Description ?? null, + state: environment.State ?? null, + } +} + +function profileResource(profile: ConfigurationProfile | ConfigurationProfileSummary, applicationId: string): AppConfigConfigurationProfile { + return { + id: profile.Id ?? '', + applicationId: profile.ApplicationId ?? applicationId, + name: profile.Name ?? '', + description: 'Description' in profile ? profile.Description ?? null : null, + locationUri: profile.LocationUri ?? null, + type: profile.Type ?? null, + } +} + +function versionResource( + version: HostedConfigurationVersion, + applicationId: string, + profileId: string, +): AppConfigHostedConfigurationVersion { + return { + id: String(version.VersionNumber ?? ''), + applicationId: version.ApplicationId ?? applicationId, + configurationProfileId: version.ConfigurationProfileId ?? profileId, + versionNumber: version.VersionNumber ?? 0, + description: version.Description ?? null, + contentType: version.ContentType ?? null, + content: version.Content ? new TextDecoder().decode(version.Content) : null, + } +} + +function strategyResource(strategy: DeploymentStrategy): AppConfigDeploymentStrategy { + return { + id: strategy.Id ?? '', + name: strategy.Name ?? '', + description: strategy.Description ?? null, + deploymentDurationInMinutes: strategy.DeploymentDurationInMinutes ?? null, + growthType: strategy.GrowthType ?? null, + growthFactor: strategy.GrowthFactor ?? null, + finalBakeTimeInMinutes: strategy.FinalBakeTimeInMinutes ?? null, + replicateTo: strategy.ReplicateTo ?? null, + } +} + +function deploymentResource(deployment: Deployment): AppConfigDeployment { + return { + applicationId: deployment.ApplicationId ?? '', + environmentId: deployment.EnvironmentId ?? '', + deploymentNumber: deployment.DeploymentNumber ?? 0, + configurationProfileId: deployment.ConfigurationProfileId ?? null, + configurationVersion: deployment.ConfigurationVersion ?? null, + deploymentStrategyId: deployment.DeploymentStrategyId ?? null, + state: deployment.State ?? null, + percentageComplete: deployment.PercentageComplete ?? null, + startedAt: deployment.StartedAt?.toISOString() ?? null, + completedAt: deployment.CompletedAt?.toISOString() ?? null, + description: deployment.Description ?? null, + } +} + +function requiredValue(value: unknown, field: string): string { + const result = stringValue(value) + if (!result) throw new ValidationError(`${field} is required`) + return result +} + +function requiredNumber(value: unknown, field: string): number { + const result = Number(value) + if (!Number.isFinite(result)) throw new ValidationError(`${field} must be a number`) + return result +} + +function numberValue(value: unknown): number | null { + const result = Number(value) + return Number.isFinite(result) ? result : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isNotFound(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as {name?: string; $metadata?: {httpStatusCode?: number}} + return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404 +} diff --git a/packages/api/src/adapter-aws/AwsCloudFormationAdapter.test.ts b/packages/api/src/adapter-aws/AwsCloudFormationAdapter.test.ts new file mode 100644 index 0000000..4946588 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCloudFormationAdapter.test.ts @@ -0,0 +1,198 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateStackCommand, + DeleteStackCommand, + DescribeStacksCommand, + ListStackResourcesCommand, + ListStacksCommand, + type CloudFormationClient, +} from '@aws-sdk/client-cloudformation' +import {AwsCloudFormationAdapter} from './AwsCloudFormationAdapter' + +function fakeClient(handler: (command: unknown) => unknown): CloudFormationClient { + return { + send: async (command: unknown) => handler(command), + } as unknown as CloudFormationClient +} + +const ordersStackSummary = { + StackId: 'arn:aws:cloudformation:us-east-1:000000000000:stack/orders-stack/abc123', + StackName: 'orders-stack', + CreationTime: new Date('2026-01-02T03:04:05.000Z'), + StackStatus: 'CREATE_COMPLETE', +} + +const ordersStack = { + StackId: 'arn:aws:cloudformation:us-east-1:000000000000:stack/orders-stack/abc123', + StackName: 'orders-stack', + CreationTime: new Date('2026-01-02T03:04:05.000Z'), + LastUpdatedTime: new Date('2026-01-02T03:04:06.000Z'), + StackStatus: 'CREATE_COMPLETE', + Capabilities: [], + Outputs: [{OutputKey: 'BucketName', OutputValue: 'orders-bucket', Description: 'The bucket name'}], + Tags: [{Key: 'env', Value: 'dev'}], + EnableTerminationProtection: false, +} + +const bucketResource = { + LogicalResourceId: 'OrdersBucket', + PhysicalResourceId: 'orders-bucket', + ResourceType: 'AWS::S3::Bucket', + ResourceStatus: 'CREATE_COMPLETE', +} + +describe('AwsCloudFormationAdapter', () => { + test('lists every page and maps stacks to normalized resources', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient((command) => { + if (!(command instanceof ListStacksCommand)) throw new Error('Unexpected command') + if (!command.input.NextToken) return {StackSummaries: [ordersStackSummary], NextToken: 'next'} + return {StackSummaries: [{...ordersStackSummary, StackName: 'accounts-stack'}]} + })) + + const result = await adapter.list() + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + id: 'orders-stack', + name: 'orders-stack', + cloud: 'aws', + service: 'iac', + type: 'stack', + region: null, + createdAt: '2026-01-02T03:04:05.000Z', + status: 'CREATE_COMPLETE', + }) + expect(result[0].metadata).toMatchObject({ + stackId: ordersStackSummary.StackId, + }) + }) + + test('filters stacks by search term', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({ + StackSummaries: [ordersStackSummary, {...ordersStackSummary, StackName: 'accounts-stack'}], + }))) + + const result = await adapter.list({search: 'ORDERS'}) + + expect(result.map((resource) => resource.name)).toEqual(['orders-stack']) + }) + + test('gets and maps one stack, including its resources', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient((command) => { + if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]} + if (command instanceof ListStackResourcesCommand) return {StackResourceSummaries: [bucketResource]} + throw new Error('Unexpected command') + })) + + const result = await adapter.get('orders-stack') + + expect(result?.id).toBe('orders-stack') + expect(result?.status).toBe('CREATE_COMPLETE') + expect(result?.metadata.outputs).toEqual([{key: 'BucketName', value: 'orders-bucket', description: 'The bucket name'}]) + expect(result?.metadata.tags).toEqual([{key: 'env', value: 'dev'}]) + expect(result?.metadata.resources).toEqual([{ + logicalId: 'OrdersBucket', + physicalId: 'orders-bucket', + resourceType: 'AWS::S3::Bucket', + status: 'CREATE_COMPLETE', + }]) + }) + + test('paginates ListStackResources and combines resources from every page', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient((command) => { + if (command instanceof DescribeStacksCommand) return {Stacks: [ordersStack]} + if (command instanceof ListStackResourcesCommand) { + if (!command.input.NextToken) { + return {StackResourceSummaries: [bucketResource], NextToken: 'next-page'} + } + return {StackResourceSummaries: [{...bucketResource, LogicalResourceId: 'OrdersQueue', PhysicalResourceId: 'orders-queue', ResourceType: 'AWS::SQS::Queue'}]} + } + throw new Error('Unexpected command') + })) + + const result = await adapter.get('orders-stack') + + expect(result?.metadata.resources).toEqual([ + { + logicalId: 'OrdersBucket', + physicalId: 'orders-bucket', + resourceType: 'AWS::S3::Bucket', + status: 'CREATE_COMPLETE', + }, + { + logicalId: 'OrdersQueue', + physicalId: 'orders-queue', + resourceType: 'AWS::SQS::Queue', + status: 'CREATE_COMPLETE', + }, + ]) + }) + + test('returns null when CloudFormation reports a missing stack', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient(() => { + throw Object.assign(new Error('Stack with id missing-stack does not exist'), {name: 'ValidationError'}) + })) + + await expect(adapter.get('missing-stack')).resolves.toBeNull() + }) + + test('creates a stack from a stack name and template body', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(CreateStackCommand) + expect((command as CreateStackCommand).input).toEqual({ + StackName: 'orders-stack', + TemplateBody: '{"Resources":{}}', + }) + return {StackId: ordersStack.StackId} + })) + + const result = await adapter.create({values: {stackName: 'orders-stack', templateBody: '{"Resources":{}}'}}) + + expect(result.id).toBe('orders-stack') + expect(result.status).toBe('CREATE_IN_PROGRESS') + expect(result.metadata.stackId).toBe(ordersStack.StackId) + }) + + test('rejects a missing stack name before calling CloudFormation', async () => { + let called = false + const adapter = new AwsCloudFormationAdapter(fakeClient(() => { + called = true + return {} + })) + + await expect(adapter.create({values: {templateBody: '{}'}})).rejects.toThrow('stackName is required') + expect(called).toBeFalse() + }) + + test('rejects a missing template body before calling CloudFormation', async () => { + let called = false + const adapter = new AwsCloudFormationAdapter(fakeClient(() => { + called = true + return {} + })) + + await expect(adapter.create({values: {stackName: 'orders-stack'}})).rejects.toThrow('templateBody is required') + expect(called).toBeFalse() + }) + + test('deletes the requested stack', async () => { + const adapter = new AwsCloudFormationAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(DeleteStackCommand) + expect((command as DeleteStackCommand).input.StackName).toBe('orders-stack') + return {} + })) + + await adapter.delete('orders-stack') + }) + + test('returns the AWS CloudFormation schema', () => { + const adapter = new AwsCloudFormationAdapter(fakeClient(() => ({}))) + + expect(adapter.schema()).toMatchObject({ + cloud: 'aws', + service: 'iac', + displayName: 'CloudFormation', + actions: ['list', 'create', 'delete', 'inspect'], + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsCloudFormationAdapter.ts b/packages/api/src/adapter-aws/AwsCloudFormationAdapter.ts new file mode 100644 index 0000000..12925a8 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCloudFormationAdapter.ts @@ -0,0 +1,160 @@ +import { + CloudFormationClient, + CreateStackCommand, + DeleteStackCommand, + DescribeStacksCommand, + ListStackResourcesCommand, + ListStacksCommand, + type Stack, + type StackResourceSummary, + type StackSummary, +} from '@aws-sdk/client-cloudformation' +import {cloudformation as defaultCloudFormation} from '../aws' +import {awsCloudformationSchema} from '../cloud-spi/cloudformationSchema' +import {ValidationError} from '../cloud-spi/errors' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' + +export class AwsCloudFormationAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'iac' as const + + constructor(private readonly cloudformation: CloudFormationClient = defaultCloudFormation) {} + + schema(): ServiceSchema { + return awsCloudformationSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const summaries: StackSummary[] = [] + let nextToken: string | undefined + + do { + const res = await this.cloudformation.send(new ListStacksCommand({NextToken: nextToken})) + summaries.push(...(res.StackSummaries ?? [])) + nextToken = res.NextToken + } while (nextToken) + + return filterBySearch(summaries.map(summaryToResource), query.search) + } + + async get(id: string): Promise { + try { + const describe = await this.cloudformation.send(new DescribeStacksCommand({StackName: id})) + const stack = describe.Stacks?.[0] + if (!stack) return null + + const resourceSummaries: StackResourceSummary[] = [] + let nextToken: string | undefined + do { + const resources = await this.cloudformation.send(new ListStackResourcesCommand({StackName: id, NextToken: nextToken})) + resourceSummaries.push(...(resources.StackResourceSummaries ?? [])) + nextToken = resources.NextToken + } while (nextToken) + + return stackToResource(stack, resourceSummaries) + } catch (error) { + if (isStackNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const stackName = stringValue(input.values.stackName) + const templateBody = stringValue(input.values.templateBody) + if (!stackName) throw new ValidationError('stackName is required') + if (!templateBody) throw new ValidationError('templateBody is required') + + const res = await this.cloudformation.send(new CreateStackCommand({StackName: stackName, TemplateBody: templateBody})) + return { + id: stackName, + name: stackName, + cloud: 'aws', + service: 'iac', + type: 'stack', + region: null, + createdAt: null, + status: 'CREATE_IN_PROGRESS', + metadata: { + provider: 'aws', + stackId: res.StackId, + }, + } + } + + async delete(id: string): Promise { + await this.cloudformation.send(new DeleteStackCommand({StackName: id})) + } +} + +function summaryToResource(summary: StackSummary): CloudResource { + const id = summary.StackName ?? '' + return { + id, + name: id, + cloud: 'aws', + service: 'iac', + type: 'stack', + region: null, + createdAt: summary.CreationTime?.toISOString() ?? null, + status: summary.StackStatus ?? null, + metadata: { + provider: 'aws', + stackId: summary.StackId, + lastUpdatedTime: summary.LastUpdatedTime?.toISOString(), + }, + } +} + +function stackToResource(stack: Stack, resources: StackResourceSummary[]): CloudResource { + const id = stack.StackName ?? '' + return { + id, + name: id, + cloud: 'aws', + service: 'iac', + type: 'stack', + region: null, + createdAt: stack.CreationTime?.toISOString() ?? null, + status: stack.StackStatus ?? null, + metadata: { + provider: 'aws', + stackId: stack.StackId, + description: stack.Description, + lastUpdatedTime: stack.LastUpdatedTime?.toISOString(), + capabilities: stack.Capabilities, + enableTerminationProtection: stack.EnableTerminationProtection, + outputs: (stack.Outputs ?? []).map((output) => ({ + key: output.OutputKey, + value: output.OutputValue, + description: output.Description, + })), + tags: (stack.Tags ?? []).map((tag) => ({key: tag.Key, value: tag.Value})), + resources: resources.map((resource) => ({ + logicalId: resource.LogicalResourceId, + physicalId: resource.PhysicalResourceId, + resourceType: resource.ResourceType, + status: resource.ResourceStatus, + })), + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +/** + * DescribeStacks on a missing stack answers a 400 ValidationError with a + * "does not exist" message rather than a 404 — verified against the local + * Floci CloudFormation emulator, which matches real AWS behaviour. + */ +function isStackNotFound(error: unknown): boolean { + if (!(error instanceof Error)) return false + return error.name === 'ValidationError' && /does not exist/i.test(error.message) +} diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts new file mode 100644 index 0000000..9700ff3 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts @@ -0,0 +1,144 @@ +import {describe, expect, test} from 'bun:test' +import {AwsComputeAdapter} from './AwsComputeAdapter' +import type {Ec2Instance, RunInstanceInput} from '../services/ec2' + +const baseInstance: Ec2Instance = { + instanceId: 'i-0abc123def456', + name: 'web-server-1', + state: 'running', + instanceType: 't3.micro', + availabilityZone: 'us-east-1a', + publicIpAddress: '54.1.2.3', + privateIpAddress: '10.0.1.5', + vpcId: 'vpc-abc', + subnetId: 'subnet-abc', + imageId: 'ami-0abcdef', + keyName: 'my-key', + launchTime: '2025-01-01T00:00:00.000Z', + architecture: 'x86_64', + platform: undefined, + securityGroups: [{id: 'sg-abc', name: 'default'}], + tags: [{key: 'Name', value: 'web-server-1'}], +} + +function fakeService(overrides: Partial<{ + listInstances: () => Promise + describeInstance: (id: string) => Promise + terminateInstance: (id: string) => Promise + listAmis: () => Promise<[]> + runInstance: (input: RunInstanceInput) => Promise +}> = {}) { + return { + listInstances: async () => [baseInstance], + describeInstance: async (_id: string) => baseInstance, + terminateInstance: async (_id: string) => {}, + listAmis: async () => [] as [], + runInstance: async (_input: RunInstanceInput) => baseInstance, + ...overrides, + } +} + +describe('AwsComputeAdapter', () => { + test('list returns mapped CloudResource array', async () => { + const adapter = new AwsComputeAdapter(fakeService()) + const result = await adapter.list() + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('i-0abc123def456') + expect(result[0].name).toBe('web-server-1') + expect(result[0].cloud).toBe('aws') + expect(result[0].service).toBe('compute') + expect(result[0].type).toBe('instance') + expect(result[0].status).toBe('running') + expect(result[0].region).toBe('us-east-1a') + }) + + test('list filters results by search term', async () => { + const adapter = new AwsComputeAdapter(fakeService({ + listInstances: async () => [ + baseInstance, + {...baseInstance, instanceId: 'i-other', name: 'db-server-1'}, + ], + })) + + const result = await adapter.list({search: 'web'}) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('web-server-1') + }) + + test('list returns all instances when search is empty', async () => { + const adapter = new AwsComputeAdapter(fakeService({ + listInstances: async () => [ + baseInstance, + {...baseInstance, instanceId: 'i-other', name: 'db-server-1'}, + ], + })) + + const result = await adapter.list({}) + + expect(result).toHaveLength(2) + }) + + test('get returns null when instance has 404 http status', async () => { + const err = Object.assign(new Error('Not found'), {$metadata: {httpStatusCode: 404}}) + const adapter = new AwsComputeAdapter(fakeService({ + describeInstance: async () => { throw err }, + })) + + const result = await adapter.get('i-missing') + + expect(result).toBeNull() + }) + + test('get rethrows non-404 errors', async () => { + const err = Object.assign(new Error('InternalError'), {$metadata: {httpStatusCode: 500}}) + const adapter = new AwsComputeAdapter(fakeService({ + describeInstance: async () => { throw err }, + })) + + await expect(adapter.get('i-bad')).rejects.toThrow('InternalError') + }) + + test('create calls runInstance and returns mapped CloudResource', async () => { + const launched: RunInstanceInput[] = [] + const adapter = new AwsComputeAdapter(fakeService({ + runInstance: async (input) => { launched.push(input); return baseInstance }, + })) + const result = await adapter.create({values: { + name: 'test-box', + imageId: 'ami-0abcdef123', + instanceType: 't3.micro', + keyName: 'my-key', + subnetId: 'subnet-abc', + securityGroupIds: 'sg-111, sg-222', + }}) + expect(launched).toHaveLength(1) + expect(launched[0].name).toBe('test-box') + expect(launched[0].imageId).toBe('ami-0abcdef123') + expect(launched[0].instanceType).toBe('t3.micro') + expect(launched[0].securityGroupIds).toEqual(['sg-111', 'sg-222']) + expect(result.type).toBe('instance') + expect(result.cloud).toBe('aws') + }) + + test('delete calls terminateInstance with the id', async () => { + const terminated: string[] = [] + const adapter = new AwsComputeAdapter(fakeService({ + terminateInstance: async (id) => { terminated.push(id) }, + })) + await adapter.delete('i-0abc123def456') + expect(terminated).toEqual(['i-0abc123def456']) + }) + + test('schema returns aws compute schema', () => { + const adapter = new AwsComputeAdapter(fakeService()) + const schema = adapter.schema() + + expect(schema.cloud).toBe('aws') + expect(schema.service).toBe('compute') + expect(schema.displayName).toBe('Compute') + expect(schema.actions).toContain('list') + expect(schema.actions).toContain('inspect') + }) +}) diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.ts new file mode 100644 index 0000000..13b472b --- /dev/null +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.ts @@ -0,0 +1,128 @@ +import {awsComputeSchema} from '../cloud-spi/computeSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' +import {ec2Service, type Ec2Image, type Ec2Instance, type RunInstanceInput} from '../services/ec2' + +type Ec2ServiceShape = { + listInstances(): Promise + describeInstance(instanceId: string): Promise + terminateInstance(instanceId: string): Promise + listAmis(): Promise + runInstance(input: RunInstanceInput): Promise +} + +export class AwsComputeAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'compute' as const + + constructor(private readonly service_: Ec2ServiceShape = ec2Service) {} + + schema(): ServiceSchema { + return awsComputeSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const [instances, images] = await Promise.all([ + this.service_.listInstances(), + this.service_.listAmis(), + ]) + const resources = [ + ...instances.map(instanceToResource), + ...images.map(imageToResource), + ] + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + try { + return instanceToResource(await this.service_.describeInstance(id)) + } catch (error) { + if (hasHttpStatus(error, 404)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const v = input.values + const sgRaw = typeof v.securityGroupIds === 'string' ? v.securityGroupIds : '' + const securityGroupIds = sgRaw ? sgRaw.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined + const runInput: RunInstanceInput = { + name: String(v.name ?? ''), + imageId: String(v.imageId ?? ''), + instanceType: String(v.instanceType ?? ''), + keyName: v.keyName ? String(v.keyName) : undefined, + subnetId: v.subnetId ? String(v.subnetId) : undefined, + securityGroupIds, + } + const instance = await this.service_.runInstance(runInput) + return instanceToResource(instance) + } + + async delete(id: string): Promise { + await this.service_.terminateInstance(id) + } +} + +function instanceToResource(instance: Ec2Instance): CloudResource { + return { + id: instance.instanceId, + name: instance.name, + cloud: 'aws', + service: 'compute', + type: 'instance', + region: instance.availabilityZone ?? null, + createdAt: instance.launchTime ?? null, + status: instance.state ?? null, + metadata: { + instanceType: instance.instanceType, + publicIpAddress: instance.publicIpAddress, + privateIpAddress: instance.privateIpAddress, + vpcId: instance.vpcId, + subnetId: instance.subnetId, + imageId: instance.imageId, + keyName: instance.keyName, + architecture: instance.architecture, + platform: instance.platform, + securityGroups: instance.securityGroups, + tags: instance.tags, + }, + } +} + +function imageToResource(image: Ec2Image): CloudResource { + return { + id: image.imageId, + name: image.name, + cloud: 'aws', + service: 'compute', + type: 'image', + region: null, + createdAt: image.createdAt ?? null, + status: image.state ?? null, + metadata: { + architecture: image.architecture, + rootDeviceType: image.rootDeviceType, + virtualizationType: image.virtualizationType, + ownerId: image.ownerId, + public: image.public, + tags: image.tags, + }, + } +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((r) => r.name.toLowerCase().includes(normalized)) +} + +function hasHttpStatus(error: unknown, status: number): boolean { + if (typeof error !== 'object' || error === null) return false + const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata + return metadata?.httpStatusCode === status +} diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts new file mode 100644 index 0000000..6a6ca66 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts @@ -0,0 +1,544 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateDBInstanceCommand, + CreateDBSnapshotCommand, + DeleteDBInstanceCommand, + DescribeDBInstancesCommand, + DescribeDBSnapshotsCommand, + DescribeOrderableDBInstanceOptionsCommand, + ListTagsForResourceCommand, + ModifyDBInstanceCommand, + type RDSClient, +} from '@aws-sdk/client-rds' +import {ConflictError, ValidationError} from '../cloud-spi/errors' +import {createRdsService} from '../services/rds' +import {mapAwsSdkError} from './awsErrors' +import {AwsDatabaseAdapter} from './AwsDatabaseAdapter' + +type RdsCommand = + | CreateDBInstanceCommand + | CreateDBSnapshotCommand + | DeleteDBInstanceCommand + | DescribeDBInstancesCommand + | DescribeDBSnapshotsCommand + | DescribeOrderableDBInstanceOptionsCommand + | ListTagsForResourceCommand + | ModifyDBInstanceCommand + +type Responder = (command: RdsCommand) => unknown | Promise + +function recordingRds(responder: Responder) { + const commands: RdsCommand[] = [] + const client = { + async send(command: RdsCommand) { + commands.push(command) + return responder(command) + }, + } as unknown as RDSClient + + return {client, commands} +} + +function adapterWith(responder: Responder) { + const recording = recordingRds(responder) + return { + ...recording, + adapter: new AwsDatabaseAdapter(createRdsService(recording.client), recording.client), + } +} + +function instanceResponse(command: CreateDBInstanceCommand) { + return { + DBInstance: { + DBInstanceIdentifier: command.input.DBInstanceIdentifier, + Engine: command.input.Engine, + EngineVersion: command.input.EngineVersion, + DBInstanceClass: command.input.DBInstanceClass, + AllocatedStorage: command.input.AllocatedStorage, + MasterUsername: command.input.MasterUsername, + DBName: command.input.DBName, + DBInstanceStatus: 'creating', + }, + } +} + +const validCreateValues = { + dbInstanceIdentifier: 'orders-db', + engine: 'postgres', + masterUserPassword: 'secret-pass', +} + +describe('AwsDatabaseAdapter', () => { + test('keeps list search behavior intact', async () => { + const {adapter} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(DescribeDBInstancesCommand) + return { + DBInstances: [ + {DBInstanceIdentifier: 'orders-db', Engine: 'postgres', VpcSecurityGroups: []}, + {DBInstanceIdentifier: 'users-db', Engine: 'mysql', VpcSecurityGroups: []}, + ], + } + }) + + const resources = await adapter.list({search: ' ORDERS '}) + + expect(resources.map(({id}) => id)).toEqual(['orders-db']) + }) + + test('keeps get 404 behavior intact', async () => { + const {adapter} = adapterWith(async () => { + throw Object.assign(new Error('not found'), {$metadata: {httpStatusCode: 404}}) + }) + + await expect(adapter.get('missing-db')).resolves.toBeNull() + }) + + test('creates an instance with required values and provider defaults', async () => { + const {adapter, commands} = adapterWith((command) => { + expect(command).toBeInstanceOf(CreateDBInstanceCommand) + return instanceResponse(command as CreateDBInstanceCommand) + }) + + const resource = await adapter.create({values: { + dbInstanceIdentifier: ' orders-db ', + engine: 'postgres', + masterUserPassword: 'secret-pass', + }}) + + const createCommand = commands[0] as CreateDBInstanceCommand + const {MasterUserPassword, ...nonSensitiveInput} = createCommand.input + expect(nonSensitiveInput).toEqual({ + DBInstanceIdentifier: 'orders-db', + Engine: 'postgres', + DBInstanceClass: 'db.t3.micro', + AllocatedStorage: 20, + MasterUsername: 'root', + }) + expect(MasterUserPassword === validCreateValues.masterUserPassword).toBe(true) + expect(JSON.stringify(resource).includes(validCreateValues.masterUserPassword)).toBe(false) + expect(resource).toMatchObject({ + id: 'orders-db', + name: 'orders-db', + type: 'db-instance', + status: 'creating', + engine: 'postgres', + instanceClass: 'db.t3.micro', + }) + }) + + test('maps all nine create fields including security groups', async () => { + const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand)) + + await adapter.create({values: { + dbInstanceIdentifier: 'orders-db', + engine: 'mariadb', + masterUserPassword: 'secret-pass', + dbInstanceClass: ' db.m6g.large ', + allocatedStorage: '100', + masterUsername: ' app_user ', + dbName: ' orders ', + engineVersion: ' 10.11 ', + securityGroupIds: 'sg-12345678, sg-87654321', + }}) + + const createCommand = commands[0] as CreateDBInstanceCommand + const {MasterUserPassword, ...nonSensitiveInput} = createCommand.input + expect(nonSensitiveInput).toEqual({ + DBInstanceIdentifier: 'orders-db', + Engine: 'mariadb', + DBInstanceClass: 'db.m6g.large', + AllocatedStorage: 100, + MasterUsername: 'app_user', + DBName: 'orders', + EngineVersion: '10.11', + VpcSecurityGroupIds: ['sg-12345678', 'sg-87654321'], + }) + expect(MasterUserPassword === validCreateValues.masterUserPassword).toBe(true) + }) + + test('accepts array of security group ids', async () => { + const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand)) + + await adapter.create({values: { + ...validCreateValues, + securityGroupIds: ['sg-11111111', 'sg-22222222'], + }}) + + const createCommand = commands[0] as CreateDBInstanceCommand + expect(createCommand.input.VpcSecurityGroupIds).toEqual(['sg-11111111', 'sg-22222222']) + }) + + test('uses defaults for blank values and omits blank database name and engine version', async () => { + const {adapter, commands} = adapterWith((command) => instanceResponse(command as CreateDBInstanceCommand)) + + await adapter.create({values: { + ...validCreateValues, + dbInstanceClass: ' ', + allocatedStorage: '', + masterUsername: ' ', + dbName: ' ', + }}) + + const input = (commands[0] as CreateDBInstanceCommand).input + expect(input.DBInstanceClass).toBe('db.t3.micro') + expect(input.AllocatedStorage).toBe(20) + expect(input.MasterUsername).toBe('root') + expect(input.DBName).toBeUndefined() + expect(input.EngineVersion).toBeUndefined() + }) + + test('rejects invalid instance identifiers before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const dbInstanceIdentifier of ['', 'Orders-db', '1-orders', 'orders--db', 'orders-', 'a'.repeat(64)]) { + await expect(adapter.create({values: { + ...validCreateValues, + dbInstanceIdentifier, + }})).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('rejects unsupported engines before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const engine of ['', 'oracle', 'POSTGRES', 42]) { + await expect(adapter.create({values: {...validCreateValues, engine}})).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('rejects invalid passwords without exposing them or calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const masterUserPassword of ['', 'short', 'x'.repeat(129), 42]) { + let caught: unknown + try { + await adapter.create({values: {...validCreateValues, masterUserPassword}}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(ValidationError) + const exposesPassword = caught instanceof Error + && typeof masterUserPassword === 'string' + && masterUserPassword.length > 0 + && caught.message.includes(masterUserPassword) + expect(exposesPassword).toBe(false) + } + expect(commands).toHaveLength(0) + }) + + test('rejects non-positive, fractional, and non-numeric storage before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + for (const allocatedStorage of [0, -1, 1.5, '0', '-1', '1.5', 'twenty', true]) { + await expect(adapter.create({values: { + ...validCreateValues, + allocatedStorage, + }})).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('rejects storage that exceeds the safe integer range before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + await expect(adapter.create({values: { + ...validCreateValues, + allocatedStorage: '9'.repeat(400), + }})).rejects.toBeInstanceOf(ValidationError) + expect(commands).toHaveLength(0) + }) + + test('leaves provider failures for the shared AWS error mapper', async () => { + const providerError = Object.assign(new Error('instance already exists'), { + name: 'DBInstanceAlreadyExistsFault', + $fault: 'client', + $metadata: {httpStatusCode: 400}, + }) + const {adapter} = adapterWith(async () => { + throw providerError + }) + + let caught: unknown + try { + await adapter.create({values: validCreateValues}) + } catch (error) { + caught = error + } + + expect(caught).toBe(providerError) + expect(mapAwsSdkError(caught)).toBeInstanceOf(ConflictError) + expect(caught instanceof Error && caught.message.includes(validCreateValues.masterUserPassword)).toBe(false) + }) + + test('deletes without a final snapshot', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + await adapter.delete('orders-db') + + const deleteCommand = commands[0] as DeleteDBInstanceCommand + expect(deleteCommand).toBeInstanceOf(DeleteDBInstanceCommand) + expect(deleteCommand.input).toEqual({ + DBInstanceIdentifier: 'orders-db', + SkipFinalSnapshot: true, + }) + }) + + test('lists and normalizes database snapshots', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand) + return { + DBSnapshots: [{ + DBSnapshotIdentifier: 'orders-db-snapshot-1', + DBInstanceIdentifier: 'orders-db', + DBSnapshotArn: 'arn:aws:rds:eu-west-2:000000000000:snapshot:orders-db-snapshot-1', + Status: 'available', + Engine: 'postgres', + EngineVersion: '16.4', + AllocatedStorage: 100, + SnapshotType: 'manual', + SnapshotCreateTime: new Date('2026-08-14T09:30:00.000Z'), + Port: 5432, + AvailabilityZone: 'eu-west-2a', + VpcId: 'vpc-123', + }], + } + }) + + const snapshots = await adapter.listDatabaseSnapshots('orders-db') + + expect((commands[0] as DescribeDBSnapshotsCommand).input).toEqual({ + DBInstanceIdentifier: 'orders-db', + Marker: undefined, + }) + expect(snapshots).toEqual([{ + id: 'orders-db-snapshot-1', + name: 'orders-db-snapshot-1', + instanceIdentifier: 'orders-db', + status: 'available', + engine: 'postgres', + version: '16.4', + createdAt: '2026-08-14T09:30:00.000Z', + metadata: { + arn: 'arn:aws:rds:eu-west-2:000000000000:snapshot:orders-db-snapshot-1', + allocatedStorage: 100, + snapshotType: 'manual', + port: 5432, + availabilityZone: 'eu-west-2a', + vpcId: 'vpc-123', + }, + }]) + }) + + test('does not swallow snapshot provider failures', async () => { + const providerError = Object.assign(new Error('operation unavailable'), { + name: 'UnsupportedOperation', + $fault: 'client', + $metadata: {httpStatusCode: 501}, + }) + const {adapter} = adapterWith(async () => { + throw providerError + }) + + await expect(adapter.listDatabaseSnapshots()).rejects.toBe(providerError) + }) + + test('creates and normalizes a database snapshot', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(CreateDBSnapshotCommand) + return { + DBSnapshot: { + DBSnapshotIdentifier: 'orders-db-snapshot-1', + DBInstanceIdentifier: 'orders-db', + Status: 'creating', + Engine: 'postgres', + SnapshotType: 'manual', + }, + } + }) + + const snapshot = await adapter.createDatabaseSnapshot({ + instanceIdentifier: ' orders-db ', + snapshotIdentifier: ' orders-db-snapshot-1 ', + }) + + const createCommand = commands[0] as CreateDBSnapshotCommand + expect(createCommand.input).toEqual({ + DBInstanceIdentifier: 'orders-db', + DBSnapshotIdentifier: 'orders-db-snapshot-1', + }) + expect(snapshot).toMatchObject({ + id: 'orders-db-snapshot-1', + name: 'orders-db-snapshot-1', + instanceIdentifier: 'orders-db', + status: 'creating', + engine: 'postgres', + metadata: {snapshotType: 'manual'}, + }) + }) + + test('rejects missing and invalid snapshot identifiers before calling RDS', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + const invalidInputs = [ + {instanceIdentifier: '', snapshotIdentifier: 'orders-db-snapshot-1'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: ''}, + {instanceIdentifier: 'orders db', snapshotIdentifier: 'orders-db-snapshot-1'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: '1-orders-snapshot'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: 'orders--snapshot'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: 'orders-snapshot-'}, + {instanceIdentifier: 'orders-db', snapshotIdentifier: 'a'.repeat(256)}, + ] + + for (const input of invalidInputs) { + await expect(adapter.createDatabaseSnapshot(input)).rejects.toBeInstanceOf(ValidationError) + } + expect(commands).toHaveLength(0) + }) + + test('lists and deduplicates orderable instance classes', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(DescribeOrderableDBInstanceOptionsCommand) + return { + OrderableDBInstanceOptions: [ + {DBInstanceClass: 'db.t3.micro', Engine: 'postgres'}, + {DBInstanceClass: 'db.m8g.large', Engine: 'postgres'}, + {DBInstanceClass: 'db.t3.micro', Engine: 'postgres'}, + ], + } + }) + + const classes = await adapter.listDatabaseOrderableInstanceClasses('postgres') + + expect((commands[0] as DescribeOrderableDBInstanceOptionsCommand).input).toEqual({ + Engine: 'postgres', + Marker: undefined, + }) + expect(classes).toEqual(['db.t3.micro', 'db.m8g.large']) + }) + + test('updates an instance with supported fields and excludes unsupported fields', async () => { + const {adapter, commands} = adapterWith(async (command) => { + expect(command).toBeInstanceOf(ModifyDBInstanceCommand) + return { + DBInstance: { + DBInstanceIdentifier: 'orders-db', + Engine: 'postgres', + DBInstanceStatus: 'available', + IAMDatabaseAuthenticationEnabled: false, + DBSubnetGroup: {DBSubnetGroupName: 'private-db'}, + VpcSecurityGroups: [{VpcSecurityGroupId: 'sg-1', Status: 'active'}, {VpcSecurityGroupId: 'sg-2', Status: 'active'}], + OptionGroupMemberships: [{OptionGroupName: 'default-postgres', Status: 'in-sync'}], + AutoMinorVersionUpgrade: true, + }, + } + }) + + const resource = await adapter.update('orders-db', {values: { + masterUserPassword: 'new-secret', + enableIamDatabaseAuthentication: 'false', + dbSubnetGroupName: 'private-db', + vpcSecurityGroupIds: 'sg-1, sg-2', + optionGroupName: 'default-postgres', + autoMinorVersionUpgrade: 'true', + }}) + + const modifyCommand = commands[0] as ModifyDBInstanceCommand + expect(modifyCommand.input).toEqual({ + DBInstanceIdentifier: 'orders-db', + MasterUserPassword: 'new-secret', + EnableIAMDatabaseAuthentication: false, + DBSubnetGroupName: 'private-db', + VpcSecurityGroupIds: ['sg-1', 'sg-2'], + OptionGroupName: 'default-postgres', + AutoMinorVersionUpgrade: true, + }) + expect(modifyCommand.input).not.toHaveProperty('DBInstanceClass') + expect(modifyCommand.input).not.toHaveProperty('AllocatedStorage') + expect(modifyCommand.input).not.toHaveProperty('Engine') + expect(modifyCommand.input).not.toHaveProperty('EngineVersion') + expect(modifyCommand.input).not.toHaveProperty('ApplyImmediately') + + expect(JSON.stringify(resource)).not.toContain('new-secret') + expect(resource.metadata.optionGroupName).toBe('default-postgres') + expect(resource.metadata.autoMinorVersionUpgrade).toBe(true) + expect(resource.metadata.vpcSecurityGroupIds).toBe('sg-1, sg-2') + }) + + test('accepts boolean literals for boolean update fields', async () => { + const {adapter, commands} = adapterWith(async (command) => ({ + DBInstance: { + DBInstanceIdentifier: 'orders-db', + Engine: 'postgres', + DBInstanceStatus: 'available', + }, + })) + + await adapter.update('orders-db', {values: { + enableIamDatabaseAuthentication: true, + autoMinorVersionUpgrade: false, + }}) + + const modifyCommand = commands[0] as ModifyDBInstanceCommand + expect(modifyCommand.input.EnableIAMDatabaseAuthentication).toBe(true) + expect(modifyCommand.input.AutoMinorVersionUpgrade).toBe(false) + }) + + test('omits blank strings from update and trims CSV', async () => { + const {adapter, commands} = adapterWith(async () => ({ + DBInstance: { + DBInstanceIdentifier: 'orders-db', + Engine: 'postgres', + DBInstanceStatus: 'available', + }, + })) + + await adapter.update('orders-db', {values: { + masterUserPassword: '', + enableIamDatabaseAuthentication: '', + dbSubnetGroupName: ' ', + vpcSecurityGroupIds: ' sg-100 , sg-200 ', + optionGroupName: '', + autoMinorVersionUpgrade: '', + }}) + + const modifyCommand = commands[0] as ModifyDBInstanceCommand + expect(modifyCommand.input).toEqual({ + DBInstanceIdentifier: 'orders-db', + VpcSecurityGroupIds: ['sg-100', 'sg-200'], + }) + }) + + test('rejects malformed booleans and invalid passwords without echoing password', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + await expect(adapter.update('orders-db', {values: { + enableIamDatabaseAuthentication: 'maybe', + }})).rejects.toBeInstanceOf(ValidationError) + + await expect(adapter.update('orders-db', {values: { + masterUserPassword: 'short', + }})).rejects.toThrow('8 and 128') + + try { + await adapter.update('orders-db', {values: {masterUserPassword: 'short'}}) + } catch (err) { + expect(String(err)).not.toContain('short') + } + + expect(commands).toHaveLength(0) + }) + + test('rejects update requests with zero effective supported changes', async () => { + const {adapter, commands} = adapterWith(async () => ({})) + + await expect(adapter.update('orders-db', {values: {}})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.update('orders-db', {values: { + masterUserPassword: '', + dbSubnetGroupName: '', + unsupportedField: 'value', + }})).rejects.toBeInstanceOf(ValidationError) + + expect(commands).toHaveLength(0) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts new file mode 100644 index 0000000..2cfd34b --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts @@ -0,0 +1,330 @@ +import {ValidationError} from '../cloud-spi/errors' +import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds' +import {rds as defaultRds} from '../aws' +import {awsDatabaseSchema} from '../cloud-spi/databaseSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateDatabaseSnapshotInput, + CreateResourceInput, + DatabaseSnapshot, + ResourceQuery, + ServiceSchema, + UpdateResourceInput, +} from '../cloud-spi/types' +import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds' + +type RdsServiceShape = Pick< + typeof rdsService, + 'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses' +> + +export class AwsDatabaseAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'database' as const + + constructor( + private readonly rdsService_: RdsServiceShape = rdsService, + private readonly rds: RDSClient = defaultRds, + ) {} + + schema(): ServiceSchema { + return awsDatabaseSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const instances = await this.rdsService_.listInstances() + const resources = await Promise.all(instances.map((instance) => this.toResource(instance))) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + try { + return await this.toResource(await this.rdsService_.describeInstance(id)) + } catch (error) { + if (hasHttpStatus(error, 404)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const values = input.values + const dbInstanceIdentifier = instanceIdentifier(values.dbInstanceIdentifier) + const engine = databaseEngine(values.engine) + const masterUserPassword = password(values.masterUserPassword) + const dbInstanceClass = stringValue(values.dbInstanceClass) || 'db.t3.micro' + const allocatedStorage = storageSize(values.allocatedStorage) + const masterUsername = stringValue(values.masterUsername) || 'root' + const dbName = stringValue(values.dbName) || undefined + const engineVersion = stringValue(values.engineVersion) || undefined + const vpcSecurityGroupIds = securityGroupIds(values.securityGroupIds ?? values.vpcSecurityGroupIds) + + const instance = await this.rdsService_.createInstance({ + DBInstanceIdentifier: dbInstanceIdentifier, + Engine: engine, + MasterUserPassword: masterUserPassword, + DBInstanceClass: dbInstanceClass, + AllocatedStorage: allocatedStorage, + MasterUsername: masterUsername, + DBName: dbName, + EngineVersion: engineVersion, + VpcSecurityGroupIds: vpcSecurityGroupIds, + }) + return this.toResource(instance) + } + + async update(id: string, input: UpdateResourceInput): Promise { + const values = input.values ?? {} + const MasterUserPassword = optionalPassword(values.masterUserPassword) + const EnableIAMDatabaseAuthentication = optionalBoolean( + values.enableIamDatabaseAuthentication, + 'enableIamDatabaseAuthentication', + ) + const DBSubnetGroupName = optionalString(values.dbSubnetGroupName) + const VpcSecurityGroupIds = optionalCsvList(values.vpcSecurityGroupIds) + const OptionGroupName = optionalString(values.optionGroupName) + const AutoMinorVersionUpgrade = optionalBoolean( + values.autoMinorVersionUpgrade, + 'autoMinorVersionUpgrade', + ) + + const hasAnyUpdate = + MasterUserPassword !== undefined || + EnableIAMDatabaseAuthentication !== undefined || + DBSubnetGroupName !== undefined || + VpcSecurityGroupIds !== undefined || + OptionGroupName !== undefined || + AutoMinorVersionUpgrade !== undefined + + if (!hasAnyUpdate) { + throw new ValidationError('At least one supported field must be updated') + } + + const modifyInput: ModifyDBInstanceCommandInput = { + DBInstanceIdentifier: id, + ...(MasterUserPassword !== undefined && {MasterUserPassword}), + ...(EnableIAMDatabaseAuthentication !== undefined && {EnableIAMDatabaseAuthentication}), + ...(DBSubnetGroupName !== undefined && {DBSubnetGroupName}), + ...(VpcSecurityGroupIds !== undefined && {VpcSecurityGroupIds}), + ...(OptionGroupName !== undefined && {OptionGroupName}), + ...(AutoMinorVersionUpgrade !== undefined && {AutoMinorVersionUpgrade}), + } + + const updated = await this.rdsService_.modifyInstance(modifyInput) + return this.toResource(updated) + } + + async delete(id: string): Promise { + await this.rdsService_.deleteInstance(id) + } + + async listDatabaseSnapshots(instanceIdentifier?: string): Promise { + const normalizedIdentifier = stringValue(instanceIdentifier) || undefined + return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot) + } + + async createDatabaseSnapshot(input: CreateDatabaseSnapshotInput): Promise { + const sourceIdentifier = instanceIdentifier(input.instanceIdentifier) + const snapshotIdentifier = databaseSnapshotIdentifier(input.snapshotIdentifier) + return snapshotToDatabaseSnapshot( + await this.rdsService_.createSnapshot(sourceIdentifier, snapshotIdentifier), + ) + } + + async listDatabaseOrderableInstanceClasses(engine?: string): Promise { + const normalizedEngine = stringValue(engine) || 'postgres' + return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine) + } + + private async toResource(instance: RdsInstance): Promise { + const tags = instance.arn ? await this.getTags(instance.arn) : [] + const vpcSecurityGroupIds = (instance.vpcSecurityGroups ?? []) + .map((group) => group.id) + .filter((id): id is string => Boolean(id)) + .join(', ') + + return { + id: instance.identifier, + name: instance.identifier, + cloud: 'aws', + service: 'database', + type: 'db-instance', + region: instance.availabilityZone ?? null, + createdAt: instance.createdAt ?? null, + status: instance.status ?? null, + version: instance.engineVersion ?? null, + engine: instance.engine ?? null, + instanceClass: instance.instanceClass ?? null, + metadata: { + arn: instance.arn, + resourceId: instance.resourceId, + dbName: instance.dbName, + masterUsername: instance.masterUsername, + allocatedStorage: instance.allocatedStorage, + storageType: instance.storageType, + endpoint: instance.endpoint, + multiAz: instance.multiAz, + publiclyAccessible: instance.publiclyAccessible, + iamDatabaseAuthenticationEnabled: instance.iamDatabaseAuthenticationEnabled, + preferredBackupWindow: instance.preferredBackupWindow, + preferredMaintenanceWindow: instance.preferredMaintenanceWindow, + vpcSecurityGroups: instance.vpcSecurityGroups, + vpcSecurityGroupIds, + subnetGroup: instance.subnetGroup, + optionGroupName: instance.optionGroupName, + autoMinorVersionUpgrade: instance.autoMinorVersionUpgrade, + tags, + }, + } + } + + private async getTags(arn: string): Promise> { + try { + const res = await this.rds.send(new ListTagsForResourceCommand({ResourceName: arn})) + return (res.TagList ?? []).map((tag) => ({ + key: tag.Key ?? '', + value: tag.Value ?? '', + })) + } catch (error) { + if (error instanceof Error && error.message.includes('ListTagsForResource is not supported')) return [] + throw error + } + } +} + +const INSTANCE_IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/ +const SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/ + +function instanceIdentifier(value: unknown): string { + const identifier = stringValue(value) + if (!INSTANCE_IDENTIFIER_PATTERN.test(identifier)) { + throw new ValidationError('dbInstanceIdentifier must be 1-63 lowercase letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen') + } + return identifier +} + +function databaseSnapshotIdentifier(value: unknown): string { + const identifier = stringValue(value) + if (!SNAPSHOT_IDENTIFIER_PATTERN.test(identifier)) { + throw new ValidationError('snapshotIdentifier must be 1-255 letters, numbers, or hyphens, begin with a letter, and not end with or repeat a hyphen') + } + return identifier +} + +function databaseEngine(value: unknown): 'postgres' | 'mysql' | 'mariadb' { + const engine = stringValue(value) + if (engine === 'postgres' || engine === 'mysql' || engine === 'mariadb') return engine + throw new ValidationError('engine must be postgres, mysql, or mariadb') +} + +function password(value: unknown): string { + if (typeof value !== 'string' || value.length < 8 || value.length > 128) { + throw new ValidationError('masterUserPassword must be 8-128 characters') + } + return value +} + +function storageSize(value: unknown): number { + if (value === undefined || (typeof value === 'string' && !value.trim())) return 20 + + const normalized = typeof value === 'string' ? value.trim() : value + const hasValidSyntax = typeof normalized === 'number' + || (typeof normalized === 'string' && /^[1-9][0-9]*$/.test(normalized)) + if (!hasValidSyntax) { + throw new ValidationError('allocatedStorage must be a positive integer') + } + + const storage = Number(normalized) + if (!Number.isSafeInteger(storage) || storage <= 0) { + throw new ValidationError('allocatedStorage must be a positive integer') + } + return storage +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function securityGroupIds(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const list = value.map(stringValue).filter(Boolean) + return list.length > 0 ? list : undefined + } + if (typeof value === 'string') { + const list = value.split(',').map((s) => s.trim()).filter(Boolean) + return list.length > 0 ? list : undefined + } + return undefined +} + +function snapshotToDatabaseSnapshot(snapshot: RdsSnapshot): DatabaseSnapshot { + return { + id: snapshot.identifier, + name: snapshot.identifier, + instanceIdentifier: snapshot.instanceIdentifier ?? null, + status: snapshot.status ?? null, + engine: snapshot.engine ?? null, + version: snapshot.engineVersion ?? null, + createdAt: snapshot.createdAt ?? null, + metadata: { + arn: snapshot.arn, + allocatedStorage: snapshot.allocatedStorage, + snapshotType: snapshot.snapshotType, + port: snapshot.port, + availabilityZone: snapshot.availabilityZone, + vpcId: snapshot.vpcId, + }, + } +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function hasHttpStatus(error: unknown, status: number): boolean { + if (typeof error !== 'object' || error === null) return false + const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata + return metadata?.httpStatusCode === status +} + +function optionalPassword(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + const str = String(value) + if (str === '') return undefined + if (str.length < 8 || str.length > 128) { + throw new ValidationError('masterUserPassword must be between 8 and 128 characters') + } + return str +} + +function optionalBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value === undefined || value === null) return undefined + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed === '') return undefined + if (trimmed === 'true') return true + if (trimmed === 'false') return false + } + throw new ValidationError(`${fieldName} must be a boolean (true or false)`) +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + const trimmed = String(value).trim() + return trimmed === '' ? undefined : trimmed +} + +function optionalCsvList(value: unknown): string[] | undefined { + if (value === undefined || value === null) return undefined + if (Array.isArray(value)) { + const items = value.map((v) => String(v).trim()).filter(Boolean) + return items.length > 0 ? items : undefined + } + const str = String(value).trim() + if (!str) return undefined + const items = str.split(',').map((s) => s.trim()).filter(Boolean) + return items.length > 0 ? items : undefined +} diff --git a/packages/api/src/adapter-aws/AwsDynamoDbAdapter.test.ts b/packages/api/src/adapter-aws/AwsDynamoDbAdapter.test.ts new file mode 100644 index 0000000..7ad645b --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDynamoDbAdapter.test.ts @@ -0,0 +1,469 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateTableCommand, + DeleteTableCommand, + DescribeTableCommand, + ListTablesCommand, + PutItemCommand, + ScanCommand, + type DynamoDBClient, +} from '@aws-sdk/client-dynamodb' +import {AwsDynamoDbAdapter} from './AwsDynamoDbAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type Command = CreateTableCommand | DeleteTableCommand | DescribeTableCommand | ListTablesCommand | PutItemCommand | ScanCommand + +function fakeClient(send: (command: Command) => Promise): DynamoDBClient { + return {send} as unknown as DynamoDBClient +} + +describe('AwsDynamoDbAdapter', () => { + test('lists, describes, maps, and filters tables across pages', async () => { + const describedTables: string[] = [] + const client = fakeClient(async (command) => { + if (command instanceof ListTablesCommand) { + return command.input.ExclusiveStartTableName + ? {TableNames: ['audit-events']} + : {TableNames: ['orders'], LastEvaluatedTableName: 'orders'} + } + if (command instanceof DescribeTableCommand) { + describedTables.push(command.input.TableName ?? '') + return {Table: { + TableName: command.input.TableName, + TableStatus: 'ACTIVE', + TableArn: `arn:aws:dynamodb:eu-west-2:000000000000:table/${command.input.TableName}`, + CreationDateTime: new Date('2026-07-18T10:00:00.000Z'), + ItemCount: 3, + KeySchema: [{AttributeName: 'pk', KeyType: 'HASH'}], + }} + } + throw new Error('Unexpected command') + }) + + const result = await new AwsDynamoDbAdapter(client).list({search: 'audit'}) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + id: 'audit-events', + name: 'audit-events', + cloud: 'aws', + service: 'nosql', + type: 'dynamodb-table', + region: 'eu-west-2', + status: 'ACTIVE', + createdAt: '2026-07-18T10:00:00.000Z', + }) + expect(result[0].metadata.itemCount).toBe(3) + expect(describedTables).toEqual(['audit-events']) + }) + + test('describes tables in bounded batches', async () => { + let activeDescriptions = 0 + let maximumActiveDescriptions = 0 + const tableNames = Array.from({length: 21}, (_, index) => `table-${index}`) + const client = fakeClient(async (command) => { + if (command instanceof ListTablesCommand) return {TableNames: tableNames} + if (command instanceof DescribeTableCommand) { + activeDescriptions += 1 + maximumActiveDescriptions = Math.max(maximumActiveDescriptions, activeDescriptions) + await Promise.resolve() + activeDescriptions -= 1 + return {Table: {TableName: command.input.TableName}} + } + throw new Error('Unexpected command') + }) + + const result = await new AwsDynamoDbAdapter(client).list() + + expect(result).toHaveLength(21) + expect(maximumActiveDescriptions).toBe(10) + }) + + test('gets a table description', async () => { + const client = fakeClient(async (command) => { + expect(command).toBeInstanceOf(DescribeTableCommand) + return {Table: {TableName: 'orders', TableStatus: 'ACTIVE'}} + }) + + const result = await new AwsDynamoDbAdapter(client).get('orders') + + expect(result?.name).toBe('orders') + }) + + test('returns null when a table does not exist', async () => { + const client = fakeClient(async () => { + throw Object.assign(new Error('Requested resource not found'), {name: 'ResourceNotFoundException'}) + }) + + await expect(new AwsDynamoDbAdapter(client).get('missing')).resolves.toBeNull() + }) + + test('creates a table with partition and sort keys', async () => { + let captured: CreateTableCommand | undefined + const client = fakeClient(async (command) => { + expect(command).toBeInstanceOf(CreateTableCommand) + captured = command as CreateTableCommand + return {TableDescription: {TableName: 'orders', TableStatus: 'ACTIVE'}} + }) + + const result = await new AwsDynamoDbAdapter(client).create({values: { + tableName: 'orders', + partitionKey: 'accountId', + partitionKeyType: 'S', + sortKey: 'createdAt', + sortKeyType: 'N', + }}) + + expect(captured?.input).toEqual({ + TableName: 'orders', + AttributeDefinitions: [ + {AttributeName: 'accountId', AttributeType: 'S'}, + {AttributeName: 'createdAt', AttributeType: 'N'}, + ], + KeySchema: [ + {AttributeName: 'accountId', KeyType: 'HASH'}, + {AttributeName: 'createdAt', KeyType: 'RANGE'}, + ], + BillingMode: 'PAY_PER_REQUEST', + }) + expect(result).toMatchObject({name: 'orders', type: 'dynamodb-table'}) + }) + + test('reports no status when create returns no table description', async () => { + const client = fakeClient(async () => ({})) + + const result = await new AwsDynamoDbAdapter(client).create({values: { + tableName: 'orders', + partitionKey: 'accountId', + partitionKeyType: 'S', + }}) + + expect(result).toMatchObject({name: 'orders', status: null}) + }) + + test('defaults an optional sort key type to string', async () => { + let captured: CreateTableCommand | undefined + const client = fakeClient(async (command) => { + captured = command as CreateTableCommand + return {TableDescription: {TableName: 'orders'}} + }) + + await new AwsDynamoDbAdapter(client).create({values: { + tableName: 'orders', + partitionKey: 'pk', + partitionKeyType: 'S', + sortKey: 'sk', + }}) + + expect(captured?.input.AttributeDefinitions?.[1]?.AttributeType).toBe('S') + }) + + test('validates create input before calling DynamoDB', async () => { + let called = false + const client = fakeClient(async () => { + called = true + return {} + }) + const adapter = new AwsDynamoDbAdapter(client) + + await expect(adapter.create({values: { + tableName: 'x', + partitionKey: 'pk', + partitionKeyType: 'S', + }})).rejects.toThrow('Use a valid DynamoDB table name') + await expect(adapter.create({values: { + tableName: 'orders', + partitionKey: 'pk', + partitionKeyType: 'S', + sortKey: 'pk', + }})).rejects.toThrow('sortKey must differ from partitionKey') + expect(called).toBe(false) + }) + + test('deletes a table', async () => { + let captured: DeleteTableCommand | undefined + const client = fakeClient(async (command) => { + captured = command as DeleteTableCommand + return {} + }) + + await new AwsDynamoDbAdapter(client).delete('orders') + + expect(captured?.input.TableName).toBe('orders') + }) + + test('lists and unmarshalls table records across scan pages', async () => { + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: {KeySchema: [ + {AttributeName: 'accountId', KeyType: 'HASH'}, + {AttributeName: 'createdAt', KeyType: 'RANGE'}, + ]}} + } + if (command instanceof ScanCommand) { + expect(command.input.Limit).toBe(command.input.ExclusiveStartKey ? 99 : 100) + return command.input.ExclusiveStartKey + ? {Items: [{accountId: {S: 'acct-2'}, createdAt: {N: '9007199254740993'}, active: {BOOL: false}, roles: {SS: ['admin', 'editor']}}]} + : { + Items: [{accountId: {S: 'acct-1'}, createdAt: {N: '1'}, active: {BOOL: true}}], + LastEvaluatedKey: {accountId: {S: 'acct-1'}, createdAt: {N: '1'}}, + } + } + throw new Error('Unexpected command') + }) + + const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders') + + expect(result).toEqual([ + { + id: '{"accountId":"acct-1","createdAt":1}', + key: {accountId: 'acct-1', createdAt: 1}, + document: {accountId: 'acct-1', createdAt: 1, active: true}, + }, + { + id: '{"accountId":"acct-2","createdAt":"9007199254740993"}', + key: {accountId: 'acct-2', createdAt: '9007199254740993'}, + document: {accountId: 'acct-2', createdAt: '9007199254740993', active: false, roles: ['admin', 'editor']}, + }, + ]) + }) + + test('stops scanning after 100 records', async () => { + let scanCount = 0 + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: {KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}]}} + } + if (command instanceof ScanCommand) { + scanCount += 1 + expect(command.input.Limit).toBe(100) + return { + Items: Array.from({length: 100}, (_, index) => ({id: {N: String(index)}})), + LastEvaluatedKey: {id: {N: '99'}}, + } + } + throw new Error('Unexpected command') + }) + + const result = await new AwsDynamoDbAdapter(client).listNoSqlItems('orders') + + expect(result).toHaveLength(100) + expect(scanCount).toBe(1) + }) + + test('puts a record using the table key types', async () => { + let captured: PutItemCommand | undefined + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [ + {AttributeName: 'accountId', KeyType: 'HASH'}, + {AttributeName: 'sequence', KeyType: 'RANGE'}, + ], + AttributeDefinitions: [ + {AttributeName: 'accountId', AttributeType: 'S'}, + {AttributeName: 'sequence', AttributeType: 'N'}, + ], + }} + } + if (command instanceof PutItemCommand) { + captured = command + return {} + } + throw new Error('Unexpected command') + }) + + const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', { + accountId: 'acct-1', + sequence: '9007199254740993', + active: true, + }) + + expect(captured?.input).toEqual({ + TableName: 'orders', + Item: { + accountId: {S: 'acct-1'}, + sequence: {N: '9007199254740993'}, + active: {BOOL: true}, + }, + }) + expect(result).toEqual({ + id: '{"accountId":"acct-1","sequence":"9007199254740993"}', + key: {accountId: 'acct-1', sequence: '9007199254740993'}, + document: {accountId: 'acct-1', sequence: '9007199254740993', active: true}, + }) + }) + + test('accepts base64 for binary keys', async () => { + let captured: PutItemCommand | undefined + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], + AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'B'}], + }} + } + captured = command as PutItemCommand + return {} + }) + + const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('blobs', {id: 'aGVsbG8=', name: 'hello'}) + + expect(captured?.input.Item?.id.B).toEqual(Buffer.from('hello')) + expect(result.key).toEqual({id: 'aGVsbG8='}) + }) + + test('validates required and typed keys before putting a record', async () => { + let putCalls = 0 + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], + AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'N'}], + }} + } + putCalls += 1 + return {} + }) + const adapter = new AwsDynamoDbAdapter(client) + + await expect(adapter.putNoSqlItem('orders', {})).rejects.toThrow('Key attribute id is required') + await expect(adapter.putNoSqlItem('orders', {id: 'not-a-number'})).rejects.toThrow('Key attribute id must be a number') + for (const json of ['1', '1.5', '9007199254740993', '123456789.123456789', '1.0000000000000000001']) { + await expect(adapter.putNoSqlItem('orders', {id: JSON.parse(json)})) + .rejects.toThrow('must be a quoted number to preserve precision') + } + expect(putCalls).toBe(0) + }) + + test.each(['123456789.123456789', '1.0000000000000000001'])('preserves the quoted numeric key %s when writing and reading', async (id) => { + let captured: PutItemCommand | undefined + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], + AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'N'}], + }} + } + if (command instanceof ScanCommand) return {Items: [{id: {N: id}}]} + captured = command as PutItemCommand + return {} + }) + const adapter = new AwsDynamoDbAdapter(client) + + const created = await adapter.putNoSqlItem('orders', {id}) + const listed = await adapter.listNoSqlItems('orders') + + expect(captured?.input.Item?.id).toEqual({N: id}) + expect(created).toEqual({id: JSON.stringify({id}), key: {id}, document: {id}}) + expect(listed).toEqual([created]) + }) + + test.each([ + ['top-level attribute', '{"id":"order-1","count":9007199254740993}', '$["count"]'], + ['nested object', '{"id":"order-1","details":{"count":-9007199254740993}}', '$["details"]["count"]'], + ['array value', '{"id":"order-1","counts":[0,9007199254740993]}', '$["counts"][1]'], + ['object inside an array', '{"id":"order-1","entries":[{"count":9007199254740993}]}', '$["entries"][0]["count"]'], + ])('rejects unsafe integers (%s) before putting a record', async (_name, json, path) => { + let putCalls = 0 + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], + AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}], + }} + } + putCalls += 1 + return {} + }) + + await expect(new AwsDynamoDbAdapter(client).putNoSqlItem('orders', JSON.parse(json))) + .rejects.toThrow(new ValidationError(`Item value at ${path} must quote integers outside JavaScript's safe range.`)) + expect(putCalls).toBe(0) + }) + + test.each([ + ['1.0', '1', 1], + ['1e3', '1000', 1000], + ['1000', '1E+3', 1000], + ['+0001.5000', '1.5', 1.5], + ['-0.00e10', '0', 0], + ['.00000100', '1E-6', 0.000001], + ['0.0000001', '1E-7', 1e-7], + ['9007199254740993.00', '9.007199254740993E15', '9007199254740993'], + ['-9007199254740993e0', '-9007199254740993', '-9007199254740993'], + ['1.000000000000000000100', '10000000000000000001E-19', '1.0000000000000000001'], + ['123456789.1234567890', '1.23456789123456789E8', '123456789.123456789'], + ['10e-131', '1E-130', 1e-130], + ['1e125', '10E124', '1e125'], + ] as const)('keeps create and refreshed IDs equal for %s and %s', async (input, stored, normalized) => { + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [ + {AttributeName: 'accountId', KeyType: 'HASH'}, + {AttributeName: 'sequence', KeyType: 'RANGE'}, + ], + AttributeDefinitions: [ + {AttributeName: 'accountId', AttributeType: 'S'}, + {AttributeName: 'sequence', AttributeType: 'N'}, + ], + }} + } + if (command instanceof PutItemCommand) { + expect(command.input.Item?.sequence).toEqual({N: input}) + return {} + } + if (command instanceof ScanCommand) { + return {Items: [{accountId: {S: 'acct-1'}, sequence: {N: stored}}]} + } + throw new Error('Unexpected command') + }) + const adapter = new AwsDynamoDbAdapter(client) + + const created = await adapter.putNoSqlItem('orders', {accountId: 'acct-1', sequence: input}) + const listed = await adapter.listNoSqlItems('orders') + const key = {accountId: 'acct-1', sequence: normalized} + + expect(created).toEqual({id: JSON.stringify(key), key, document: key}) + expect(listed).toEqual([created]) + }) + + test('preserves safe integers, fractions, and quoted large values in nested records', async () => { + let captured: PutItemCommand | undefined + const client = fakeClient(async (command) => { + if (command instanceof DescribeTableCommand) { + return {Table: { + KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], + AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}], + }} + } + captured = command as PutItemCommand + return {} + }) + const document = { + id: 'order-1', + values: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0, 1.5], + details: {count: '9007199254740993', active: true, optional: null}, + } + + const result = await new AwsDynamoDbAdapter(client).putNoSqlItem('orders', document) + + expect(captured?.input.Item?.values).toEqual({L: [ + {N: '-9007199254740991'}, {N: '9007199254740991'}, {N: '0'}, {N: '1.5'}, + ]}) + expect(captured?.input.Item?.details.M?.count).toEqual({S: '9007199254740993'}) + expect(result.document).toEqual(document) + }) + + test('returns the AWS DynamoDB schema', () => { + const adapter = new AwsDynamoDbAdapter(fakeClient(async () => ({}))) + + expect(adapter.schema()).toMatchObject({ + cloud: 'aws', + service: 'nosql', + displayName: 'DynamoDB', + actions: ['list', 'create', 'delete', 'inspect'], + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsDynamoDbAdapter.ts b/packages/api/src/adapter-aws/AwsDynamoDbAdapter.ts new file mode 100644 index 0000000..6b9d26a --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDynamoDbAdapter.ts @@ -0,0 +1,359 @@ +import { + CreateTableCommand, + DeleteTableCommand, + DescribeTableCommand, + ListTablesCommand, + PutItemCommand, + ScanCommand, + type AttributeDefinition, + type AttributeValue, + type DynamoDBClient, + type KeySchemaElement, + type ScalarAttributeType, + type TableDescription, +} from '@aws-sdk/client-dynamodb' +import {marshall, unmarshall} from '@aws-sdk/util-dynamodb' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {dynamodb as defaultDynamoDb} from '../aws' +import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + NoSqlItem, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +const DESCRIBE_BATCH_SIZE = 10 +const ITEM_SCAN_LIMIT = 100 + +export class AwsDynamoDbAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'nosql' as const + + constructor(private readonly dynamodb: DynamoDBClient = defaultDynamoDb) {} + + schema(): ServiceSchema { + return awsDynamoDbSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const tableNames = filterNamesBySearch(await this.listTableNames(), query.search) + const resources: CloudResource[] = [] + + for (let index = 0; index < tableNames.length; index += DESCRIBE_BATCH_SIZE) { + const batch = tableNames.slice(index, index + DESCRIBE_BATCH_SIZE) + resources.push(...await Promise.all(batch.map((tableName) => this.describe(tableName)))) + } + + return resources + } + + async get(id: string): Promise { + try { + return await this.describe(id) + } catch (error) { + if (isResourceNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const tableName = requiredString(input.values.tableName, 'tableName') + if (!isValidTableName(tableName)) { + throw new ValidationError('Use a valid DynamoDB table name: 3-255 letters, numbers, underscores, dots, or hyphens.') + } + + const partitionKey = requiredString(input.values.partitionKey, 'partitionKey') + const partitionKeyType = attributeType(input.values.partitionKeyType, 'partitionKeyType') + const sortKey = stringValue(input.values.sortKey) + if (sortKey === partitionKey) throw new ValidationError('sortKey must differ from partitionKey') + + const attributeDefinitions: AttributeDefinition[] = [ + {AttributeName: partitionKey, AttributeType: partitionKeyType}, + ] + const keySchema: KeySchemaElement[] = [ + {AttributeName: partitionKey, KeyType: 'HASH'}, + ] + + if (sortKey) { + attributeDefinitions.push({ + AttributeName: sortKey, + AttributeType: attributeType(input.values.sortKeyType || 'S', 'sortKeyType'), + }) + keySchema.push({AttributeName: sortKey, KeyType: 'RANGE'}) + } + + const response = await this.dynamodb.send(new CreateTableCommand({ + TableName: tableName, + AttributeDefinitions: attributeDefinitions, + KeySchema: keySchema, + BillingMode: 'PAY_PER_REQUEST', + })) + + return toResource(response.TableDescription ?? { + TableName: tableName, + AttributeDefinitions: attributeDefinitions, + KeySchema: keySchema, + BillingModeSummary: {BillingMode: 'PAY_PER_REQUEST'}, + }) + } + + async delete(id: string): Promise { + await this.dynamodb.send(new DeleteTableCommand({TableName: id})) + } + + async listNoSqlItems(resourceId: string): Promise { + const table = await this.describeTable(resourceId) + const keyNames = keyAttributes(table).map(({name}) => name) + const items: NoSqlItem[] = [] + let exclusiveStartKey: Record | undefined + + do { + const response = await this.dynamodb.send(new ScanCommand({ + TableName: resourceId, + ExclusiveStartKey: exclusiveStartKey, + Limit: ITEM_SCAN_LIMIT - items.length, + })) + for (const rawItem of response.Items ?? []) { + const document = normalizeDocument(unmarshall(rawItem, {wrapNumbers: normalizeNumber})) + const key = Object.fromEntries(keyNames.map((name) => [name, document[name]])) + items.push({id: JSON.stringify(key), key, document}) + if (items.length === ITEM_SCAN_LIMIT) break + } + exclusiveStartKey = response.LastEvaluatedKey + } while (items.length < ITEM_SCAN_LIMIT && exclusiveStartKey && Object.keys(exclusiveStartKey).length > 0) + + return items + } + + async putNoSqlItem(resourceId: string, document: Record): Promise { + if (!document || typeof document !== 'object' || Array.isArray(document)) { + throw new ValidationError('Item must be a JSON object.') + } + + const table = await this.describeTable(resourceId) + const keys = keyAttributes(table) + if (keys.length === 0) throw new RuntimeError(`DynamoDB table ${resourceId} has no key schema`) + const untypedKey = keys.find(({type}) => !type) + if (untypedKey) throw new RuntimeError(`DynamoDB table ${resourceId} has no type definition for key ${untypedKey.name}`) + + const item = marshalDocument(document, keys) + await this.dynamodb.send(new PutItemCommand({TableName: resourceId, Item: item})) + + const normalizedDocument = normalizeDocument(unmarshall(item, {wrapNumbers: normalizeNumber})) + const key = Object.fromEntries(keys.map(({name}) => [name, normalizedDocument[name]])) + return {id: JSON.stringify(key), key, document: normalizedDocument} + } + + private async listTableNames(): Promise { + const tableNames: string[] = [] + let exclusiveStartTableName: string | undefined + + do { + const response = await this.dynamodb.send(new ListTablesCommand({ + ExclusiveStartTableName: exclusiveStartTableName, + })) + tableNames.push(...(response.TableNames ?? [])) + exclusiveStartTableName = response.LastEvaluatedTableName + } while (exclusiveStartTableName) + + return tableNames + } + + private async describe(tableName: string): Promise { + return toResource(await this.describeTable(tableName)) + } + + private async describeTable(tableName: string): Promise { + const response = await this.dynamodb.send(new DescribeTableCommand({TableName: tableName})) + if (!response.Table) throw new RuntimeError(`DynamoDB did not return a description for table ${tableName}`) + return response.Table + } +} + +interface KeyAttribute { + name: string + type?: ScalarAttributeType +} + +function keyAttributes(table: TableDescription): KeyAttribute[] { + const attributeTypes = new Map( + (table.AttributeDefinitions ?? []) + .filter((definition): definition is AttributeDefinition & {AttributeName: string} => Boolean(definition.AttributeName)) + .map((definition) => [definition.AttributeName, definition.AttributeType]), + ) + return (table.KeySchema ?? []) + .filter((key): key is KeySchemaElement & {AttributeName: string} => Boolean(key.AttributeName)) + .map((key) => ({name: key.AttributeName, type: attributeTypes.get(key.AttributeName)})) +} + +function marshalDocument(document: Record, keys: KeyAttribute[]): Record { + for (const {name} of keys) { + if (!Object.prototype.hasOwnProperty.call(document, name)) { + throw new ValidationError(`Key attribute ${name} is required.`) + } + } + const marshalledKeys = Object.fromEntries( + keys.map((key) => [key.name, marshalKey(key, document[key.name])]), + ) + validateSafeIntegers(document) + + let item: Record + try { + item = marshall(document) + } catch (error) { + throw new ValidationError(error instanceof Error ? error.message : 'Item contains a value DynamoDB cannot store.', {cause: error}) + } + + return {...item, ...marshalledKeys} +} + +function validateSafeIntegers(value: unknown, path = '$'): void { + if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new ValidationError(`Item value at ${path} must quote integers outside JavaScript's safe range.`) + } + if (Array.isArray(value)) { + value.forEach((item, index) => validateSafeIntegers(item, `${path}[${index}]`)) + } else if (typeof value === 'object' && value !== null) { + for (const [name, item] of Object.entries(value)) { + validateSafeIntegers(item, `${path}[${JSON.stringify(name)}]`) + } + } +} + +function marshalKey(key: KeyAttribute, value: unknown): AttributeValue { + if (key.type === 'N') { + if (typeof value === 'number') { + throw new ValidationError(`Key attribute ${key.name} must be a quoted number to preserve precision.`) + } + const number = typeof value === 'string' ? value : '' + if (!isDynamoNumber(number)) throw new ValidationError(`Key attribute ${key.name} must be a number.`) + return {N: number} + } + if (key.type === 'B') { + if (typeof value !== 'string' || !isBase64(value)) { + throw new ValidationError(`Binary key attribute ${key.name} must be a non-empty base64 string.`) + } + return {B: Buffer.from(value, 'base64')} + } + if (typeof value !== 'string' || !value) throw new ValidationError(`Key attribute ${key.name} must be a non-empty string.`) + return {S: value} +} + +function isDynamoNumber(value: string): boolean { + return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?$/.test(value) +} + +function isBase64(value: string): boolean { + return value.length > 0 && value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) +} + +function normalizeDocument(document: Record): Record { + return Object.fromEntries( + Object.entries(document).map(([name, value]) => [name, normalizeValue(value)]), + ) +} + +function normalizeValue(value: unknown): unknown { + if (typeof value === 'bigint') return value.toString() + if (value instanceof Uint8Array) return Buffer.from(value).toString('base64') + if (value instanceof Set) return Array.from(value, normalizeValue) + if (Array.isArray(value)) return value.map(normalizeValue) + if (typeof value === 'object' && value !== null) return normalizeDocument(value as Record) + return value +} + +// Compare exact decimals so DynamoDB's formatting changes cannot change item IDs. +function normalizeNumber(value: string): number | string { + const canonical = canonicalDecimal(value) + const parsed = Number(canonical) + if (!Number.isFinite(parsed) || (Number.isInteger(parsed) && !Number.isSafeInteger(parsed))) return canonical + return canonicalDecimal(String(parsed)) === canonical ? parsed : canonical +} + +function canonicalDecimal(value: string): string { + const [coefficient, exponent = '0'] = value.toLowerCase().split('e') + const sign = coefficient.startsWith('-') ? '-' : '' + const [integer, fraction = ''] = coefficient.replace(/^[+-]/, '').split('.') + const digits = (integer + fraction).replace(/^0+/, '') + if (!digits) return '0' + + const point = BigInt(digits.length - fraction.length) + BigInt(exponent) + const significant = digits.replace(/0+$/, '') + if (point <= -6n || point > 21n) { + const tail = significant.slice(1) + const mantissa = significant[0] + (tail ? `.${tail}` : '') + return `${sign}${mantissa}e${point - 1n}` + } + + const position = Number(point) + if (position <= 0) return `${sign}0.${'0'.repeat(-position)}${significant}` + if (position >= significant.length) return sign + significant.padEnd(position, '0') + return `${sign}${significant.slice(0, position)}.${significant.slice(position)}` +} + +function toResource(table: TableDescription): CloudResource { + const name = table.TableName ?? '' + return { + id: name, + name, + cloud: 'aws', + service: 'nosql', + type: 'dynamodb-table', + region: regionFromArn(table.TableArn), + createdAt: table.CreationDateTime?.toISOString() ?? null, + status: table.TableStatus ?? null, + metadata: { + arn: table.TableArn, + tableId: table.TableId, + itemCount: table.ItemCount, + tableSizeBytes: table.TableSizeBytes, + billingMode: table.BillingModeSummary?.BillingMode, + keySchema: table.KeySchema, + attributeDefinitions: table.AttributeDefinitions, + provisionedThroughput: table.ProvisionedThroughput, + globalSecondaryIndexes: table.GlobalSecondaryIndexes, + localSecondaryIndexes: table.LocalSecondaryIndexes, + streamSpecification: table.StreamSpecification, + deletionProtectionEnabled: table.DeletionProtectionEnabled, + }, + } +} + +function requiredString(value: unknown, field: string): string { + const result = stringValue(value) + if (!result) throw new ValidationError(`${field} is required`) + return result +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function attributeType(value: unknown, field: string): ScalarAttributeType { + const result = stringValue(value) + if (result === 'S' || result === 'N' || result === 'B') return result + throw new ValidationError(`${field} must be S, N, or B`) +} + +function filterNamesBySearch(names: string[], search?: string): string[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return names + return names.filter((name) => name.toLowerCase().includes(normalized)) +} + +function regionFromArn(arn?: string): string | null { + return arn?.split(':')[3] || null +} + +function isValidTableName(value: string): boolean { + return /^[A-Za-z0-9_.-]{3,255}$/.test(value) +} + +function isResourceNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const candidate = error as {name?: string; $metadata?: {httpStatusCode?: number}} + return candidate.name === 'ResourceNotFoundException' || candidate.$metadata?.httpStatusCode === 404 +} diff --git a/packages/api/src/adapter-aws/AwsEksAdapter.test.ts b/packages/api/src/adapter-aws/AwsEksAdapter.test.ts new file mode 100644 index 0000000..8d4debb --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEksAdapter.test.ts @@ -0,0 +1,67 @@ +import {describe, expect, test} from 'bun:test' +import {AwsEksAdapter} from './AwsEksAdapter' + +function fakeEksService() { + return { + listClusters: async () => [], + describeCluster: async () => ({name: 'cluster-a', tags: {}}), + listNodegroups: async (clusterName: string) => [{ + name: 'workers', clusterName, arn: 'arn:aws:eks:us-east-1:000000000000:nodegroup/cluster-a/workers/id', + status: 'ACTIVE', version: '1.30', releaseVersion: '1.30.0-20260801', + instanceTypes: ['t3.medium'], subnets: ['subnet-a'], nodeRole: 'arn:aws:iam::000000000000:role/workers', + scalingConfig: {minSize: 1, desiredSize: 2, maxSize: 3}, labels: {workload: 'api'}, tags: {team: 'platform'}, + }], + createNodegroup: async (clusterName: string, input: unknown) => ({ + name: (input as {name: string}).name, clusterName, instanceTypes: [], subnets: [], labels: {}, tags: {}, + }), + deleteNodegroup: async () => ({name: 'workers', clusterName: 'cluster-a', instanceTypes: [], subnets: [], labels: {}, tags: {}}), + listFargateProfiles: async (clusterName: string) => [{ + name: 'default', clusterName, arn: 'arn:aws:eks:us-east-1:000000000000:fargateprofile/cluster-a/default/id', + status: 'ACTIVE', podExecutionRoleArn: 'arn:aws:iam::000000000000:role/fargate', subnets: ['subnet-a'], + selectors: [{namespace: 'default', labels: {app: 'api'}}], tags: {team: 'platform'}, + }], + createFargateProfile: async (clusterName: string, input: unknown) => ({ + name: (input as {name: string}).name, clusterName, subnets: [], selectors: [], tags: {}, + }), + deleteFargateProfile: async () => ({name: 'default', clusterName: 'cluster-a', subnets: [], selectors: [], tags: {}}), + } +} + +describe('AwsEksAdapter', () => { + test('maps EKS nodegroups through the provider-neutral contract', async () => { + const adapter = new AwsEksAdapter(fakeEksService()) + + await expect(adapter.listKubernetesNodegroups('cluster-a')).resolves.toEqual([expect.objectContaining({ + id: 'workers', + clusterId: 'cluster-a', + capacityType: null, + scalingConfig: {minSize: 1, desiredSize: 2, maxSize: 3}, + })]) + }) + + test('validates nodegroup input before calling the runtime', async () => { + const adapter = new AwsEksAdapter(fakeEksService()) + + await expect(adapter.createKubernetesNodegroup('cluster-a', { + name: 'workers', nodeRole: '', subnets: ['subnet-a'], + })).rejects.toThrow('Node role ARN is required') + }) + + test('maps EKS Fargate profiles through the provider-neutral contract', async () => { + const adapter = new AwsEksAdapter(fakeEksService()) + + await expect(adapter.listKubernetesFargateProfiles('cluster-a')).resolves.toEqual([expect.objectContaining({ + id: 'default', + clusterId: 'cluster-a', + selectors: [{namespace: 'default', labels: {app: 'api'}}], + })]) + }) + + test('validates that a Fargate profile has a selector namespace', async () => { + const adapter = new AwsEksAdapter(fakeEksService()) + + await expect(adapter.createKubernetesFargateProfile('cluster-a', { + name: 'default', podExecutionRoleArn: 'arn:aws:iam::000000000000:role/fargate', selectors: [], + })).rejects.toThrow('At least one selector namespace is required') + }) +}) diff --git a/packages/api/src/adapter-aws/AwsEksAdapter.ts b/packages/api/src/adapter-aws/AwsEksAdapter.ts new file mode 100644 index 0000000..ee6ad16 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEksAdapter.ts @@ -0,0 +1,183 @@ +import {NotSupportedError} from '../cloud-spi/errors' +import {awsEksSchema} from '../cloud-spi/eksSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateKubernetesFargateProfileInput, + CreateKubernetesNodegroupInput, + CreateResourceInput, + KubernetesFargateProfile, + KubernetesNodegroup, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' +import {ValidationError} from '../cloud-spi/errors' +import {eksService, type EksCluster, type EksFargateProfile, type EksNodegroup} from '../services/eks' + +type EksServiceShape = Pick< + typeof eksService, + | 'listClusters' + | 'describeCluster' + | 'listNodegroups' + | 'createNodegroup' + | 'deleteNodegroup' + | 'listFargateProfiles' + | 'createFargateProfile' + | 'deleteFargateProfile' +> + +export class AwsEksAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'k8s' as const + + constructor(private readonly eks: EksServiceShape = eksService) {} + + schema(): ServiceSchema { + return awsEksSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const clusters = await this.eks.listClusters() + return filterBySearch(clusters.map(toResource), query.search) + } + + async get(id: string): Promise { + try { + return toResource(await this.eks.describeCluster(id)) + } catch (error) { + if (hasHttpStatus(error, 404)) return null + throw error + } + } + + async create(_input: CreateResourceInput): Promise { + throw new NotSupportedError('EKS cluster creation is not supported from the dynamic Cloud Explorer.') + } + + async delete(_id: string): Promise { + throw new NotSupportedError('EKS cluster deletion is not supported from the dynamic Cloud Explorer.') + } + + async listKubernetesNodegroups(clusterId: string): Promise { + return (await this.eks.listNodegroups(clusterId)).map(toNodegroup) + } + + async createKubernetesNodegroup( + clusterId: string, + input: CreateKubernetesNodegroupInput, + ): Promise { + validateNodegroupInput(input) + return toNodegroup(await this.eks.createNodegroup(clusterId, input)) + } + + async deleteKubernetesNodegroup(clusterId: string, nodegroupId: string): Promise { + await this.eks.deleteNodegroup(clusterId, nodegroupId) + } + + async listKubernetesFargateProfiles(clusterId: string): Promise { + return (await this.eks.listFargateProfiles(clusterId)).map(toFargateProfile) + } + + async createKubernetesFargateProfile( + clusterId: string, + input: CreateKubernetesFargateProfileInput, + ): Promise { + validateFargateProfileInput(input) + return toFargateProfile(await this.eks.createFargateProfile(clusterId, input)) + } + + async deleteKubernetesFargateProfile(clusterId: string, profileId: string): Promise { + await this.eks.deleteFargateProfile(clusterId, profileId) + } +} + +function toResource(cluster: EksCluster): CloudResource { + return { + id: cluster.name, + name: cluster.name, + cloud: 'aws', + service: 'k8s', + type: 'cluster', + region: null, + createdAt: cluster.createdAt ?? null, + status: cluster.status ?? null, + version: cluster.version ?? null, + metadata: { + arn: cluster.arn, + endpoint: cluster.endpoint, + roleArn: cluster.roleArn, + platformVersion: cluster.platformVersion, + nodegroupCount: cluster.nodegroupCount ?? 0, + fargateProfileCount: cluster.fargateProfileCount ?? 0, + resourcesVpcConfig: cluster.resourcesVpcConfig, + tags: Object.entries(cluster.tags).map(([key, value]) => ({key, value})), + }, + } +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function hasHttpStatus(error: unknown, status: number): boolean { + if (typeof error !== 'object' || error === null) return false + const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata + return metadata?.httpStatusCode === status +} + +function toNodegroup(nodegroup: EksNodegroup): KubernetesNodegroup { + return { + id: nodegroup.name, + name: nodegroup.name, + clusterId: nodegroup.clusterName, + arn: nodegroup.arn ?? null, + status: nodegroup.status ?? null, + version: nodegroup.version ?? null, + releaseVersion: nodegroup.releaseVersion ?? null, + createdAt: nodegroup.createdAt ?? null, + modifiedAt: nodegroup.modifiedAt ?? null, + capacityType: nodegroup.capacityType ?? null, + instanceTypes: nodegroup.instanceTypes, + subnets: nodegroup.subnets, + nodeRole: nodegroup.nodeRole ?? null, + scalingConfig: nodegroup.scalingConfig ?? null, + labels: nodegroup.labels, + tags: nodegroup.tags, + } +} + +function toFargateProfile(profile: EksFargateProfile): KubernetesFargateProfile { + return { + id: profile.name, + name: profile.name, + clusterId: profile.clusterName, + arn: profile.arn ?? null, + status: profile.status ?? null, + createdAt: profile.createdAt ?? null, + podExecutionRoleArn: profile.podExecutionRoleArn ?? null, + subnets: profile.subnets, + selectors: profile.selectors.map((selector) => ({ + namespace: selector.namespace ?? null, + labels: selector.labels, + })), + tags: profile.tags, + } +} + +function validateNodegroupInput(input: CreateKubernetesNodegroupInput): void { + if (typeof input.name !== 'string' || !input.name.trim()) throw new ValidationError('Nodegroup name is required') + if (typeof input.nodeRole !== 'string' || !input.nodeRole.trim()) throw new ValidationError('Node role ARN is required') + if (!Array.isArray(input.subnets) || input.subnets.length === 0 || input.subnets.some((subnet) => typeof subnet !== 'string' || !subnet.trim())) { + throw new ValidationError('At least one subnet is required') + } +} + +function validateFargateProfileInput(input: CreateKubernetesFargateProfileInput): void { + if (typeof input.name !== 'string' || !input.name.trim()) throw new ValidationError('Fargate profile name is required') + if (typeof input.podExecutionRoleArn !== 'string' || !input.podExecutionRoleArn.trim()) throw new ValidationError('Pod execution role ARN is required') + if (!Array.isArray(input.selectors) || input.selectors.length === 0 || input.selectors.some((selector) => typeof selector.namespace !== 'string' || !selector.namespace.trim())) { + throw new ValidationError('At least one selector namespace is required') + } +} diff --git a/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts new file mode 100644 index 0000000..f82c296 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.test.ts @@ -0,0 +1,81 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateEventBusCommand, + DeleteEventBusCommand, + DescribeEventBusCommand, + ListEventBusesCommand, + type EventBridgeClient, +} from '@aws-sdk/client-eventbridge' +import {AwsEventBridgeAdapter} from './AwsEventBridgeAdapter' + +type SendHandler = (command: unknown) => Promise + +describe('AwsEventBridgeAdapter', () => { + test('identifies EventBridge through the separate events category', () => { + const adapter = createAdapter() + + expect(adapter.schema()).toMatchObject({cloud: 'aws', service: 'events', displayName: 'EventBridge'}) + expect(adapter.schema().actions).toEqual(['list', 'create', 'delete', 'inspect']) + expect(adapter.descriptorOverride()).toEqual({displayName: 'EventBridge'}) + }) + + test('lists every EventBridge page and filters by name', async () => { + const tokens: Array = [] + const adapter = createAdapter(async (command) => { + expect(command).toBeInstanceOf(ListEventBusesCommand) + const token = (command as ListEventBusesCommand).input.NextToken + tokens.push(token) + return token + ? {EventBuses: [{Name: 'billing'}]} + : {EventBuses: [{Name: 'orders', Arn: 'arn:aws:events:us-east-1:000000000000:event-bus/orders'}], NextToken: 'next'} + }) + + expect(tokens).toEqual([]) + await expect(adapter.list({search: 'bill'})).resolves.toMatchObject([{name: 'billing', type: 'event-bus'}]) + expect(tokens).toEqual([undefined, 'next']) + }) + + test('inspects an event bus and returns null when it does not exist', async () => { + const adapter = createAdapter(async (command) => { + if (command instanceof DescribeEventBusCommand) { + expect(command.input.Name).toBe('orders') + return {Name: 'orders', Arn: 'arn:aws:events:eu-west-2:000000000000:event-bus/orders', Description: 'Order events'} + } + return {} + }) + + await expect(adapter.get('event-bus:orders')).resolves.toMatchObject({ + name: 'orders', region: 'eu-west-2', metadata: {description: 'Order events'}, + }) + + const missing = createAdapter(async () => { + throw Object.assign(new Error('missing'), {name: 'ResourceNotFoundException'}) + }) + await expect(missing.get('event-bus:missing')).resolves.toBeNull() + }) + + test('creates and deletes an event bus with its normalized id', async () => { + const commands: unknown[] = [] + const adapter = createAdapter(async (command) => { + commands.push(command) + if (command instanceof CreateEventBusCommand) { + return {EventBusArn: 'arn:aws:events:us-east-1:000000000000:event-bus/orders'} + } + return {} + }) + + await expect(adapter.create({values: {name: 'orders', description: 'Order events'}})).resolves.toMatchObject({ + id: 'event-bus:orders', type: 'event-bus', metadata: {description: 'Order events'}, + }) + await adapter.delete('event-bus:orders') + + expect(commands[0]).toBeInstanceOf(CreateEventBusCommand) + expect((commands[0] as CreateEventBusCommand).input).toEqual({Name: 'orders', Description: 'Order events'}) + expect(commands[1]).toBeInstanceOf(DeleteEventBusCommand) + expect((commands[1] as DeleteEventBusCommand).input.Name).toBe('orders') + }) +}) + +function createAdapter(handler: SendHandler = async () => ({EventBuses: []})): AwsEventBridgeAdapter { + return new AwsEventBridgeAdapter({send: handler} as unknown as EventBridgeClient) +} diff --git a/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts new file mode 100644 index 0000000..acbace5 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsEventBridgeAdapter.ts @@ -0,0 +1,124 @@ +import { + CreateEventBusCommand, + DeleteEventBusCommand, + DescribeEventBusCommand, + ListEventBusesCommand, + type EventBridgeClient, + type EventBus, +} from '@aws-sdk/client-eventbridge' +import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' +import {ValidationError} from '../cloud-spi/errors' +import type { + CloudResource, + CloudServiceAdapter, + CloudServiceDescriptorOverride, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +const EVENT_BUS_PREFIX = 'event-bus:' + +/** AWS EventBridge event buses through the provider-neutral Events category. */ +export class AwsEventBridgeAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'events' as const + + constructor(private readonly eventbridge: EventBridgeClient) {} + + schema(): ServiceSchema { + return awsEventBridgeSchema() + } + + descriptorOverride(): CloudServiceDescriptorOverride { + return {displayName: 'EventBridge'} + } + + async list(query: ResourceQuery = {}): Promise { + const eventBuses: EventBus[] = [] + let nextToken: string | undefined + do { + const response = await this.eventbridge.send(new ListEventBusesCommand({NextToken: nextToken})) + eventBuses.push(...(response.EventBuses ?? [])) + nextToken = response.NextToken + } while (nextToken) + + return filterBySearch(eventBuses.map(eventBusResource), query.search) + } + + async get(id: string): Promise { + const name = eventBusName(id) + try { + return eventBusResource(await this.eventbridge.send(new DescribeEventBusCommand({Name: name}))) + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredValue(input.values.name, 'name') + const description = stringValue(input.values.description) || undefined + const response = await this.eventbridge.send(new CreateEventBusCommand({Name: name, Description: description})) + + return eventBusResource({Name: name, Arn: response.EventBusArn, Description: description}) + } + + async delete(id: string): Promise { + await this.eventbridge.send(new DeleteEventBusCommand({Name: eventBusName(id)})) + } +} + +function eventBusResource(eventBus: EventBus): CloudResource { + const name = eventBus.Name ?? '' + return { + id: `${EVENT_BUS_PREFIX}${name}`, + name, + cloud: 'aws', + service: 'events', + type: 'event-bus', + region: regionFromArn(eventBus.Arn), + createdAt: eventBus.CreationTime?.toISOString() ?? null, + status: null, + metadata: { + provider: 'aws', + eventsService: 'eventbridge', + arn: eventBus.Arn, + description: eventBus.Description, + policy: eventBus.Policy, + lastModifiedAt: eventBus.LastModifiedTime?.toISOString() ?? null, + }, + } +} + +function eventBusName(id: string): string { + const name = id.startsWith(EVENT_BUS_PREFIX) ? id.slice(EVENT_BUS_PREFIX.length) : '' + if (!name) throw new ValidationError(`Unknown EventBridge event bus id: ${id}`) + return name +} + +function requiredValue(value: unknown, field: string): string { + const result = stringValue(value) + if (!result) throw new ValidationError(`${field} is required`) + return result +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function regionFromArn(arn?: string): string | null { + return arn?.split(':')[3] || null +} + +function isNotFound(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const value = error as {name?: string; $metadata?: {httpStatusCode?: number}} + return value.name === 'ResourceNotFoundException' || value.$metadata?.httpStatusCode === 404 +} diff --git a/packages/api/src/adapter-aws/AwsIamAdapter.test.ts b/packages/api/src/adapter-aws/AwsIamAdapter.test.ts new file mode 100644 index 0000000..595c1c8 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsIamAdapter.test.ts @@ -0,0 +1,160 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateUserCommand, + DeleteUserCommand, + GetUserCommand, + ListUsersCommand, + type IAMClient, +} from '@aws-sdk/client-iam' +import {AwsIamAdapter} from './AwsIamAdapter' + +function fakeClient(handler: (command: unknown) => unknown): IAMClient { + return { + send: async (command: unknown) => handler(command), + } as unknown as IAMClient +} + +const alice = { + UserName: 'alice', + UserId: 'AIDAALICE', + Arn: 'arn:aws:iam::000000000000:user/team/alice', + Path: '/team/', + CreateDate: new Date('2026-01-02T03:04:05.000Z'), +} + +describe('AwsIamAdapter', () => { + test('lists every page and maps IAM users to normalized resources', async () => { + const adapter = new AwsIamAdapter(fakeClient((command) => { + if (!(command instanceof ListUsersCommand)) throw new Error('Unexpected command') + if (!command.input.Marker) return {Users: [alice], IsTruncated: true, Marker: 'next'} + return {Users: [{...alice, UserName: 'bob', UserId: 'AIDABOB'}], IsTruncated: false} + })) + + const result = await adapter.list() + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + id: 'alice', + name: 'alice', + cloud: 'aws', + service: 'identity', + type: 'iam-user', + region: null, + createdAt: '2026-01-02T03:04:05.000Z', + }) + expect(result[0].metadata).toMatchObject({ + identityService: 'iam', + userId: 'AIDAALICE', + path: '/team/', + }) + }) + + test('filters users by search term', async () => { + const adapter = new AwsIamAdapter(fakeClient(() => ({ + Users: [alice, {...alice, UserName: 'bob'}], + IsTruncated: false, + }))) + + const result = await adapter.list({search: 'ALI'}) + + expect(result.map((resource) => resource.name)).toEqual(['alice']) + }) + + test('gets and maps one IAM user', async () => { + const adapter = new AwsIamAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(GetUserCommand) + return {User: alice} + })) + + const result = await adapter.get('alice') + + expect(result?.id).toBe('alice') + expect(result?.metadata.arn).toBe(alice.Arn) + }) + + test('returns null when IAM reports a missing user', async () => { + const adapter = new AwsIamAdapter(fakeClient(() => { + throw Object.assign(new Error('missing'), {name: 'NoSuchEntityException'}) + })) + + await expect(adapter.get('missing')).resolves.toBeNull() + }) + + test('creates an IAM user with an optional path', async () => { + const adapter = new AwsIamAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(CreateUserCommand) + expect((command as CreateUserCommand).input).toEqual({UserName: 'alice', Path: '/team/'}) + return {User: alice} + })) + + const result = await adapter.create({values: {userName: 'alice', path: '/team/'}}) + + expect(result.id).toBe('alice') + }) + + test('reports an empty create response as a runtime error', async () => { + const adapter = new AwsIamAdapter(fakeClient(() => ({}))) + + await expect(adapter.create({values: {userName: 'alice'}})).rejects.toMatchObject({ + name: 'RuntimeError', + message: 'AWS IAM did not return the created user', + }) + }) + + test('rejects invalid user names before calling IAM', async () => { + let called = false + const adapter = new AwsIamAdapter(fakeClient(() => { + called = true + return {} + })) + + await expect(adapter.create({values: {userName: 'not valid'}})).rejects.toThrow('Use a valid IAM user name') + await expect(adapter.create({values: {userName: 'alice', path: 'team'}})).rejects.toThrow('Use a valid IAM path') + expect(called).toBeFalse() + }) + + test('deletes the requested IAM user', async () => { + const adapter = new AwsIamAdapter(fakeClient((command) => { + expect(command).toBeInstanceOf(DeleteUserCommand) + expect((command as DeleteUserCommand).input.UserName).toBe('alice') + return {} + })) + + await adapter.delete('alice') + }) + + test('reports a delete of a missing IAM user as not found', async () => { + const adapter = new AwsIamAdapter(fakeClient(() => { + throw Object.assign(new Error('The user with name alice cannot be found.'), {name: 'NoSuchEntityException'}) + })) + + await expect(adapter.delete('alice')).rejects.toMatchObject({ + name: 'NotFoundError', + message: 'IAM user alice not found', + }) + }) + + test('reports a delete blocked by attached resources as a conflict', async () => { + const adapter = new AwsIamAdapter(fakeClient(() => { + throw Object.assign(new Error('Cannot delete entity, must delete access keys first.'), { + name: 'DeleteConflictException', + }) + })) + + await expect(adapter.delete('alice')).rejects.toMatchObject({ + name: 'ConflictError', + message: expect.stringContaining('IAM user alice still has attached resources'), + }) + }) + + test('returns the AWS IAM schema', () => { + const adapter = new AwsIamAdapter(fakeClient(() => ({}))) + + expect(adapter.schema()).toMatchObject({ + cloud: 'aws', + service: 'identity', + displayName: 'AWS IAM users', + actions: ['list', 'create', 'delete', 'inspect'], + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsIamAdapter.ts b/packages/api/src/adapter-aws/AwsIamAdapter.ts new file mode 100644 index 0000000..faef1fe --- /dev/null +++ b/packages/api/src/adapter-aws/AwsIamAdapter.ts @@ -0,0 +1,133 @@ +import { + CreateUserCommand, + DeleteUserCommand, + GetUserCommand, + ListUsersCommand, + type IAMClient, + type User, +} from '@aws-sdk/client-iam' +import {iam as defaultIam} from '../aws' +import { + awsIamSchema, + IAM_PATH_MAX_LENGTH, + IAM_PATH_MESSAGE, + IAM_PATH_PATTERN, + IAM_USER_NAME_MESSAGE, + IAM_USER_NAME_PATTERN, +} from '../cloud-spi/iamSchema' +import {ConflictError, NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' + +const MAX_LIST_PAGES = 50 + +export class AwsIamAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'identity' as const + + constructor(private readonly iam: IAMClient = defaultIam) {} + + schema(): ServiceSchema { + return awsIamSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const users: User[] = [] + let marker: string | undefined + let pages = 0 + + do { + const res = await this.iam.send(new ListUsersCommand({Marker: marker})) + users.push(...(res.Users ?? [])) + marker = res.IsTruncated ? res.Marker : undefined + pages += 1 + } while (marker && pages < MAX_LIST_PAGES) + + return filterBySearch(users.map(toResource), query.search) + } + + async get(id: string): Promise { + try { + const res = await this.iam.send(new GetUserCommand({UserName: id})) + return res.User ? toResource(res.User) : null + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const userName = stringValue(input.values.userName) + const path = stringValue(input.values.path) + if (!userName) throw new ValidationError('userName is required') + if (!new RegExp(IAM_USER_NAME_PATTERN).test(userName)) throw new ValidationError(IAM_USER_NAME_MESSAGE) + if (path && (path.length > IAM_PATH_MAX_LENGTH || !new RegExp(IAM_PATH_PATTERN).test(path))) { + throw new ValidationError(IAM_PATH_MESSAGE) + } + + const res = await this.iam.send(new CreateUserCommand({ + UserName: userName, + Path: path || undefined, + })) + if (!res.User) throw new RuntimeError('AWS IAM did not return the created user') + return toResource(res.User) + } + + async delete(id: string): Promise { + try { + await this.iam.send(new DeleteUserCommand({UserName: id})) + } catch (error) { + if (isNotFound(error)) throw new NotFoundError(`IAM user ${id} not found`, {cause: error}) + if (isDeleteConflict(error)) { + throw new ConflictError( + `IAM user ${id} still has attached resources. Remove group memberships, access keys, ` + + 'certificates, MFA devices, and policies before deleting the user.', + {cause: error}, + ) + } + throw error + } + } +} + +function toResource(user: User): CloudResource { + const userName = user.UserName ?? '' + return { + id: userName, + name: userName, + cloud: 'aws', + service: 'identity', + type: 'iam-user', + region: null, + createdAt: user.CreateDate?.toISOString() ?? null, + metadata: { + provider: 'aws', + identityService: 'iam', + userId: user.UserId, + arn: user.Arn, + path: user.Path, + permissionsBoundary: user.PermissionsBoundary, + tags: user.Tags, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const candidate = error as {name?: string; $metadata?: {httpStatusCode?: number}} + return candidate.name === 'NoSuchEntityException' || candidate.$metadata?.httpStatusCode === 404 +} + +function isDeleteConflict(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + return (error as {name?: string}).name === 'DeleteConflictException' +} diff --git a/packages/api/src/adapter-aws/AwsKmsAdapter.test.ts b/packages/api/src/adapter-aws/AwsKmsAdapter.test.ts new file mode 100644 index 0000000..74592a9 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsKmsAdapter.test.ts @@ -0,0 +1,461 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateKeyCommand, + DecryptCommand, + DescribeKeyCommand, + EncryptCommand, + type KMSClient, + ListKeysCommand, + ListResourceTagsCommand, + ScheduleKeyDeletionCommand, + TagResourceCommand, + UntagResourceCommand, +} from '@aws-sdk/client-kms' +import {AwsKmsAdapter} from './AwsKmsAdapter' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +/** Minimal KMSClient stub that records the commands it was sent. */ +function stubKms(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as KMSClient + return {client, sent} +} + +const KEY_ID = '68543643-23b6-4037-a59b-4d43d5d308f1' +const KEY_ARN = `arn:aws:kms:us-east-1:000000000000:key/${KEY_ID}` + +const keyMetadata = { + AWSAccountId: '000000000000', + KeyId: KEY_ID, + Arn: KEY_ARN, + CreationDate: new Date('2026-07-28T10:00:00.000Z'), + Enabled: true, + Description: 'payments signing key', + KeyUsage: 'ENCRYPT_DECRYPT', + KeyState: 'Enabled', + Origin: 'AWS_KMS', + KeyManager: 'CUSTOMER', + KeySpec: 'SYMMETRIC_DEFAULT', + EncryptionAlgorithms: ['SYMMETRIC_DEFAULT'], +} + +/** Routes each command to the response the real runtime gives for it. */ +function runtimeStub(overrides: Record = {}) { + return stubKms((command) => { + if (command instanceof ListKeysCommand) { + return overrides.list ?? {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]} + } + if (command instanceof DescribeKeyCommand) { + return overrides.describe ?? {KeyMetadata: keyMetadata} + } + if (command instanceof ListResourceTagsCommand) { + return overrides.tags ?? {Tags: []} + } + return {} + }) +} + +describe('AwsKmsAdapter', () => { + test('identifies itself as the AWS KMS adapter', () => { + const adapter = new AwsKmsAdapter(runtimeStub().client) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('kms') + expect(adapter.schema().displayName).toBe('AWS KMS') + }) + + test('lists keys by fanning out DescribeKey over the thin ListKeys result', async () => { + // ListKeys returns only id and ARN, so every displayed column depends on + // the per-key DescribeKey call. + const {client, sent} = runtimeStub() + const [resource] = await new AwsKmsAdapter(client).list() + + expect(sent[0]).toBeInstanceOf(ListKeysCommand) + expect(sent[1]).toBeInstanceOf(DescribeKeyCommand) + expect(resource).toMatchObject({ + id: KEY_ID, + name: KEY_ID, + cloud: 'aws', + service: 'kms', + type: 'key', + region: 'us-east-1', + status: 'Enabled', + createdAt: '2026-07-28T10:00:00.000Z', + }) + expect(resource?.metadata).toMatchObject({ + arn: KEY_ARN, + description: 'payments signing key', + keyUsage: 'ENCRYPT_DECRYPT', + keySpec: 'SYMMETRIC_DEFAULT', + keyManager: 'CUSTOMER', + origin: 'AWS_KMS', + enabled: true, + }) + }) + + test('keeps keys that are pending deletion in the list', async () => { + // ScheduleKeyDeletion is a soft delete and real AWS keeps listing the key, + // so hiding it here would misreport the account. + const {client} = runtimeStub({ + describe: {KeyMetadata: {...keyMetadata, KeyState: 'PendingDeletion', Enabled: false}}, + }) + const [resource] = await new AwsKmsAdapter(client).list() + + expect(resource?.status).toBe('PendingDeletion') + }) + + test('surfaces key tags under metadata', async () => { + const {client} = runtimeStub({tags: {Tags: [{TagKey: 'env', TagValue: 'prod'}]}}) + const [resource] = await new AwsKmsAdapter(client).list() + + expect(resource?.metadata.tags).toEqual([{key: 'env', value: 'prod'}]) + }) + + test('still lists keys when the tag lookup fails', async () => { + // list() builds every row through Promise.all, so an unisolated tag + // failure would reject the whole view and show nothing, when the key + // metadata was available all along. + const {client} = stubKms((command) => { + if (command instanceof ListKeysCommand) return {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]} + if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata} + throw Object.assign(new Error('Rate exceeded'), { + name: 'ThrottlingException', + $metadata: {httpStatusCode: 400}, + }) + }) + + const [resource] = await new AwsKmsAdapter(client).list() + + expect(resource?.id).toBe(KEY_ID) + expect(resource?.metadata.tags).toEqual([]) + // Degraded, not silent. + expect(resource?.metadata.tagsUnavailable).toBe(true) + }) + + test('does not claim tags are unavailable when a key simply has none', async () => { + const {client} = runtimeStub() + const [resource] = await new AwsKmsAdapter(client).list() + + expect(resource?.metadata.tags).toEqual([]) + expect(resource?.metadata.tagsUnavailable).toBeUndefined() + }) + + test('filters the list by key id or description', async () => { + // The name is an opaque uuid, so a search that ignored the description + // would be unusable. + const adapter = new AwsKmsAdapter(runtimeStub().client) + + await expect(adapter.list({search: '68543643'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'payments'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('returns null when a key does not exist', async () => { + const {client} = stubKms(() => { + throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) + }) + await expect(new AwsKmsAdapter(client).get('missing')).resolves.toBeNull() + }) + + /** + * Real KMS is one of the AWS services that does not use 404 for not-found: + * it answers `NotFoundException` with HTTP 400. The local runtime sends 404, + * so a status-only check passes locally and turns a missing key into a 502 + * against real AWS. + */ + test('returns null for the real KMS not-found shape, which carries HTTP 400', async () => { + const {client} = stubKms(() => { + throw Object.assign(new Error('Key ARN does not exist'), { + name: 'NotFoundException', + $metadata: {httpStatusCode: 400}, + }) + }) + await expect(new AwsKmsAdapter(client).get('missing')).resolves.toBeNull() + }) + + test('rethrows a non-404 failure from get', async () => { + const {client} = stubKms(() => { + throw Object.assign(new Error('AccessDenied'), {$metadata: {httpStatusCode: 403}}) + }) + await expect(new AwsKmsAdapter(client).get(KEY_ID)).rejects.toThrow('AccessDenied') + }) + + test('inspects a key through DescribeKey', async () => { + const {client, sent} = runtimeStub() + const resource = await new AwsKmsAdapter(client).get(KEY_ID) + + expect(sent[0]).toBeInstanceOf(DescribeKeyCommand) + expect((sent[0] as DescribeKeyCommand).input.KeyId).toBe(KEY_ID) + expect(resource?.id).toBe(KEY_ID) + }) + + test('creates a key with the requested description and usage', async () => { + const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata})) + const resource = await new AwsKmsAdapter(client).create({ + values: {description: 'payments signing key', keyUsage: 'ENCRYPT_DECRYPT'}, + }) + + const command = sent[0] as CreateKeyCommand + expect(command).toBeInstanceOf(CreateKeyCommand) + expect(command.input.Description).toBe('payments signing key') + expect(command.input.KeyUsage).toBe('ENCRYPT_DECRYPT') + expect(resource.id).toBe(KEY_ID) + }) + + test('defaults key usage and spec when they are omitted', async () => { + const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata})) + await new AwsKmsAdapter(client).create({values: {}}) + + const command = sent[0] as CreateKeyCommand + expect(command.input.KeyUsage).toBe('ENCRYPT_DECRYPT') + expect(command.input.KeySpec).toBe('SYMMETRIC_DEFAULT') + }) + + describe('key usage and spec compatibility', () => { + // The local runtime accepts SIGN_VERIFY with SYMMETRIC_DEFAULT and + // ENCRYPT_DECRYPT with ECC_NIST_P256 and returns 200, but real KMS rejects + // both. Only these tests can hold the line, so they encode the provider's + // rules rather than the emulator's. + test('defaults the spec to one that is valid for the chosen usage', async () => { + const cases: Array<[string, string]> = [ + ['ENCRYPT_DECRYPT', 'SYMMETRIC_DEFAULT'], + ['SIGN_VERIFY', 'RSA_2048'], + ['GENERATE_VERIFY_MAC', 'HMAC_256'], + ] + + for (const [keyUsage, expectedSpec] of cases) { + const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata})) + await new AwsKmsAdapter(client).create({values: {keyUsage}}) + + expect(String((sent[0] as CreateKeyCommand).input.KeySpec)).toBe(expectedSpec) + } + }) + + test('rejects a spec that the chosen usage cannot use', async () => { + const cases: Array<[string, string]> = [ + ['SIGN_VERIFY', 'SYMMETRIC_DEFAULT'], + ['ENCRYPT_DECRYPT', 'ECC_NIST_P256'], + ['ENCRYPT_DECRYPT', 'HMAC_256'], + ['GENERATE_VERIFY_MAC', 'RSA_2048'], + ] + + for (const [keyUsage, keySpec] of cases) { + // Returns valid metadata, so a create that reaches the runtime + // succeeds — the only way this rejects is the compatibility check. + const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata})) + const adapter = new AwsKmsAdapter(client) + + await expect(adapter.create({values: {keyUsage, keySpec}})).rejects.toThrow(ValidationError) + expect(sent, `${keyUsage}/${keySpec} must be rejected before reaching KMS`).toHaveLength(0) + } + }) + + test('accepts every pair the provider considers valid', async () => { + const cases: Array<[string, string]> = [ + ['ENCRYPT_DECRYPT', 'SYMMETRIC_DEFAULT'], + ['ENCRYPT_DECRYPT', 'RSA_2048'], + ['ENCRYPT_DECRYPT', 'RSA_4096'], + ['SIGN_VERIFY', 'RSA_2048'], + ['SIGN_VERIFY', 'RSA_4096'], + ['SIGN_VERIFY', 'ECC_NIST_P256'], + ['GENERATE_VERIFY_MAC', 'HMAC_256'], + ] + + for (const [keyUsage, keySpec] of cases) { + const {client, sent} = stubKms(() => ({KeyMetadata: keyMetadata})) + await new AwsKmsAdapter(client).create({values: {keyUsage, keySpec}}) + + expect(String((sent[0] as CreateKeyCommand).input.KeySpec)).toBe(keySpec) + } + }) + }) + + test('follows the tag pages so a heavily tagged key is not truncated', async () => { + let tagCall = 0 + const {client} = stubKms((command) => { + if (command instanceof ListKeysCommand) return {Keys: [{KeyId: KEY_ID, KeyArn: KEY_ARN}]} + if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata} + tagCall += 1 + if (tagCall === 1) { + return {Tags: [{TagKey: 'a', TagValue: '1'}], Truncated: true, NextMarker: 'tag-page-2'} + } + return {Tags: [{TagKey: 'b', TagValue: '2'}], Truncated: false} + }) + + const [resource] = await new AwsKmsAdapter(client).list() + + expect(resource?.metadata.tags).toEqual([ + {key: 'a', value: '1'}, + {key: 'b', value: '2'}, + ]) + }) + + test('rejects a key usage the schema does not offer', async () => { + const adapter = new AwsKmsAdapter(stubKms(() => ({})).client) + + await expect(adapter.create({values: {keyUsage: 'MINE_BITCOIN'}})).rejects.toThrow(ValidationError) + }) + + test('rejects a description longer than the KMS limit', async () => { + const adapter = new AwsKmsAdapter(stubKms(() => ({})).client) + + await expect(adapter.create({values: {description: 'x'.repeat(8193)}})).rejects.toThrow(ValidationError) + }) + + test('encrypts symmetric plaintext after validating the selected key', async () => { + const ciphertext = new Uint8Array([9, 8, 7]) + const {client, sent} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata} + if (command instanceof EncryptCommand) { + return {CiphertextBlob: ciphertext, KeyId: KEY_ARN, EncryptionAlgorithm: 'SYMMETRIC_DEFAULT'} + } + return {} + }) + + const result = await new AwsKmsAdapter(client).encrypt(KEY_ID, { + plaintext: new TextEncoder().encode('hello'), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + encryptionContext: {purpose: 'test'}, + }) + + expect(sent[0]).toBeInstanceOf(DescribeKeyCommand) + const command = sent[1] as EncryptCommand + expect(command).toBeInstanceOf(EncryptCommand) + expect(command.input.KeyId).toBe(KEY_ID) + expect(command.input.EncryptionContext).toEqual({purpose: 'test'}) + expect(new TextDecoder().decode(command.input.Plaintext)).toBe('hello') + expect(result).toEqual({ + ciphertextBlob: ciphertext, + keyId: KEY_ARN, + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + }) + }) + + test('rejects plaintext above the RSA OAEP limit before encrypting', async () => { + const {client, sent} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) { + return {KeyMetadata: {...keyMetadata, KeySpec: 'RSA_2048'}} + } + return {} + }) + + await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, { + plaintext: new Uint8Array(191), + encryptionAlgorithm: 'RSAES_OAEP_SHA_256', + })).rejects.toThrow('190 bytes or fewer') + expect(sent).toHaveLength(1) + }) + + test('rejects an encryption context for RSA keys', async () => { + const {client, sent} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) { + return {KeyMetadata: {...keyMetadata, KeySpec: 'RSA_4096'}} + } + return {} + }) + + await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, { + plaintext: new Uint8Array([1]), + encryptionAlgorithm: 'RSAES_OAEP_SHA_256', + encryptionContext: {secret: 'not-supported'}, + })).rejects.toThrow('do not support encryptionContext') + expect(sent).toHaveLength(1) + }) + + test('rejects crypto operations for disabled keys', async () => { + const {client, sent} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) { + return {KeyMetadata: {...keyMetadata, Enabled: false, KeyState: 'Disabled'}} + } + return {} + }) + + await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, { + plaintext: new Uint8Array([1]), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + })).rejects.toThrow('must be enabled') + expect(sent).toHaveLength(1) + }) + + test('decrypts ciphertext with the selected key and context', async () => { + const plaintext = new TextEncoder().encode('round trip') + const {client, sent} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata} + if (command instanceof DecryptCommand) { + return {Plaintext: plaintext, KeyId: KEY_ARN, EncryptionAlgorithm: 'SYMMETRIC_DEFAULT'} + } + return {} + }) + + const result = await new AwsKmsAdapter(client).decrypt(KEY_ID, { + ciphertextBlob: new Uint8Array([3, 2, 1]), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + encryptionContext: {purpose: 'test'}, + }) + + const command = sent[1] as DecryptCommand + expect(command).toBeInstanceOf(DecryptCommand) + expect(command.input.CiphertextBlob).toEqual(new Uint8Array([3, 2, 1])) + expect(command.input.EncryptionContext).toEqual({purpose: 'test'}) + expect(new TextDecoder().decode(result.plaintext)).toBe('round trip') + }) + + test('treats a missing provider ciphertext as a runtime contract failure', async () => { + const {client} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata} + return {} + }) + + await expect(new AwsKmsAdapter(client).encrypt(KEY_ID, { + plaintext: new Uint8Array([1]), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + })).rejects.toThrow(RuntimeError) + }) + + test('treats a missing provider plaintext as a runtime contract failure', async () => { + const {client} = stubKms((command) => { + if (command instanceof DescribeKeyCommand) return {KeyMetadata: keyMetadata} + return {} + }) + + await expect(new AwsKmsAdapter(client).decrypt(KEY_ID, { + ciphertextBlob: new Uint8Array([1]), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + })).rejects.toThrow(RuntimeError) + }) + + test('deletes a key by scheduling deletion with the shortest window', async () => { + const {client, sent} = stubKms(() => ({KeyId: KEY_ARN, DeletionDate: new Date()})) + await new AwsKmsAdapter(client).delete(KEY_ID) + + const command = sent[0] as ScheduleKeyDeletionCommand + expect(command).toBeInstanceOf(ScheduleKeyDeletionCommand) + expect(command.input.KeyId).toBe(KEY_ID) + expect(command.input.PendingWindowInDays).toBe(7) + }) + + test('adds and removes tags in one update', async () => { + const {client, sent} = stubKms(() => ({})) + await new AwsKmsAdapter(client).updateTags(KEY_ID, {env: 'prod', stale: null}) + + const tagged = sent.find((c) => c instanceof TagResourceCommand) as TagResourceCommand + const untagged = sent.find((c) => c instanceof UntagResourceCommand) as UntagResourceCommand + + expect(tagged.input.Tags).toEqual([{TagKey: 'env', TagValue: 'prod'}]) + expect(untagged.input.TagKeys).toEqual(['stale']) + }) + + test('skips the tag calls it has nothing to send', async () => { + const {client, sent} = stubKms(() => ({})) + await new AwsKmsAdapter(client).updateTags(KEY_ID, {env: 'prod'}) + + expect(sent.some((c) => c instanceof UntagResourceCommand)).toBe(false) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsKmsAdapter.ts b/packages/api/src/adapter-aws/AwsKmsAdapter.ts new file mode 100644 index 0000000..e0dbe11 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsKmsAdapter.ts @@ -0,0 +1,419 @@ +import { + CreateKeyCommand, + DecryptCommand, + DescribeKeyCommand, + EncryptCommand, + type KeyMetadata, + type KMSClient, + ListKeysCommand, + ListResourceTagsCommand, + ScheduleKeyDeletionCommand, + TagResourceCommand, + UntagResourceCommand, +} from '@aws-sdk/client-kms' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { + awsKmsSchema, + KMS_DESCRIPTION_MAX_LENGTH, + KMS_KEY_SPECS, + KMS_KEY_USAGES, + KMS_SPECS_BY_USAGE, +} from '../cloud-spi/kmsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + KmsDecryptInput, + KmsDecryptResult, + KmsEncryptInput, + KmsEncryptionAlgorithm, + KmsEncryptResult, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +type KeyTag = {key: string; value: string} + +/** Tags plus whether the lookup itself failed, so the two stay distinguishable. */ +type TagLookup = {tags: KeyTag[]; unavailable?: true} + +/** + * The shortest deletion window KMS accepts. Deletion is irreversible once it + * completes, so the console never offers a longer wait than it has to — but it + * also cannot delete immediately, which is why `delete` schedules. + */ +const PENDING_DELETION_WINDOW_DAYS = 7 +const SYMMETRIC_PLAINTEXT_MAX_BYTES = 4_096 +const DECRYPT_CIPHERTEXT_MAX_BYTES = 6_144 + +const RSA_PLAINTEXT_MAX_BYTES = { + RSA_2048: { + RSAES_OAEP_SHA_1: 214, + RSAES_OAEP_SHA_256: 190, + }, + RSA_4096: { + RSAES_OAEP_SHA_1: 470, + RSAES_OAEP_SHA_256: 446, + }, +} as const + +export class AwsKmsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'kms' as const + + constructor(private readonly kms: KMSClient) {} + + schema(): ServiceSchema { + return awsKmsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const ids = await this.listKeyIds() + const resources = await Promise.all(ids.map((id) => this.describe(id))) + return filterBySearch(resources.filter((resource): resource is CloudResource => resource !== null), query.search) + } + + async get(id: string): Promise { + return this.describe(id) + } + + async create(input: CreateResourceInput): Promise { + const description = optionalString(input.values.description, 'description') + if (description && description.length > KMS_DESCRIPTION_MAX_LENGTH) { + throw new ValidationError(`description must be ${KMS_DESCRIPTION_MAX_LENGTH} characters or fewer`) + } + + const keyUsage = oneOf(input.values.keyUsage, KMS_KEY_USAGES, 'keyUsage') + const keySpec = keySpecFor(keyUsage, input.values.keySpec) + + const res = await this.kms.send( + new CreateKeyCommand({ + ...(description ? {Description: description} : {}), + KeyUsage: keyUsage, + KeySpec: keySpec, + }), + ) + if (!res.KeyMetadata) throw new ValidationError('KMS did not return metadata for the created key') + + // A brand new key has no tags, so this skips the ListResourceTags call. + return toResource(res.KeyMetadata, {tags: []}) + } + + /** + * KMS has no immediate delete: the key enters PendingDeletion and is destroyed + * when the window elapses. It keeps appearing in `list()` until then, which is + * the runtime's behaviour and not something to hide. + */ + async delete(id: string): Promise { + await this.kms.send( + new ScheduleKeyDeletionCommand({KeyId: id, PendingWindowInDays: PENDING_DELETION_WINDOW_DAYS}), + ) + } + + async updateTags(id: string, tags: Record): Promise { + const added = Object.entries(tags).filter((entry): entry is [string, string] => entry[1] !== null) + const removed = Object.keys(tags).filter((key) => tags[key] === null) + + if (added.length > 0) { + await this.kms.send( + new TagResourceCommand({ + KeyId: id, + Tags: added.map(([key, value]) => ({TagKey: key, TagValue: value})), + }), + ) + } + if (removed.length > 0) { + await this.kms.send(new UntagResourceCommand({KeyId: id, TagKeys: removed})) + } + } + + async encrypt(id: string, input: KmsEncryptInput): Promise { + const metadata = await this.describeForCrypto(id) + validateCryptoKey(metadata, input.encryptionAlgorithm, input.encryptionContext) + validatePlaintextSize(metadata, input.plaintext, input.encryptionAlgorithm) + + const res = await this.kms.send( + new EncryptCommand({ + KeyId: id, + Plaintext: input.plaintext, + EncryptionAlgorithm: input.encryptionAlgorithm, + ...(input.encryptionContext ? {EncryptionContext: input.encryptionContext} : {}), + }), + ) + if (!res.CiphertextBlob) throw new RuntimeError('KMS did not return ciphertext') + + return { + ciphertextBlob: res.CiphertextBlob, + keyId: res.KeyId ?? id, + encryptionAlgorithm: responseAlgorithm(res.EncryptionAlgorithm, input.encryptionAlgorithm), + } + } + + async decrypt(id: string, input: KmsDecryptInput): Promise { + const metadata = await this.describeForCrypto(id) + validateCryptoKey(metadata, input.encryptionAlgorithm, input.encryptionContext) + if (input.ciphertextBlob.byteLength === 0) throw new ValidationError('ciphertextBlobBase64 must not be empty') + if (input.ciphertextBlob.byteLength > DECRYPT_CIPHERTEXT_MAX_BYTES) { + throw new ValidationError(`ciphertextBlobBase64 must decode to ${DECRYPT_CIPHERTEXT_MAX_BYTES} bytes or fewer`) + } + + const res = await this.kms.send( + new DecryptCommand({ + KeyId: id, + CiphertextBlob: input.ciphertextBlob, + EncryptionAlgorithm: input.encryptionAlgorithm, + ...(input.encryptionContext ? {EncryptionContext: input.encryptionContext} : {}), + }), + ) + if (!res.Plaintext) throw new RuntimeError('KMS did not return plaintext') + + return { + plaintext: res.Plaintext, + keyId: res.KeyId ?? id, + encryptionAlgorithm: responseAlgorithm(res.EncryptionAlgorithm, input.encryptionAlgorithm), + } + } + + /** ListKeys pages at 100 keys, so an account past that would silently truncate. */ + private async listKeyIds(): Promise { + const ids: string[] = [] + let marker: string | undefined + + do { + const res = await this.kms.send(new ListKeysCommand(marker ? {Marker: marker} : {})) + for (const key of res.Keys ?? []) { + if (key.KeyId) ids.push(key.KeyId) + } + marker = res.Truncated ? res.NextMarker : undefined + } while (marker) + + return ids + } + + /** Crypto actions need key metadata but not the extra tag lookup used by get(). */ + private async describeForCrypto(id: string): Promise { + const res = await this.kms.send(new DescribeKeyCommand({KeyId: id})) + if (!res.KeyMetadata) throw new RuntimeError('KMS did not return key metadata') + return res.KeyMetadata + } + + /** + * ListKeys returns only an id and an ARN, so every column the table shows + * comes from this per-key DescribeKey call. + */ + private async describe(id: string): Promise { + try { + const res = await this.kms.send(new DescribeKeyCommand({KeyId: id})) + if (!res.KeyMetadata) return null + return toResource(res.KeyMetadata, await this.getTags(id)) + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + /** + * Paged like ListKeys, so a heavily tagged key would otherwise be truncated. + * + * Tags are enrichment, so a failure degrades the row rather than the request: + * `list` builds every row through `Promise.all`, so an unisolated throttle + * would reject the whole view. Isolating it here also stops a tag 404 — a key + * deleted mid-list — from reaching `describe`'s catch and silently dropping + * the key. The failure is reported rather than swallowed, so an untagged key + * and an unreadable one stay distinguishable. + */ + private async getTags(id: string): Promise { + const tags: KeyTag[] = [] + let marker: string | undefined + + try { + do { + const res = await this.kms.send( + new ListResourceTagsCommand({KeyId: id, ...(marker ? {Marker: marker} : {})}), + ) + for (const tag of res.Tags ?? []) { + tags.push({key: tag.TagKey ?? '', value: tag.TagValue ?? ''}) + } + marker = res.Truncated ? res.NextMarker : undefined + } while (marker) + } catch { + // Keep whatever pages already arrived rather than discarding them. + return {tags, unavailable: true} + } + + return {tags} + } +} + +function validateCryptoKey( + metadata: KeyMetadata, + algorithm: KmsEncryptionAlgorithm, + encryptionContext: Record | undefined, +): void { + if (metadata.KeyUsage !== 'ENCRYPT_DECRYPT') { + throw new ValidationError('Selected key does not support encryption and decryption') + } + if (metadata.Enabled !== true || metadata.KeyState !== 'Enabled') { + throw new ValidationError('Selected key must be enabled') + } + + const keySpec = metadata.KeySpec ?? metadata.CustomerMasterKeySpec + if (keySpec === 'SYMMETRIC_DEFAULT') { + if (algorithm !== 'SYMMETRIC_DEFAULT') { + throw new ValidationError('Symmetric keys require SYMMETRIC_DEFAULT') + } + return + } + + if (keySpec !== 'RSA_2048' && keySpec !== 'RSA_4096') { + throw new ValidationError('Selected key spec does not support encryption and decryption') + } + if (algorithm !== 'RSAES_OAEP_SHA_1' && algorithm !== 'RSAES_OAEP_SHA_256') { + throw new ValidationError('RSA keys require an RSAES_OAEP encryption algorithm') + } + if (encryptionContext && Object.keys(encryptionContext).length > 0) { + throw new ValidationError('RSA keys do not support encryptionContext') + } +} + +function validatePlaintextSize( + metadata: KeyMetadata, + plaintext: Uint8Array, + algorithm: KmsEncryptionAlgorithm, +): void { + if (plaintext.byteLength === 0) throw new ValidationError('plaintextBase64 must not be empty') + + const keySpec = metadata.KeySpec ?? metadata.CustomerMasterKeySpec + if (keySpec === 'SYMMETRIC_DEFAULT') { + if (plaintext.byteLength > SYMMETRIC_PLAINTEXT_MAX_BYTES) { + throw new ValidationError(`plaintextBase64 must decode to ${SYMMETRIC_PLAINTEXT_MAX_BYTES} bytes or fewer`) + } + return + } + + if (keySpec !== 'RSA_2048' && keySpec !== 'RSA_4096') return + if (algorithm !== 'RSAES_OAEP_SHA_1' && algorithm !== 'RSAES_OAEP_SHA_256') return + + const limit = RSA_PLAINTEXT_MAX_BYTES[keySpec][algorithm] + if (plaintext.byteLength > limit) { + throw new ValidationError(`plaintextBase64 must decode to ${limit} bytes or fewer for ${keySpec}/${algorithm}`) + } +} + +function responseAlgorithm( + value: string | undefined, + fallback: KmsEncryptionAlgorithm, +): KmsEncryptionAlgorithm { + if (value === undefined) return fallback + if (value === 'SYMMETRIC_DEFAULT' || value === 'RSAES_OAEP_SHA_1' || value === 'RSAES_OAEP_SHA_256') { + return value + } + throw new RuntimeError('KMS returned an unsupported encryption algorithm') +} + +function toResource(metadata: KeyMetadata, tagLookup: TagLookup): CloudResource { + const id = metadata.KeyId ?? '' + + return { + id, + // KMS keys have no name field; the uuid is the identity. + name: id, + cloud: 'aws', + service: 'kms', + type: 'key', + region: regionFromArn(metadata.Arn), + createdAt: metadata.CreationDate ? metadata.CreationDate.toISOString() : null, + status: metadata.KeyState ?? null, + metadata: { + arn: metadata.Arn, + accountId: metadata.AWSAccountId, + description: metadata.Description, + keyUsage: metadata.KeyUsage, + keySpec: metadata.KeySpec, + keyManager: metadata.KeyManager, + origin: metadata.Origin, + enabled: metadata.Enabled, + deletionDate: metadata.DeletionDate ? metadata.DeletionDate.toISOString() : null, + encryptionAlgorithms: metadata.EncryptionAlgorithms, + signingAlgorithms: metadata.SigningAlgorithms, + multiRegion: metadata.MultiRegion, + tags: tagLookup.tags, + ...(tagLookup.unavailable ? {tagsUnavailable: true} : {}), + }, + } +} + +/** `arn:aws:kms:us-east-1:000000000000:key/` — the region is the 4th field. */ +function regionFromArn(arn: string | undefined): string | null { + return arn?.split(':')[3] || null +} + +/** Matches on the id and the description, because the id alone is an opaque uuid. */ +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + + return resources.filter((resource) => { + const description = String(resource.metadata.description ?? '').toLowerCase() + return resource.name.toLowerCase().includes(normalized) || description.includes(normalized) + }) +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + return value +} + +/** + * Resolve the key spec for a usage, defaulting to the first spec that usage + * supports rather than to a fixed symmetric default. + * + * A fixed default would pair SIGN_VERIFY with SYMMETRIC_DEFAULT, which real KMS + * rejects — and the local runtime accepts, so the failure would only appear + * against a real provider. + */ +function keySpecFor(keyUsage: (typeof KMS_KEY_USAGES)[number], raw: unknown): (typeof KMS_KEY_SPECS)[number] { + const allowed = KMS_SPECS_BY_USAGE[keyUsage] + const requested = oneOf(raw, KMS_KEY_SPECS, 'keySpec', allowed[0]) + + if (!(allowed as readonly string[]).includes(requested)) { + throw new ValidationError( + `keySpec ${requested} is not valid for keyUsage ${keyUsage}; use one of ${allowed.join(', ')}`, + ) + } + return requested +} + +/** + * Keeps create honest about the schema: a value the form never offers would be + * rejected by KMS anyway, and failing here gives a typed error instead of a 400. + */ +function oneOf( + value: unknown, + allowed: T, + field: string, + fallback: T[number] = allowed[0] as T[number], +): T[number] { + const raw = optionalString(value, field) + if (raw === undefined) return fallback + if (!allowed.includes(raw)) { + throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + } + return raw as T[number] +} + +/** + * KMS is one of the AWS services that does not use 404 for not-found: real KMS + * answers `NotFoundException` with HTTP **400**. The local runtime happens to + * send 404, so a status-only check passes every test here and would start + * throwing 502s the moment the runtime tightened to the real contract — the same + * shape of bug as matching a wire code the SDK never produces. Match the name + * first and keep the status as a fallback for the emulator. + */ +function isNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + if ((error as {name?: string}).name === 'NotFoundException') return true + const metadata = (error as {$metadata?: {httpStatusCode?: number}}).$metadata + return metadata?.httpStatusCode === 404 +} diff --git a/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts new file mode 100644 index 0000000..9a2425e --- /dev/null +++ b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.test.ts @@ -0,0 +1,223 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateLoadBalancerCommand, + DeleteLoadBalancerCommand, + DescribeLoadBalancersCommand, + type ElasticLoadBalancingV2Client, +} from '@aws-sdk/client-elastic-load-balancing-v2' +import {AwsLoadBalancingAdapter} from './AwsLoadBalancingAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +function stubElb(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as ElasticLoadBalancingV2Client + return {client, sent} +} + +const ARN = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/77aa388f69144785' + +const balancer = { + LoadBalancerArn: ARN, + LoadBalancerName: 'web', + DNSName: 'web-77aa388f69144785.elb.localhost.floci.io', + // The runtime nests this: provisioning + State: {Code: 'provisioning'}, + Type: 'application', + Scheme: 'internet-facing', + VpcId: 'vpc-default', + CreatedTime: new Date('2026-07-28T17:04:35.028Z'), + AvailabilityZones: [ + {ZoneName: 'us-east-1b', SubnetId: 'subnet-default-b'}, + {ZoneName: 'us-east-1c', SubnetId: 'subnet-default-c'}, + ], + SecurityGroups: ['sg-1'], +} + +function runtimeStub() { + return stubElb((command) => { + if (command instanceof CreateLoadBalancerCommand) return {LoadBalancers: [balancer]} + if (command instanceof DescribeLoadBalancersCommand) return {LoadBalancers: [balancer]} + return {} + }) +} + +const validValues = {name: 'web', subnets: 'subnet-default-b, subnet-default-c'} + +describe('AwsLoadBalancingAdapter', () => { + test('identifies itself as the AWS load balancing adapter', () => { + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('loadbalancing') + expect(adapter.schema().displayName).toBe('AWS Elastic Load Balancing') + }) + + test('lists load balancers and reads the nested state code', async () => { + const {client, sent} = runtimeStub() + const [resource] = await new AwsLoadBalancingAdapter(client).list() + + expect(sent[0]).toBeInstanceOf(DescribeLoadBalancersCommand) + expect(resource).toMatchObject({ + // The ARN is the id: Describe and Delete both take one. + id: ARN, + name: 'web', + cloud: 'aws', + service: 'loadbalancing', + type: 'load-balancer', + region: 'us-east-1', + // State is nested, not a flat field — reading State directly yields + // an object and the column would render blank. + status: 'provisioning', + createdAt: '2026-07-28T17:04:35.028Z', + }) + expect(resource?.metadata).toMatchObject({ + dnsName: 'web-77aa388f69144785.elb.localhost.floci.io', + lbType: 'application', + scheme: 'internet-facing', + vpcId: 'vpc-default', + subnets: ['subnet-default-b', 'subnet-default-c'], + availabilityZones: ['us-east-1b', 'us-east-1c'], + }) + }) + + test('follows the pagination marker', async () => { + let call = 0 + const {client, sent} = stubElb((command) => { + if (!(command instanceof DescribeLoadBalancersCommand)) return {} + call += 1 + if (call === 1) return {LoadBalancers: [balancer], NextMarker: 'page-2'} + return {LoadBalancers: [{...balancer, LoadBalancerName: 'api', LoadBalancerArn: `${ARN}-2`}]} + }) + + const resources = await new AwsLoadBalancingAdapter(client).list() + + expect(resources.map((r) => r.name)).toEqual(['web', 'api']) + expect((sent[1] as DescribeLoadBalancersCommand).input.Marker).toBe('page-2') + }) + + test('treats an empty NextMarker as the end of the list', async () => { + // The runtime returns , which the SDK surfaces as + // an empty string; looping on truthiness alone would spin forever. + let call = 0 + const {client} = stubElb((command) => { + if (!(command instanceof DescribeLoadBalancersCommand)) return {} + call += 1 + return {LoadBalancers: [balancer], NextMarker: ''} + }) + + await expect(new AwsLoadBalancingAdapter(client).list()).resolves.toHaveLength(1) + expect(call).toBe(1) + }) + + test('filters by name and ARN', async () => { + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + + await expect(adapter.list({search: 'we'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'loadbalancer/app'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a load balancer by ARN', async () => { + const {client, sent} = runtimeStub() + const resource = await new AwsLoadBalancingAdapter(client).get(ARN) + + expect((sent[0] as DescribeLoadBalancersCommand).input.LoadBalancerArns).toEqual([ARN]) + expect(resource?.id).toBe(ARN) + }) + + test('returns null on LoadBalancerNotFound even though the runtime answers 400', async () => { + // ELB reports a missing load balancer as LoadBalancerNotFound with HTTP 400, + // not 404, so matching on the status would rethrow instead of returning null. + const {client} = stubElb(() => { + throw Object.assign(new Error('One or more load balancers not found.'), { + name: 'LoadBalancerNotFound', + $metadata: {httpStatusCode: 400}, + }) + }) + await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull() + }) + + test('also returns null for the SDK-modelled exception name', async () => { + // The wire is LoadBalancerNotFound but the SDK calls the error + // LoadBalancerNotFoundException. Matching only the wire code passed every + // stubbed test here and still returned 400 against the live runtime. + const {client} = stubElb(() => { + throw Object.assign(new Error('One or more load balancers not found.'), { + name: 'LoadBalancerNotFoundException', + $metadata: {httpStatusCode: 400}, + }) + }) + await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull() + }) + + test('returns null when the runtime answers with no load balancers', async () => { + const {client} = stubElb(() => ({LoadBalancers: []})) + await expect(new AwsLoadBalancingAdapter(client).get(ARN)).resolves.toBeNull() + }) + + test('rethrows a failure that is not a missing load balancer', async () => { + const {client} = stubElb(() => { + throw Object.assign(new Error('AccessDenied'), { + name: 'AccessDeniedException', + $metadata: {httpStatusCode: 403}, + }) + }) + await expect(new AwsLoadBalancingAdapter(client).get(ARN)).rejects.toThrow('AccessDenied') + }) + + test('creates a load balancer, splitting the comma separated subnets', async () => { + const {client, sent} = runtimeStub() + const resource = await new AwsLoadBalancingAdapter(client).create({values: validValues}) + + const command = sent[0] as CreateLoadBalancerCommand + expect(command).toBeInstanceOf(CreateLoadBalancerCommand) + expect(command.input.Name).toBe('web') + expect(command.input.Subnets).toEqual(['subnet-default-b', 'subnet-default-c']) + expect(command.input.Type).toBe('application') + expect(command.input.Scheme).toBe('internet-facing') + expect(resource.id).toBe(ARN) + }) + + test('requires at least two subnet ids', async () => { + // Only the count is enforced. The AZ requirement is ELB's, and checking it + // would mean calling EC2 from this adapter, so the message must not claim it. + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + + await expect(adapter.create({values: {...validValues, subnets: 'subnet-only'}})).rejects.toThrow( + new ValidationError('subnets must list at least two subnet ids'), + ) + }) + + test('requires the fields the schema marks required', async () => { + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + + await expect(adapter.create({values: {}})).rejects.toThrow(new ValidationError('name is required')) + await expect(adapter.create({values: {name: 'web'}})).rejects.toThrow( + new ValidationError('subnets is required'), + ) + }) + + test('rejects values the runtime would refuse', async () => { + const adapter = new AwsLoadBalancingAdapter(runtimeStub().client) + + for (const name of ['-web', 'web-', 'has spaces', 'a'.repeat(33)]) { + await expect(adapter.create({values: {...validValues, name}}), name).rejects.toThrow(ValidationError) + } + await expect(adapter.create({values: {...validValues, type: 'gateway'}})).rejects.toThrow(ValidationError) + await expect(adapter.create({values: {...validValues, scheme: 'sideways'}})).rejects.toThrow(ValidationError) + }) + + test('deletes a load balancer by ARN', async () => { + const {client, sent} = runtimeStub() + await new AwsLoadBalancingAdapter(client).delete(ARN) + + expect((sent[0] as DeleteLoadBalancerCommand).input.LoadBalancerArn).toBe(ARN) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts new file mode 100644 index 0000000..06e5553 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsLoadBalancingAdapter.ts @@ -0,0 +1,185 @@ +import { + CreateLoadBalancerCommand, + DeleteLoadBalancerCommand, + DescribeLoadBalancersCommand, + type ElasticLoadBalancingV2Client, + type LoadBalancer, +} from '@aws-sdk/client-elastic-load-balancing-v2' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {ELB_NAME_PATTERN, ELB_SCHEMES, ELB_TYPES, awsLoadBalancingSchema} from '../cloud-spi/loadBalancingSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to Elastic Load Balancing — verified against Floci core 1.5.33. + * + * Notes from probing the runtime: + * - **Only ELBv2 is implemented.** A `Version=2012-06-01` request comes back in the + * `2015-12-01` namespace, so there is no separate classic ELB to model. + * - `State` is **nested** (`provisioning`). Reading + * `State` directly yields an object and the column renders blank. + * - A missing load balancer is `LoadBalancerNotFound` with HTTP **400**, not 404, + * so `get` matches on the error name. `awsErrors.ts` now maps that name (and + * `TargetGroupNotFound`) so the route reports 404 rather than a validation error. + * - The runtime returns `` at the end of the list, which + * the SDK surfaces as an empty string — paging must stop on falsy, not on + * undefined, or it spins forever. + * - A new load balancer reports `provisioning` and then reaches `active`; the + * state is surfaced as-is rather than waited on. + * + * Target groups are deliberately excluded: they are a second resource kind in this + * category and would need the `kind` facet from `ResourceQuery.filters` (#162). + * Shipping load balancers alone keeps this PR independent of that one. + */ +export class AwsLoadBalancingAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'loadbalancing' as const + + constructor(private readonly elb: ElasticLoadBalancingV2Client) {} + + schema(): ServiceSchema { + return awsLoadBalancingSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const balancers: LoadBalancer[] = [] + let marker: string | undefined + + do { + const res = await this.elb.send(new DescribeLoadBalancersCommand(marker ? {Marker: marker} : {})) + balancers.push(...(res.LoadBalancers ?? [])) + marker = res.NextMarker || undefined + } while (marker) + + return filterBySearch(balancers.map(toResource), query.search) + } + + async get(id: string): Promise { + try { + const res = await this.elb.send(new DescribeLoadBalancersCommand({LoadBalancerArns: [id]})) + const balancer = res.LoadBalancers?.[0] + return balancer ? toResource(balancer) : null + } catch (error) { + if (isMissing(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(ELB_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must be up to 32 characters of letters, digits and hyphens, not starting or ending with a hyphen', + ) + } + + // Only the count is enforced. Whether the subnets sit in different + // availability zones is ELB's rule, and checking it here would mean calling + // EC2 DescribeSubnets from the load balancing adapter — real coupling for a + // validation nicety. So the message claims only what this check does; the + // schema field carries the AZ requirement as guidance. + const subnets = splitIds(requiredString(input.values.subnets, 'subnets')) + if (subnets.length < 2) { + throw new ValidationError('subnets must list at least two subnet ids') + } + + const type = optionalOneOf(input.values.type, ELB_TYPES, 'type') ?? 'application' + const scheme = optionalOneOf(input.values.scheme, ELB_SCHEMES, 'scheme') ?? 'internet-facing' + + const res = await this.elb.send( + new CreateLoadBalancerCommand({Name: name, Subnets: subnets, Type: type, Scheme: scheme}), + ) + const created = res.LoadBalancers?.[0] + if (!created) throw new RuntimeError(`ELB did not return the created load balancer for ${name}`) + return toResource(created) + } + + async delete(id: string): Promise { + await this.elb.send(new DeleteLoadBalancerCommand({LoadBalancerArn: id})) + } +} + +function toResource(balancer: LoadBalancer): CloudResource { + const arn = balancer.LoadBalancerArn ?? '' + const zones = balancer.AvailabilityZones ?? [] + + return { + id: arn, + name: balancer.LoadBalancerName ?? '', + cloud: 'aws', + service: 'loadbalancing', + type: 'load-balancer', + region: regionFromArn(arn), + createdAt: balancer.CreatedTime ? balancer.CreatedTime.toISOString() : null, + status: balancer.State?.Code ?? null, + metadata: { + arn, + dnsName: balancer.DNSName, + /** `type` is taken by CloudResource, so the ELB type lives here. */ + lbType: balancer.Type, + scheme: balancer.Scheme, + vpcId: balancer.VpcId, + stateReason: balancer.State?.Reason, + subnets: zones.map((zone) => zone.SubnetId).filter((id): id is string => Boolean(id)), + availabilityZones: zones.map((zone) => zone.ZoneName).filter((z): z is string => Boolean(z)), + securityGroups: balancer.SecurityGroups, + ipAddressType: balancer.IpAddressType, + canonicalHostedZoneId: balancer.CanonicalHostedZoneId, + }, + } +} + +/** `arn:aws:elasticloadbalancing:us-east-1:...` — region is field 4. */ +function regionFromArn(arn: string): string | null { + return arn.split(':')[3] || null +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + + return resources.filter( + (resource) => + resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized), + ) +} + +/** Accepts the comma separated form the schema asks for, ignoring blanks. */ +function splitIds(raw: string): string[] { + return raw + .split(',') + .map((part) => part.trim()) + .filter(Boolean) +} + +/** + * The wire `` is `LoadBalancerNotFound`, but the SDK names the modelled error + * `LoadBalancerNotFoundException`. Matching only the wire code passed every stubbed + * test and still returned 400 against the live runtime, so both are accepted. + */ +function isMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const name = (error as {name?: string}).name + return name === 'LoadBalancerNotFound' || name === 'LoadBalancerNotFoundException' +} + +function requiredString(value: unknown, field: string): string { + if (value === undefined || value === null || value === '') throw new ValidationError(`${field} is required`) + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + const trimmed = value.trim() + if (!trimmed) throw new ValidationError(`${field} is required`) + return trimmed +} + +function optionalOneOf(value: unknown, allowed: T, field: string): T[number] | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + const raw = value.trim() + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} diff --git a/packages/api/src/adapter-aws/AwsLogsAdapter.test.ts b/packages/api/src/adapter-aws/AwsLogsAdapter.test.ts new file mode 100644 index 0000000..38c22a1 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsLogsAdapter.test.ts @@ -0,0 +1,363 @@ +import {describe, expect, test} from 'bun:test' +import {AwsLogsAdapter} from './AwsLogsAdapter' + +/** + * Stubs the SDK client's `send`, dispatching on the command's constructor name. + * Matching the real SDK's *name* rather than the wire shape is deliberate — see + * .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md. + */ +function stubClient(handlers: Record) => unknown>) { + const calls: Array<{command: string; input: Record}> = [] + const client = { + async send(command: {constructor: {name: string}; input: Record}) { + const name = command.constructor.name + calls.push({command: name, input: command.input}) + const handler = handlers[name] + if (!handler) throw new Error(`unexpected command ${name}`) + return handler(command.input) + }, + } + return {client: client as never, calls} +} + +describe('AwsLogsAdapter resources', () => { + test('normalizes log groups as cloud resources', async () => { + const {client} = stubClient({ + DescribeLogGroupsCommand: () => ({ + logGroups: [{ + logGroupName: '/floci/probe', + creationTime: 1785260691820, + arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe', + storedBytes: 0, + metricFilterCount: 0, + }], + }), + }) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.list()).resolves.toMatchObject([{ + id: '/floci/probe', + name: '/floci/probe', + cloud: 'aws', + service: 'logs', + type: 'log-group', + createdAt: new Date(1785260691820).toISOString(), + }]) + }) + + // The runtime filters natively by prefix, so `search` must not be a + // client-side contains — that would silently disagree with the field label. + test('search is sent as logGroupNamePrefix', async () => { + const {client, calls} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})}) + const adapter = new AwsLogsAdapter(client) + + await adapter.list({search: '/floci'}) + + expect(calls[0].input).toMatchObject({logGroupNamePrefix: '/floci'}) + }) + + test('get returns null for a group the runtime does not have', async () => { + const {client} = stubClient({DescribeLogGroupsCommand: () => ({logGroups: []})}) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.get('/nope')).resolves.toBeNull() + }) + + // DescribeLogGroups is a prefix match, so a longer name starting with the + // requested one would otherwise be returned as if it were an exact hit. + test('get matches the name exactly, not by prefix', async () => { + const {client} = stubClient({ + DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/probe-two', creationTime: 1}]}), + }) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.get('/floci/probe')).resolves.toBeNull() + }) + + test('create and delete issue the right commands', async () => { + const {client, calls} = stubClient({ + CreateLogGroupCommand: () => ({}), + DeleteLogGroupCommand: () => ({}), + DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/floci/new', creationTime: 1}]}), + }) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.create({values: {name: '/floci/new'}})).resolves.toMatchObject({id: '/floci/new'}) + await adapter.delete('/floci/new') + + expect(calls.map((call) => call.command)).toEqual([ + 'CreateLogGroupCommand', + 'DescribeLogGroupsCommand', + 'DeleteLogGroupCommand', + ]) + }) + + /** + * `creationTime` is the field the SDK models and real AWS returns. The + * local runtime sends `createdTime`, which the SDK deserializer discards — + * so createdAt is legitimately null against the emulator, and a stub that + * feeds `createdTime` is testing a shape the SDK can never produce. That + * mistake shipped here once already; this pins the real contract. + */ + test('reads the creation timestamp the SDK actually models', async () => { + const {client} = stubClient({ + DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a', creationTime: 1785260691820}]}), + }) + + await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([ + {createdAt: new Date(1785260691820).toISOString()}, + ]) + }) + + test('a group with no modelled timestamp reports null rather than a bogus date', async () => { + const {client} = stubClient({ + DescribeLogGroupsCommand: () => ({logGroups: [{logGroupName: '/a'}]}), + }) + + await expect(new AwsLogsAdapter(client).list()).resolves.toMatchObject([{createdAt: null}]) + }) + + test('create rejects a blank name before calling the runtime', async () => { + const {client, calls} = stubClient({}) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.create({values: {name: ' '}})).rejects.toThrow() + expect(calls).toEqual([]) + }) +}) + +describe('AwsLogsAdapter queryLogs', () => { + test('starts a query and maps result fields into row objects', async () => { + const {client, calls} = stubClient({ + StartQueryCommand: () => ({queryId: 'q-1'}), + GetQueryResultsCommand: () => ({ + status: 'Complete', + results: [ + [{field: '@timestamp', value: '2026-01-01 00:00:00.000'}, {field: '@message', value: 'hello'}], + ], + }), + }) + const adapter = new AwsLogsAdapter(client) + + const result = await adapter.queryLogs('/floci/probe', { + queryString: 'fields @timestamp, @message', + startTime: 1000, + endTime: 2000, + limit: 20, + }) + + expect(result).toEqual({ + queryId: 'q-1', + status: 'Complete', + rows: [{'@timestamp': '2026-01-01 00:00:00.000', '@message': 'hello'}], + }) + expect(calls[0]).toMatchObject({ + command: 'StartQueryCommand', + input: {logGroupName: '/floci/probe', startTime: 1000, endTime: 2000, queryString: 'fields @timestamp, @message', limit: 20}, + }) + expect(calls[1].command).toBe('GetQueryResultsCommand') + }) + + // FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS defaults to 0, so a + // real completion normally takes one GetQueryResults call — but a positive + // delay (or real AWS) makes the first call(s) come back Running, so the + // adapter must keep polling rather than returning a stale in-progress result. + test('polls until the query leaves Running/Scheduled', async () => { + let getCalls = 0 + const {client, calls} = stubClient({ + StartQueryCommand: () => ({queryId: 'q-2'}), + GetQueryResultsCommand: () => { + getCalls += 1 + if (getCalls < 3) return {status: getCalls === 1 ? 'Scheduled' : 'Running', results: []} + return {status: 'Complete', results: [[{field: '@message', value: 'done'}]]} + }, + }) + const adapter = new AwsLogsAdapter(client) + + const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}) + + expect(result).toEqual({queryId: 'q-2', status: 'Complete', rows: [{'@message': 'done'}]}) + expect(calls.filter((call) => call.command === 'GetQueryResultsCommand')).toHaveLength(3) + }) + + // The P1 this test pins: if the poll deadline is hit while the query is + // still Running/Scheduled, the caller must still get the queryId back — + // dropping it here would make that still-running query unrecoverable, even + // though Floci (or real AWS) keeps executing it server-side. + test('still returns the query id when the poll deadline is hit before completion', async () => { + const {client} = stubClient({ + StartQueryCommand: () => ({queryId: 'q-3'}), + GetQueryResultsCommand: () => ({status: 'Running', results: []}), + }) + const adapter = new AwsLogsAdapter(client, {timeoutMs: 30, intervalMs: 10}) + + const result = await adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1}) + + expect(result.queryId).toBe('q-3') + expect(result.status).toBe('Running') + }) + + test('rejects a blank query string before calling the runtime', async () => { + const {client, calls} = stubClient({}) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.queryLogs('/floci/probe', {queryString: ' ', startTime: 0, endTime: 1})) + .rejects.toThrow() + expect(calls).toEqual([]) + }) + + test('raises when the runtime does not hand back a query id', async () => { + const {client} = stubClient({StartQueryCommand: () => ({})}) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.queryLogs('/floci/probe', {queryString: 'fields @message', startTime: 0, endTime: 1})) + .rejects.toThrow() + }) +}) + +describe('AwsLogsAdapter documents', () => { + test('lists log streams as collections, newest activity first', async () => { + const {client, calls} = stubClient({ + DescribeLogStreamsCommand: () => ({ + logStreams: [{ + logStreamName: 'stream-a', + arn: 'arn:aws:logs:us-east-1:000000000000:log-group:/floci/probe:log-stream:stream-a', + creationTime: 1785260702176, + storedBytes: 70, + firstEventTimestamp: 1785260000000, + lastEventTimestamp: 1785260001000, + }], + nextToken: 'page-2', + }), + }) + const adapter = new AwsLogsAdapter(client) + + const page = await adapter.documents.listCollections('/floci/probe') + + expect(page).toMatchObject({ + items: [{id: 'stream-a', name: 'stream-a', parentId: '/floci/probe'}], + nextCursor: 'page-2', + }) + expect(calls[0].input).toMatchObject({ + logGroupName: '/floci/probe', + orderBy: 'LastEventTime', + descending: true, + }) + }) + + /** + * Real AWS caps DescribeLogStreams Limit at 50 and rejects anything higher + * with a ValidationException. The SPI defaults to 100 and permits 1000, and + * the local runtime does not enforce the cap — so an unclamped value passes + * every local test and 400s in production. Encode the provider's rule. + */ + test('clamps the stream page size to the AWS maximum of 50', async () => { + const {client, calls} = stubClient({DescribeLogStreamsCommand: () => ({logStreams: []})}) + const adapter = new AwsLogsAdapter(client) + + await adapter.documents.listCollections('/floci/probe') + await adapter.documents.listCollections('/floci/probe', {limit: 1000}) + await adapter.documents.listCollections('/floci/probe', {limit: 25}) + + expect(calls.map((call) => call.input.limit)).toEqual([50, 50, 25]) + }) + + test('reads log events as items, oldest first', async () => { + const {client, calls} = stubClient({ + GetLogEventsCommand: () => ({ + events: [ + {timestamp: 1785260000000, message: 'hello one', ingestionTime: 1785260702192}, + {timestamp: 1785260001000, message: 'hello two', ingestionTime: 1785260702192}, + ], + nextForwardToken: 'f/2', + nextBackwardToken: 'b/0', + }), + }) + const adapter = new AwsLogsAdapter(client) + + const page = await adapter.documents.listItems('/floci/probe', 'stream-a') + + // Real AWS gives events no id, so it is derived from timestamp and + // position. Two events sharing a millisecond still get distinct ids. + expect(page.items).toMatchObject([ + { + id: '1785260000000-0', + collectionId: 'stream-a', + timestamp: new Date(1785260000000).toISOString(), + body: {message: 'hello one'}, + }, + {id: '1785260001000-1', collectionId: 'stream-a'}, + ]) + expect(page.nextCursor).toBe('f/2') + // Without startFromHead the runtime returns the newest page, so paging + // forward from a fresh cursor would skip the history. + expect(calls[0].input).toMatchObject({startFromHead: true}) + }) + + /** + * The termination rule. GetLogEvents echoes the supplied token back when + * there is nothing further, so a cursor loop that trusts a non-null token + * never ends. Verified against the runtime on 2026-07-28. + */ + test('stops paging when the runtime echoes the cursor back', async () => { + const {client} = stubClient({ + GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/2', nextBackwardToken: 'b/2'}), + }) + const adapter = new AwsLogsAdapter(client) + + const page = await adapter.documents.listItems('/floci/probe', 'stream-a', {cursor: 'f/2'}) + + expect(page.items).toEqual([]) + expect(page.nextCursor).toBeNull() + }) + + test('an empty first page terminates too', async () => { + const {client} = stubClient({ + GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}), + }) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.documents.listItems('/floci/probe', 'stream-a')).resolves.toMatchObject({nextCursor: null}) + }) + + test('creates and deletes a log stream', async () => { + const {client, calls} = stubClient({ + CreateLogStreamCommand: () => ({}), + DeleteLogStreamCommand: () => ({}), + DescribeLogStreamsCommand: () => ({ + logStreams: [{logStreamName: 'stream-b', creationTime: 1785260702176}], + }), + }) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.documents.createCollection!('/floci/probe', {values: {name: 'stream-b'}})) + .resolves.toMatchObject({id: 'stream-b', parentId: '/floci/probe'}) + await adapter.documents.deleteCollection!('/floci/probe', 'stream-b') + + expect(calls.map((call) => call.command)).toEqual([ + 'CreateLogStreamCommand', + 'DescribeLogStreamsCommand', + 'DeleteLogStreamCommand', + ]) + }) + + // Provider behaviour, verified: a missing group raises, a missing stream + // returns an empty page. The contract mirrors that rather than smoothing it. + test('a missing stream yields an empty page rather than an error', async () => { + const {client} = stubClient({ + GetLogEventsCommand: () => ({events: [], nextForwardToken: 'f/0', nextBackwardToken: 'b/0'}), + }) + const adapter = new AwsLogsAdapter(client) + + await expect(adapter.documents.listItems('/floci/probe', 'nope')).resolves.toEqual({items: [], nextCursor: null}) + }) + + test('the store advertises no item writes', () => { + const {client} = stubClient({}) + const adapter = new AwsLogsAdapter(client) + + expect(adapter.documents.putItem).toBeUndefined() + expect(adapter.documents.deleteItem).toBeUndefined() + expect(adapter.documents.queryItems).toBeUndefined() + }) +}) diff --git a/packages/api/src/adapter-aws/AwsLogsAdapter.ts b/packages/api/src/adapter-aws/AwsLogsAdapter.ts new file mode 100644 index 0000000..a2402a4 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsLogsAdapter.ts @@ -0,0 +1,304 @@ +import { + type CloudWatchLogsClient, + CreateLogGroupCommand, + CreateLogStreamCommand, + DeleteLogGroupCommand, + DeleteLogStreamCommand, + DescribeLogGroupsCommand, + DescribeLogStreamsCommand, + GetLogEventsCommand, + GetQueryResultsCommand, + StartQueryCommand, +} from '@aws-sdk/client-cloudwatch-logs' +import type { + ChildCollection, + ChildItem, + CollectionPage, + DocumentStoreAdapter, + PageQuery, +} from '../cloud-spi/childCollections' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' +import {awsLogsSchema} from '../cloud-spi/logsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + LogsInsightsQueryInput, + LogsInsightsQueryResult, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +// Real StartQuery is async and queries can run up to an hour server-side. Floci +// completes instantly by default (FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS=0), +// but this poll loop is bounded rather than unconditional so a future non-zero delay, +// or a real backing AWS account, cannot hang the request indefinitely. +const QUERY_POLL_INTERVAL_MS = 150 +const QUERY_POLL_TIMEOUT_MS = 10_000 +const ACTIVE_QUERY_STATUSES = new Set(['Running', 'Scheduled']) + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function toRow(fields: Array<{field?: string; value?: string}>): Record { + const row: Record = {} + for (const {field, value} of fields) { + if (field) row[field] = value ?? '' + } + return row +} + +/** + * `creationTime` is the field real AWS returns and the only one the SDK models. + * + * The local runtime sends `createdTime` instead, and the SDK deserializer drops + * wire fields it does not model — so that value is unrecoverable here and + * `createdAt` is null for every log group and stream against the emulator. + * Reading the emulator's spelling instead would be dead code in production and + * would only ever pass against hand-built stubs, which is how this shipped + * wrong the first time. See + * .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md — same failure + * mode, different field. + */ +interface LogGroupShape { + logGroupName?: string + creationTime?: number + arn?: string + storedBytes?: number + metricFilterCount?: number + retentionInDays?: number +} + +function createdAtOf(record: {creationTime?: number}): string | null { + return record.creationTime ? new Date(record.creationTime).toISOString() : null +} + +export class AwsLogsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'logs' as const + + readonly documents: DocumentStoreAdapter + + constructor( + private readonly client: CloudWatchLogsClient, + /** Overridable so tests can shrink the wait instead of taking QUERY_POLL_TIMEOUT_MS for real. */ + private readonly queryPoll: {timeoutMs?: number; intervalMs?: number} = {}, + ) { + this.documents = new LogGroupDocumentStore(client) + } + + schema(): ServiceSchema { + return awsLogsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const prefix = query.search?.trim() + const response = await this.client.send( + new DescribeLogGroupsCommand(prefix ? {logGroupNamePrefix: prefix} : {}), + ) + return (response.logGroups ?? []).map(toResource) + } + + async get(id: string): Promise { + const group = await this.describeExact(id) + return group ? toResource(group) : null + } + + async create(input: CreateResourceInput): Promise { + const name = String(input.values.name ?? '').trim() + if (!name) throw new ValidationError('A log group name is required.') + + await this.client.send(new CreateLogGroupCommand({logGroupName: name})) + + const group = await this.describeExact(name) + if (!group) throw new NotFoundError(`Log group ${name} was created but could not be read back.`) + return toResource(group) + } + + async delete(id: string): Promise { + await this.client.send(new DeleteLogGroupCommand({logGroupName: id})) + } + + async queryLogs(logGroupName: string, input: LogsInsightsQueryInput): Promise { + const queryString = input.queryString.trim() + if (!queryString) throw new ValidationError('A query string is required.') + + const {queryId} = await this.client.send( + new StartQueryCommand({ + logGroupName, + startTime: input.startTime, + endTime: input.endTime, + queryString, + limit: input.limit, + }), + ) + if (!queryId) throw new NotFoundError(`Floci did not return a query id for ${logGroupName}.`) + + const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS) + const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS + let response = await this.client.send(new GetQueryResultsCommand({queryId})) + while (ACTIVE_QUERY_STATUSES.has(response.status ?? '') && Date.now() < deadline) { + await sleep(pollInterval) + response = await this.client.send(new GetQueryResultsCommand({queryId})) + } + + return { + queryId, + status: response.status ?? 'Unknown', + rows: (response.results ?? []).map(toRow), + } + } + + /** + * DescribeLogGroups filters by prefix, so `/floci/probe` also returns + * `/floci/probe-two`. Filtering to an exact match keeps `get` honest. + */ + private async describeExact(name: string): Promise { + const response = await this.client.send(new DescribeLogGroupsCommand({logGroupNamePrefix: name})) + return (response.logGroups ?? []).find((group) => group.logGroupName === name) ?? null + } +} + +/** AWS rejects a DescribeLogStreams Limit above this. GetLogEvents allows 10,000. */ +const MAX_DESCRIBE_LOG_STREAMS_LIMIT = 50 + +interface LogStreamShape { + logStreamName?: string + arn?: string + creationTime?: number + storedBytes?: number + firstEventTimestamp?: number + lastEventTimestamp?: number +} + +/** + * Streams are the collection level, events the leaf. There is deliberately no + * putItem/deleteItem/queryItems: AWS has no DeleteLogEvent, and PutLogEvents is + * not something a resource browser should expose. + */ +class LogGroupDocumentStore implements DocumentStoreAdapter { + constructor(private readonly client: CloudWatchLogsClient) {} + + async listCollections(resourceId: string, page: PageQuery = {}): Promise> { + const response = await this.client.send( + new DescribeLogStreamsCommand({ + logGroupName: resourceId, + orderBy: 'LastEventTime', + descending: true, + // Real AWS caps this at 50 and rejects anything higher with a + // ValidationException; the SPI defaults to 100 and permits 1000. + // The local runtime does not enforce the cap, so an unclamped + // value passes here and 400s in production. + // + // Clamping is safe where rejecting would be wrong: `limit` is a + // maximum, not a quota, and the response still carries + // `nextCursor`, so a short page is honest rather than a silent + // truncation. That is the opposite of `clampLimit`, which + // rejects — there the caller has no cursor to tell them more + // exists. + limit: Math.min(page.limit ?? MAX_DESCRIBE_LOG_STREAMS_LIMIT, MAX_DESCRIBE_LOG_STREAMS_LIMIT), + nextToken: page.cursor, + }), + ) + return { + items: (response.logStreams ?? []).map((stream) => toCollection(stream, resourceId)), + nextCursor: response.nextToken ?? null, + } + } + + async createCollection(resourceId: string, input: CreateResourceInput): Promise { + const name = String(input.values.name ?? '').trim() + if (!name) throw new ValidationError('A log stream name is required.') + + await this.client.send(new CreateLogStreamCommand({logGroupName: resourceId, logStreamName: name})) + + const response = await this.client.send( + new DescribeLogStreamsCommand({logGroupName: resourceId, logStreamNamePrefix: name}), + ) + const stream = (response.logStreams ?? []).find((candidate) => candidate.logStreamName === name) + if (!stream) throw new NotFoundError(`Log stream ${name} was created but could not be read back.`) + return toCollection(stream, resourceId) + } + + async deleteCollection(resourceId: string, collectionId: string): Promise { + await this.client.send(new DeleteLogStreamCommand({logGroupName: resourceId, logStreamName: collectionId})) + } + + async listItems(resourceId: string, collectionId: string, page: PageQuery = {}): Promise> { + const response = await this.client.send( + new GetLogEventsCommand({ + logGroupName: resourceId, + logStreamName: collectionId, + // Without this the runtime returns the newest page, so paging + // forward from a fresh cursor would skip the history. + startFromHead: true, + limit: page.limit, + nextToken: page.cursor, + }), + ) + + const events = response.events ?? [] + const forward = response.nextForwardToken ?? null + + return { + items: events.map((event, index) => ({ + // Real AWS gives log events no identifier — the SDK models only + // timestamp/message/ingestionTime, and the emulator's `eventId` + // is discarded by the deserializer. Position within the page is + // the only thing available, so it is not stable across calls and + // must not be used as a durable key. + id: `${event.timestamp ?? 0}-${index}`, + collectionId, + timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null, + body: {message: event.message ?? ''}, + metadata: { + ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null, + }, + })), + // GetLogEvents echoes the supplied token back at the end of the + // stream instead of returning null, so a naive loop never + // terminates. Verified against the runtime on 2026-07-28. + nextCursor: events.length === 0 || forward === page.cursor ? null : forward, + } + } +} + +function toCollection(stream: LogStreamShape, parentId: string): ChildCollection { + const name = stream.logStreamName ?? '' + return { + id: name, + name, + parentId, + createdAt: createdAtOf(stream), + metadata: { + arn: stream.arn, + storedBytes: stream.storedBytes ?? 0, + firstEventTimestamp: stream.firstEventTimestamp + ? new Date(stream.firstEventTimestamp).toISOString() + : null, + lastEventTimestamp: stream.lastEventTimestamp + ? new Date(stream.lastEventTimestamp).toISOString() + : null, + }, + } +} + +function toResource(group: LogGroupShape): CloudResource { + const name = group.logGroupName ?? '' + return { + id: name, + name, + cloud: 'aws', + service: 'logs', + type: 'log-group', + region: null, + createdAt: createdAtOf(group), + metadata: { + arn: group.arn, + storedBytes: group.storedBytes ?? 0, + metricFilterCount: group.metricFilterCount ?? 0, + retentionInDays: group.retentionInDays ?? null, + }, + } +} diff --git a/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts b/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts new file mode 100644 index 0000000..ade2d5b --- /dev/null +++ b/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts @@ -0,0 +1,78 @@ +import {NotSupportedError} from '../cloud-spi/errors' +import {awsNetworkingSchema} from '../cloud-spi/networkingSchema' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' +import {ec2Service, type Ec2Tag, type Ec2Vpc} from '../services/ec2' + +type Ec2NetworkingServiceShape = { + listVpcs(): Promise +} + +/** + * Networking adapter for AWS. + * + * Exposes VPCs as the top-level resource in the generic Cloud Explorer table. + * All VPC, subnet, security-group, key-pair, IGW, route-table, and EIP management + * is handled by the dedicated NetworkingPanel component which calls the EC2 API + * routes directly — this adapter only drives the resource table and sidebar entry. + */ +export class AwsNetworkingAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'networking' as const + + constructor(private readonly service_: Ec2NetworkingServiceShape = ec2Service) {} + + schema(): ServiceSchema { + return awsNetworkingSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const vpcs = await this.service_.listVpcs() + return filterBySearch(vpcs.map(vpcToResource), query.search) + } + + async get(id: string): Promise { + // listVpcs is the only read the EC2 service exposes here, and a local + // emulator's VPC count is small enough that filtering it is cheaper than + // adding a describe path through the legacy service layer. + const vpcs = await this.service_.listVpcs() + const match = vpcs.find((vpc) => vpc.vpcId === id) + return match ? vpcToResource(match) : null + } + + async create(_input: CreateResourceInput): Promise { + throw new NotSupportedError('Use the Networking panel to create VPCs and networking resources.') + } + + async delete(_id: string): Promise { + throw new NotSupportedError('Use the Networking panel to delete networking resources.') + } +} + +function vpcToResource(vpc: Ec2Vpc): CloudResource { + return { + id: vpc.vpcId, + name: nameTag(vpc.tags) || vpc.vpcId, + cloud: 'aws', + service: 'networking', + type: 'vpc', + region: null, + createdAt: null, + status: vpc.state ?? null, + version: vpc.cidrBlock, + metadata: { + cidrBlock: vpc.cidrBlock, + isDefault: vpc.isDefault, + tags: vpc.tags, + }, + } +} + +function nameTag(tags: Ec2Tag[]): string { + return tags.find((t) => t.key === 'Name')?.value ?? '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((r) => r.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/adapter-aws/AwsParameterStoreAdapter.test.ts b/packages/api/src/adapter-aws/AwsParameterStoreAdapter.test.ts new file mode 100644 index 0000000..102bb77 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsParameterStoreAdapter.test.ts @@ -0,0 +1,300 @@ +import {describe, expect, test} from 'bun:test' +import { + AddTagsToResourceCommand, + DeleteParameterCommand, + DescribeParametersCommand, + ListTagsForResourceCommand, + PutParameterCommand, + RemoveTagsFromResourceCommand, + type SSMClient, +} from '@aws-sdk/client-ssm' +import {AwsParameterStoreAdapter} from './AwsParameterStoreAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +/** Minimal SSMClient stub that records the commands it was sent. */ +function stubSsm(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as SSMClient + return {client, sent} +} + +const plain = { + Name: '/floci/app/region', + Type: 'String', + Version: 1, + LastModifiedDate: new Date('2026-07-28T10:00:00.000Z'), + Description: 'deployment region', + DataType: 'text', + Tier: 'Standard', +} + +const secure = { + Name: '/floci/app/token', + Type: 'SecureString', + Version: 3, + LastModifiedDate: new Date('2026-07-28T11:00:00.000Z'), + DataType: 'text', +} + +function runtimeStub(overrides: {describe?: SendResult; tags?: SendResult} = {}) { + return stubSsm((command) => { + if (command instanceof DescribeParametersCommand) { + return overrides.describe ?? {Parameters: [plain, secure]} + } + if (command instanceof ListTagsForResourceCommand) { + return overrides.tags ?? {TagList: []} + } + if (command instanceof PutParameterCommand) return {Version: 1} + return {} + }) +} + +describe('AwsParameterStoreAdapter', () => { + test('identifies itself as the AWS Parameter Store adapter', () => { + const adapter = new AwsParameterStoreAdapter(runtimeStub().client) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('parameters') + expect(adapter.schema().displayName).toBe('SSM Parameter Store') + }) + + test('lists parameters with their metadata', async () => { + const {client, sent} = runtimeStub() + const resources = await new AwsParameterStoreAdapter(client).list() + + expect(sent[0]).toBeInstanceOf(DescribeParametersCommand) + expect(resources).toHaveLength(2) + expect(resources[0]).toMatchObject({ + id: '/floci/app/region', + name: '/floci/app/region', + cloud: 'aws', + service: 'parameters', + type: 'parameter', + createdAt: '2026-07-28T10:00:00.000Z', + }) + expect(resources[0]?.metadata).toMatchObject({ + parameterType: 'String', + version: 1, + description: 'deployment region', + dataType: 'text', + tier: 'Standard', + }) + }) + + test('never exposes a parameter value, not even for a plain String', async () => { + // metadata reaches the inspector, the query cache and telemetry, so a + // value must not travel with the resource. This is the same rule both + // secrets adapters pin, and it matters more here: the local runtime + // returns SecureString plaintext even without WithDecryption, so relying + // on the runtime to withhold it would leak. + const {client, sent} = runtimeStub() + const adapter = new AwsParameterStoreAdapter(client) + + const resources = await adapter.list() + const inspected = await adapter.get('/floci/app/token') + + for (const resource of [...resources, inspected]) { + expect(JSON.stringify(resource)).not.toContain('Value') + expect(resource?.metadata.value).toBeUndefined() + } + // The value is never even requested. + expect(sent.every((command) => command.constructor.name !== 'GetParameterCommand')).toBe(true) + }) + + test('marks that the value is withheld so the UI can explain the empty field', async () => { + const {client} = runtimeStub() + const [resource] = await new AwsParameterStoreAdapter(client).list() + + expect(resource?.metadata.valueWithheld).toBe(true) + }) + + test('follows NextToken until the runtime stops paging', async () => { + // The local runtime ignores MaxResults and returns everything in one + // response, so only a stub can prove the loop works. Real SSM pages at 10. + let call = 0 + const {client, sent} = stubSsm((command) => { + if (command instanceof ListTagsForResourceCommand) return {TagList: []} + call += 1 + if (call === 1) return {Parameters: [plain], NextToken: 'page-2'} + if (call === 2) return {Parameters: [secure], NextToken: 'page-3'} + return {Parameters: [{...plain, Name: '/floci/app/third'}]} + }) + + const resources = await new AwsParameterStoreAdapter(client).list() + + expect(resources.map((r) => r.id)).toEqual(['/floci/app/region', '/floci/app/token', '/floci/app/third']) + const describes = sent.filter((c) => c instanceof DescribeParametersCommand) as DescribeParametersCommand[] + expect(describes).toHaveLength(3) + expect(describes[1]?.input.NextToken).toBe('page-2') + expect(describes[2]?.input.NextToken).toBe('page-3') + }) + + test('filters the list by name or description', async () => { + const adapter = new AwsParameterStoreAdapter(runtimeStub().client) + + await expect(adapter.list({search: 'token'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'deployment'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a parameter through a server-side name filter', async () => { + const {client, sent} = runtimeStub({describe: {Parameters: [plain]}}) + const resource = await new AwsParameterStoreAdapter(client).get('/floci/app/region') + + const command = sent[0] as DescribeParametersCommand + expect(command).toBeInstanceOf(DescribeParametersCommand) + expect(command.input.ParameterFilters).toEqual([ + {Key: 'Name', Option: 'Equals', Values: ['/floci/app/region']}, + ]) + expect(resource?.id).toBe('/floci/app/region') + }) + + test('returns null when the name filter matches nothing', async () => { + const {client} = runtimeStub({describe: {Parameters: []}}) + await expect(new AwsParameterStoreAdapter(client).get('/nope')).resolves.toBeNull() + }) + + test('returns null on ParameterNotFound even though the runtime answers 400', async () => { + // SSM reports a missing parameter as ParameterNotFound with HTTP 400, not + // 404, so matching on the status alone would rethrow instead of returning + // null and the inspector would show a runtime error for a missing row. + const {client} = stubSsm(() => { + throw Object.assign(new Error('Parameter /nope not found.'), { + name: 'ParameterNotFound', + $metadata: {httpStatusCode: 400}, + }) + }) + await expect(new AwsParameterStoreAdapter(client).get('/nope')).resolves.toBeNull() + }) + + test('rethrows a failure that is not a missing parameter', async () => { + const {client} = stubSsm(() => { + throw Object.assign(new Error('AccessDenied'), { + name: 'AccessDeniedException', + $metadata: {httpStatusCode: 403}, + }) + }) + await expect(new AwsParameterStoreAdapter(client).get('/floci/app/region')).rejects.toThrow('AccessDenied') + }) + + test('creates a parameter without Overwrite so an existing name is a conflict', async () => { + // Overwrite:true would silently replace a parameter the user did not mean + // to touch, which is data loss dressed up as a create. + const {client, sent} = runtimeStub({describe: {Parameters: [plain]}}) + await new AwsParameterStoreAdapter(client).create({ + values: {name: '/floci/app/region', value: 'eu-west-1', type: 'String', description: 'deployment region'}, + }) + + const command = sent[0] as PutParameterCommand + expect(command).toBeInstanceOf(PutParameterCommand) + expect(command.input.Name).toBe('/floci/app/region') + expect(command.input.Value).toBe('eu-west-1') + expect(command.input.Type).toBe('String') + expect(command.input.Overwrite).toBeUndefined() + }) + + test('returns the created parameter without echoing the value back', async () => { + const {client} = runtimeStub({describe: {Parameters: [plain]}}) + const resource = await new AwsParameterStoreAdapter(client).create({ + values: {name: '/floci/app/region', value: 'eu-west-1', type: 'String'}, + }) + + expect(resource.id).toBe('/floci/app/region') + expect(JSON.stringify(resource)).not.toContain('eu-west-1') + }) + + test('requires the fields the schema marks required', async () => { + const adapter = new AwsParameterStoreAdapter(runtimeStub().client) + + const cases: Array<[Record, string]> = [ + [{}, 'name is required'], + [{name: '/floci/app/region'}, 'value is required'], + ] + + for (const [values, message] of cases) { + await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('rejects a parameter type the schema does not offer', async () => { + const adapter = new AwsParameterStoreAdapter(runtimeStub().client) + + await expect( + adapter.create({values: {name: '/a', value: 'b', type: 'Encrypted'}}), + ).rejects.toThrow(ValidationError) + }) + + test('deletes a parameter by name', async () => { + const {client, sent} = stubSsm(() => ({})) + await new AwsParameterStoreAdapter(client).delete('/floci/app/region') + + const command = sent[0] as DeleteParameterCommand + expect(command).toBeInstanceOf(DeleteParameterCommand) + expect(command.input.Name).toBe('/floci/app/region') + }) + + test('surfaces parameter tags under metadata', async () => { + const {client} = runtimeStub({ + describe: {Parameters: [plain]}, + tags: {TagList: [{Key: 'env', Value: 'prod'}]}, + }) + const [resource] = await new AwsParameterStoreAdapter(client).list() + + expect(resource?.metadata.tags).toEqual([{key: 'env', value: 'prod'}]) + }) + + test('still lists parameters when the tag lookup fails', async () => { + // list() builds every row through Promise.all, so an unisolated tag + // failure — throttling, transient IAM, a delete race — would reject the + // whole view and show nothing rather than metadata without tags. + const {client} = stubSsm((command) => { + if (command instanceof DescribeParametersCommand) return {Parameters: [plain]} + throw Object.assign(new Error('Rate exceeded'), { + name: 'ThrottlingException', + $metadata: {httpStatusCode: 400}, + }) + }) + + const [resource] = await new AwsParameterStoreAdapter(client).list() + + expect(resource?.id).toBe('/floci/app/region') + expect(resource?.metadata.tags).toEqual([]) + // Degraded, not silent: the UI can say tags could not be read. + expect(resource?.metadata.tagsUnavailable).toBe(true) + }) + + test('does not claim tags are unavailable when they are simply empty', async () => { + const {client} = runtimeStub({describe: {Parameters: [plain]}, tags: {TagList: []}}) + const [resource] = await new AwsParameterStoreAdapter(client).list() + + expect(resource?.metadata.tags).toEqual([]) + expect(resource?.metadata.tagsUnavailable).toBeUndefined() + }) + + test('adds and removes tags against the Parameter resource type', async () => { + const {client, sent} = stubSsm(() => ({})) + await new AwsParameterStoreAdapter(client).updateTags('/floci/app/region', {env: 'prod', stale: null}) + + const added = sent.find((c) => c instanceof AddTagsToResourceCommand) as AddTagsToResourceCommand + const removed = sent.find((c) => c instanceof RemoveTagsFromResourceCommand) as RemoveTagsFromResourceCommand + + expect(added.input.ResourceType).toBe('Parameter') + expect(added.input.ResourceId).toBe('/floci/app/region') + expect(added.input.Tags).toEqual([{Key: 'env', Value: 'prod'}]) + expect(removed.input.TagKeys).toEqual(['stale']) + }) + + test('skips the tag call when it has nothing to send', async () => { + const {client, sent} = stubSsm(() => ({})) + await new AwsParameterStoreAdapter(client).updateTags('/floci/app/region', {env: 'prod'}) + + expect(sent.some((c) => c instanceof RemoveTagsFromResourceCommand)).toBe(false) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsParameterStoreAdapter.ts b/packages/api/src/adapter-aws/AwsParameterStoreAdapter.ts new file mode 100644 index 0000000..19481fd --- /dev/null +++ b/packages/api/src/adapter-aws/AwsParameterStoreAdapter.ts @@ -0,0 +1,236 @@ +import { + AddTagsToResourceCommand, + DeleteParameterCommand, + DescribeParametersCommand, + type DescribeParametersCommandInput, + ListTagsForResourceCommand, + type ParameterMetadata, + PutParameterCommand, + RemoveTagsFromResourceCommand, + type SSMClient, +} from '@aws-sdk/client-ssm' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {awsParametersSchema, PARAMETER_NAME_MAX_LENGTH, PARAMETER_TYPES} from '../cloud-spi/parametersSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +type ParameterTag = {key: string; value: string} + +/** Tags plus whether the lookup itself failed, so the two stay distinguishable. */ +type TagLookup = {tags: ParameterTag[]; unavailable?: true} + +/** + * SSM reports a missing parameter as `ParameterNotFound` with HTTP **400**, not + * 404, so `get` matches on the error name. Matching the status instead would + * rethrow and show a runtime error where the console expects an empty result. + */ +const NOT_FOUND_NAME = 'ParameterNotFound' + +export class AwsParameterStoreAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'parameters' as const + + constructor(private readonly ssm: SSMClient) {} + + schema(): ServiceSchema { + return awsParametersSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const parameters = await this.describeAll() + const resources = await Promise.all(parameters.map((parameter) => this.toResource(parameter))) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + try { + const [parameter] = await this.describe({ + ParameterFilters: [{Key: 'Name', Option: 'Equals', Values: [id]}], + }) + return parameter ? await this.toResource(parameter) : null + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + /** + * Creates without `Overwrite`, so an existing name fails as a conflict rather + * than silently replacing a parameter the caller did not mean to touch. + */ + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (name.length > PARAMETER_NAME_MAX_LENGTH) { + throw new ValidationError(`name must be ${PARAMETER_NAME_MAX_LENGTH} characters or fewer`) + } + const value = requiredString(input.values.value, 'value') + const type = oneOf(input.values.type, PARAMETER_TYPES, 'type') + const description = optionalString(input.values.description, 'description') + + await this.ssm.send( + new PutParameterCommand({ + Name: name, + Value: value, + Type: type, + ...(description ? {Description: description} : {}), + }), + ) + + // Read the metadata back rather than echoing the input, so the returned + // resource reflects the runtime — and carries no value. + const created = await this.get(name) + if (!created) throw new RuntimeError(`Parameter ${name} was created but could not be read back`) + return created + } + + async delete(id: string): Promise { + await this.ssm.send(new DeleteParameterCommand({Name: id})) + } + + async updateTags(id: string, tags: Record): Promise { + const added = Object.entries(tags).filter((entry): entry is [string, string] => entry[1] !== null) + const removed = Object.keys(tags).filter((key) => tags[key] === null) + + if (added.length > 0) { + await this.ssm.send( + new AddTagsToResourceCommand({ + ResourceType: 'Parameter', + ResourceId: id, + Tags: added.map(([key, value]) => ({Key: key, Value: value})), + }), + ) + } + if (removed.length > 0) { + await this.ssm.send( + new RemoveTagsFromResourceCommand({ResourceType: 'Parameter', ResourceId: id, TagKeys: removed}), + ) + } + } + + /** + * The local runtime ignores MaxResults and returns everything in one response, + * so this loop is only exercised by tests. Real SSM pages at 10. + */ + private async describeAll(): Promise { + const parameters: ParameterMetadata[] = [] + let token: string | undefined + + do { + const page = await this.ssm.send( + new DescribeParametersCommand(token ? {NextToken: token} : {}), + ) + parameters.push(...(page.Parameters ?? [])) + token = page.NextToken + } while (token) + + return parameters + } + + private async describe(input: DescribeParametersCommandInput): Promise { + const res = await this.ssm.send(new DescribeParametersCommand(input)) + return res.Parameters ?? [] + } + + private async toResource(parameter: ParameterMetadata): Promise { + const name = parameter.Name ?? '' + return toResource(parameter, await this.getTags(name)) + } + + /** + * Tags are enrichment, so a failure here degrades the row instead of failing + * the request. `list` builds every row through `Promise.all`, so an unisolated + * throttle or a delete race would reject the whole view and show nothing. + * + * The failure is reported as `tagsUnavailable` rather than swallowed, so an + * empty tag list and an unreadable one stay distinguishable. + */ + private async getTags(name: string): Promise { + try { + const res = await this.ssm.send( + new ListTagsForResourceCommand({ResourceType: 'Parameter', ResourceId: name}), + ) + return {tags: (res.TagList ?? []).map((tag) => ({key: tag.Key ?? '', value: tag.Value ?? ''}))} + } catch { + return {tags: [], unavailable: true} + } + } +} + +/** + * Builds the resource **without the parameter value**, deliberately. + * + * `metadata` reaches the inspector, the React Query cache and telemetry, so a + * value must never travel with the resource — the same rule both secrets + * adapters pin. It matters more here than it looks: the local runtime returns + * SecureString plaintext even when `WithDecryption` is false, so relying on the + * runtime to withhold it would leak the secret. The adapter therefore never + * issues GetParameter at all. + */ +function toResource(parameter: ParameterMetadata, tagLookup: TagLookup): CloudResource { + const name = parameter.Name ?? '' + + return { + id: name, + name, + cloud: 'aws', + service: 'parameters', + type: 'parameter', + // Parameter Store is regional but DescribeParameters does not echo the + // region, and inventing one would be fake data. + region: null, + createdAt: parameter.LastModifiedDate ? parameter.LastModifiedDate.toISOString() : null, + status: null, + metadata: { + parameterType: parameter.Type, + version: parameter.Version, + description: parameter.Description, + dataType: parameter.DataType, + tier: parameter.Tier, + allowedPattern: parameter.AllowedPattern, + lastModifiedUser: parameter.LastModifiedUser, + /** Tells the inspector the blank value is a policy, not a gap. */ + valueWithheld: true, + tags: tagLookup.tags, + ...(tagLookup.unavailable ? {tagsUnavailable: true} : {}), + }, + } +} + +/** Matches on the name and the description, since a name is often a long path. */ +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + + return resources.filter((resource) => { + const description = String(resource.metadata.description ?? '').toLowerCase() + return resource.name.toLowerCase().includes(normalized) || description.includes(normalized) + }) +} + +function isNotFound(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as {name?: string}).name === NOT_FOUND_NAME +} + +function requiredString(value: unknown, field: string): string { + const raw = optionalString(value, field) + if (raw === undefined) throw new ValidationError(`${field} is required`) + return raw +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + return value +} + +function oneOf(value: unknown, allowed: T, field: string): T[number] { + const raw = optionalString(value, field) + if (raw === undefined) return allowed[0] as T[number] + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} diff --git a/packages/api/src/adapter-aws/AwsSecretsAdapter.test.ts b/packages/api/src/adapter-aws/AwsSecretsAdapter.test.ts new file mode 100644 index 0000000..f361617 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSecretsAdapter.test.ts @@ -0,0 +1,143 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateSecretCommand, + DeleteSecretCommand, + DescribeSecretCommand, + ListSecretsCommand, + type SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager' +import {AwsSecretsAdapter} from './AwsSecretsAdapter' +import {ValidationError} from '../cloud-spi/errors' + +const CREATED = new Date('2026-07-01T10:00:00.000Z') + +function stubSecrets(options: {pages?: Array<{SecretList: object[]; NextToken?: string}>; missing?: boolean} = {}) { + const sent: object[] = [] + let page = 0 + const client = { + async send(command: object) { + sent.push(command) + if (command instanceof ListSecretsCommand) { + const pages = options.pages ?? [{ + SecretList: [{ + Name: 'db-password', + ARN: 'arn:aws:secretsmanager:::db-password', + CreatedDate: CREATED, + DeletedDate: new Date('2026-08-01T10:00:00.000Z'), + KmsKeyId: 'alias/floci', + Tags: [{Key: 'environment', Value: 'test'}], + }], + }] + return pages[page++] ?? {SecretList: []} + } + if (command instanceof DescribeSecretCommand) { + if (options.missing) { + const err = new Error("Secrets Manager can't find the specified secret.") + err.name = 'ResourceNotFoundException' + throw err + } + return {Name: 'db-password', ARN: 'arn:aws:secretsmanager:::db-password', CreatedDate: CREATED, Description: 'primary db'} + } + if (command instanceof CreateSecretCommand) return {Name: command.input.Name, ARN: 'arn:new'} + return {} + }, + } as unknown as SecretsManagerClient + return {client, sent} +} + +describe('AwsSecretsAdapter', () => { + test('identifies itself as the AWS secrets adapter', () => { + const adapter = new AwsSecretsAdapter(stubSecrets().client) + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('secrets') + }) + + test('lists secret metadata and retains recovery state and KMS metadata', async () => { + const [resource] = await new AwsSecretsAdapter(stubSecrets().client).list() + + expect(resource).toMatchObject({id: 'db-password', name: 'db-password', type: 'secret', service: 'secrets', status: 'deleted'}) + expect(resource?.createdAt).toBe(CREATED.toISOString()) + expect(resource?.metadata).toMatchObject({ + arn: 'arn:aws:secretsmanager:::db-password', + kmsKeyId: 'alias/floci', + tags: [{key: 'environment', value: 'test'}], + }) + }) + + test('never exposes a secret value', async () => { + const {client, sent} = stubSecrets() + const adapter = new AwsSecretsAdapter(client) + const [listed] = await adapter.list() + const inspected = await adapter.get('db-password') + + for (const resource of [listed, inspected]) { + const serialized = JSON.stringify(resource) + expect(serialized).not.toContain('SecretString') + expect(serialized).not.toContain('SecretBinary') + for (const key of Object.keys(resource?.metadata ?? {})) { + expect(key.toLowerCase()).not.toContain('value') + } + } + expect(sent.every((command) => command.constructor.name !== 'GetSecretValueCommand')).toBe(true) + }) + + test('follows pagination', async () => { + const {client} = stubSecrets({ + pages: [ + {SecretList: [{Name: 'one'}], NextToken: 'next'}, + {SecretList: [{Name: 'two'}]}, + ], + }) + await expect(new AwsSecretsAdapter(client).list()).resolves.toHaveLength(2) + }) + + test('filters the list by search term', async () => { + const adapter = new AwsSecretsAdapter(stubSecrets().client) + await expect(adapter.list({search: 'db'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('returns null for a missing secret', async () => { + const {client} = stubSecrets({missing: true}) + await expect(new AwsSecretsAdapter(client).get('nope')).resolves.toBeNull() + }) + + test('requires a secret name', async () => { + await expect(new AwsSecretsAdapter(stubSecrets().client).create({values: {}})) + .rejects.toBeInstanceOf(ValidationError) + }) + + test('creates with an optional description', async () => { + const {client, sent} = stubSecrets() + await new AwsSecretsAdapter(client).create({values: {secretName: 'api-key', description: 'third party'}}) + + const command = sent[0] as CreateSecretCommand + expect(command.input.Name).toBe('api-key') + expect(command.input.Description).toBe('third party') + }) + + test('forwards an initial value verbatim but never echoes it back', async () => { + const {client, sent} = stubSecrets() + const value = ' s3cr3t ' + const resource = await new AwsSecretsAdapter(client).create({values: {secretName: 'api-key', secretValue: value}}) + + expect((sent[0] as CreateSecretCommand).input.SecretString).toBe(value) + expect(JSON.stringify(resource)).not.toContain(value) + }) + + test('omits SecretString when no value was supplied', async () => { + const {client, sent} = stubSecrets() + await new AwsSecretsAdapter(client).create({values: {secretName: 'api-key'}}) + + expect((sent[0] as CreateSecretCommand).input.SecretString).toBeUndefined() + }) + + test('force-deletes so the secret does not linger in a recovery window', async () => { + const {client, sent} = stubSecrets() + await new AwsSecretsAdapter(client).delete('api-key') + + const command = sent[0] as DeleteSecretCommand + expect(command.input.SecretId).toBe('api-key') + expect(command.input.ForceDeleteWithoutRecovery).toBe(true) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsSecretsAdapter.ts b/packages/api/src/adapter-aws/AwsSecretsAdapter.ts new file mode 100644 index 0000000..f6fc3d6 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSecretsAdapter.ts @@ -0,0 +1,148 @@ +import { + CreateSecretCommand, + DeleteSecretCommand, + DescribeSecretCommand, + ListSecretsCommand, + type SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager' +import {secretsManager as defaultSecretsManager} from '../aws' +import {ValidationError} from '../cloud-spi/errors' +import {awsSecretsSchema} from '../cloud-spi/secretsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Secrets Manager as a Cloud Explorer service, so the console can count secrets + * instead of falling back to a dash. + * + * Deliberately metadata-only: this file imports ListSecrets and DescribeSecret + * and NOT GetSecretValue, so no code path here can return a secret value. That + * is a structural guarantee rather than a convention — adding value disclosure + * would require adding an import. + * + * Create accepts an optional write-only value. It is sent to the runtime once, + * but is never read back or placed on a resource. + */ +export class AwsSecretsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'secrets' as const + + constructor(private readonly client: SecretsManagerClient = defaultSecretsManager) {} + + schema(): ServiceSchema { + return awsSecretsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const resources: CloudResource[] = [] + let nextToken: string | undefined + + // Paginated: a single ListSecrets page caps at 100, and an unpaginated + // count silently under-reports past that. + do { + const res = await this.client.send(new ListSecretsCommand({NextToken: nextToken})) + for (const secret of res.SecretList ?? []) { + resources.push(toResource(secret)) + } + nextToken = res.NextToken + } while (nextToken) + + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + try { + const res = await this.client.send(new DescribeSecretCommand({SecretId: id})) + return toResource(res) + } catch (error) { + if (isNotFound(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.secretName ?? input.values.name) + const description = stringValue(input.values.description) + const secretValue = optionalSecretValue(input.values.secretValue) + + if (!name) throw new ValidationError('secretName is required') + + const res = await this.client.send(new CreateSecretCommand({ + Name: name, + ...(description ? {Description: description} : {}), + ...(secretValue !== undefined ? {SecretString: secretValue} : {}), + })) + + return toResource({Name: res.Name ?? name, ARN: res.ARN}) + } + + async delete(id: string): Promise { + // A generic delete action cannot ask for a recovery window. Immediate + // deletion avoids leaving a resource visibly present after deletion. + await this.client.send(new DeleteSecretCommand({SecretId: id, ForceDeleteWithoutRecovery: true})) + } +} + +interface SecretMetadata { + Name?: string + ARN?: string + CreatedDate?: Date + LastChangedDate?: Date + DeletedDate?: Date + Description?: string + RotationEnabled?: boolean + KmsKeyId?: string + LastAccessedDate?: Date + Tags?: {Key?: string; Value?: string}[] +} + +function toResource(secret: SecretMetadata): CloudResource { + const name = secret.Name ?? '' + return { + id: name, + name, + cloud: 'aws', + service: 'secrets', + type: 'secret', + region: null, + createdAt: secret.CreatedDate?.toISOString() ?? null, + // A deleted secret lingers in the list during its recovery window, so the + // status has to distinguish it rather than reading as healthy. + status: secret.DeletedDate ? 'deleted' : 'active', + metadata: { + arn: secret.ARN, + description: secret.Description, + rotationEnabled: secret.RotationEnabled ?? false, + kmsKeyId: secret.KmsKeyId, + lastChangedDate: secret.LastChangedDate?.toISOString() ?? null, + lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null, + tags: (secret.Tags ?? []).map((tag) => ({key: tag.Key, value: tag.Value})), + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function optionalSecretValue(value: unknown): string | undefined { + // Whitespace is valid secret content. Treat only an empty input as absent. + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const e = error as {name?: string; $metadata?: {httpStatusCode?: number}} + return e.name === 'ResourceNotFoundException' || e.$metadata?.httpStatusCode === 404 +} diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts new file mode 100644 index 0000000..150a7cf --- /dev/null +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts @@ -0,0 +1,275 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateFunctionCommand, + DeleteFunctionCommand, + GetFunctionCommand, + InvokeCommand, + type LambdaClient, + ListFunctionsCommand, +} from '@aws-sdk/client-lambda' +import {AwsServerlessAdapter} from './AwsServerlessAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +/** Minimal LambdaClient stub that records the commands it was sent. */ +function stubLambda(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as LambdaClient + return {client, sent} +} + +const listPayload = { + Functions: [ + { + FunctionName: 'hello', + FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello', + Runtime: 'nodejs20.x', + Handler: 'index.handler', + LastModified: '2026-07-01T10:00:00.000+0000', + State: 'Active', + MemorySize: 128, + Timeout: 3, + }, + ], +} + +describe('AwsServerlessAdapter', () => { + test('identifies itself as the AWS serverless adapter', () => { + const {client} = stubLambda(() => ({})) + const adapter = new AwsServerlessAdapter(client) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('serverless') + expect(adapter.schema().displayName).toBe('AWS Lambda') + }) + + test('lists functions and exposes runtime detail under metadata', async () => { + const {client, sent} = stubLambda(() => listPayload) + const [resource] = await new AwsServerlessAdapter(client).list() + + expect(sent[0]).toBeInstanceOf(ListFunctionsCommand) + expect(resource).toMatchObject({ + id: 'hello', + name: 'hello', + cloud: 'aws', + service: 'serverless', + type: 'lambda', + status: 'Active', + }) + // The serverless schema reads these through metadata paths. + expect(resource?.metadata.runtime).toBe('nodejs20.x') + expect(resource?.metadata.lastModified).toBe('2026-07-01T10:00:00.000+0000') + }) + + test('filters the list by search term', async () => { + const {client} = stubLambda(() => listPayload) + const adapter = new AwsServerlessAdapter(client) + + await expect(adapter.list({search: 'hell'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('returns null when a function does not exist', async () => { + const {client} = stubLambda(() => { + throw Object.assign(new Error('ResourceNotFoundException'), {$metadata: {httpStatusCode: 404}}) + }) + await expect(new AwsServerlessAdapter(client).get('missing')).resolves.toBeNull() + }) + + test('rethrows a non-404 failure from get', async () => { + const {client} = stubLambda(() => { + throw Object.assign(new Error('AccessDenied'), {$metadata: {httpStatusCode: 403}}) + }) + await expect(new AwsServerlessAdapter(client).get('hello')).rejects.toThrow('AccessDenied') + }) + + test('inspects a function through GetFunction', async () => { + const {client, sent} = stubLambda(() => ({ + Configuration: {FunctionName: 'hello', Runtime: 'nodejs20.x', Role: 'arn:aws:iam::000000000000:role/lambda'}, + })) + const resource = await new AwsServerlessAdapter(client).get('hello') + + expect(sent[0]).toBeInstanceOf(GetFunctionCommand) + expect(resource?.id).toBe('hello') + expect(resource?.metadata.role).toBe('arn:aws:iam::000000000000:role/lambda') + }) + + test('requires the fields the schema marks required', async () => { + const {client} = stubLambda(() => ({})) + const adapter = new AwsServerlessAdapter(client) + + const cases: Array<[Record, string]> = [ + [{}, 'functionName is required'], + [{functionName: 'a'}, 'runtime is required'], + [{functionName: 'a', runtime: 'nodejs20.x'}, 'handler is required'], + [{functionName: 'a', runtime: 'nodejs20.x', handler: 'index.handler'}, 'role is required'], + ] + + for (const [values, message] of cases) { + await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('creates a function with a default inline handler', async () => { + const {client, sent} = stubLambda(() => ({FunctionName: 'hello', State: 'Pending'})) + await new AwsServerlessAdapter(client).create({ + values: {functionName: 'hello', runtime: 'nodejs20.x', handler: 'index.handler', role: 'arn:role'}, + }) + + const command = sent[0] as CreateFunctionCommand + expect(command).toBeInstanceOf(CreateFunctionCommand) + expect(command.input.FunctionName).toBe('hello') + expect(command.input.Architectures).toEqual(['x86_64']) + expect(command.input.MemorySize).toBe(128) + expect(command.input.Timeout).toBe(3) + expect(command.input.Code?.ZipFile).toBeInstanceOf(Uint8Array) + }) + + test('passes the selected architecture to Lambda', async () => { + const {client, sent} = stubLambda(() => ({FunctionName: 'hello'})) + await new AwsServerlessAdapter(client).create({ + values: { + functionName: 'hello', + runtime: 'nodejs22.x', + architecture: 'arm64', + handler: 'index.handler', + role: 'arn:role', + }, + }) + + expect((sent[0] as CreateFunctionCommand).input.Architectures).toEqual(['arm64']) + }) + + test('rejects an unsupported architecture', async () => { + const {client} = stubLambda(() => ({})) + await expect(new AwsServerlessAdapter(client).create({ + values: { + functionName: 'hello', + runtime: 'nodejs22.x', + architecture: 'ppc64', + handler: 'index.handler', + role: 'arn:role', + }, + })).rejects.toThrow(new ValidationError('architecture must be x86_64 or arm64')) + }) + + test('uses Lambda defaults for blank optional numeric fields', async () => { + const {client, sent} = stubLambda(() => ({FunctionName: 'hello'})) + await new AwsServerlessAdapter(client).create({ + values: { + functionName: 'hello', + runtime: 'nodejs22.x', + handler: 'index.handler', + role: 'arn:role', + memorySize: '', + timeout: '', + }, + }) + + const command = sent[0] as CreateFunctionCommand + expect(command.input.MemorySize).toBe(128) + expect(command.input.Timeout).toBe(3) + }) + + test('uses an uploaded ZIP deployment package when supplied', async () => { + const {client, sent} = stubLambda(() => ({FunctionName: 'hello'})) + const zip = new Uint8Array([0x50, 0x4b, 0x03, 0x04]) + await new AwsServerlessAdapter(client).create({ + values: { + functionName: 'hello', + runtime: 'nodejs22.x', + handler: 'index.handler', + role: 'arn:role', + zipFile: Buffer.from(zip).toString('base64'), + }, + }) + + const command = sent[0] as CreateFunctionCommand + expect(command.input.Code?.ZipFile).toEqual(zip) + }) + + test('rejects an invalid uploaded ZIP encoding', async () => { + const {client} = stubLambda(() => ({})) + await expect(new AwsServerlessAdapter(client).create({ + values: { + functionName: 'hello', + runtime: 'nodejs22.x', + handler: 'index.handler', + role: 'arn:role', + zipFile: 'not base64!', + }, + })).rejects.toThrow(new ValidationError('zipFile must be a base64-encoded ZIP archive')) + }) + + test('deletes a function by name', async () => { + const {client, sent} = stubLambda(() => ({})) + await new AwsServerlessAdapter(client).delete('hello') + + expect((sent[0] as DeleteFunctionCommand).input.FunctionName).toBe('hello') + }) + + describe('invoke', () => { + test('returns the decoded payload, status and duration', async () => { + const {client, sent} = stubLambda(() => ({ + StatusCode: 200, + Payload: new TextEncoder().encode('{"ok":true}'), + })) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{"a":1}') + + const command = sent[0] as InvokeCommand + expect(command).toBeInstanceOf(InvokeCommand) + expect(command.input.FunctionName).toBe('hello') + expect(command.input.LogType).toBe('Tail') + expect(new TextDecoder().decode(command.input.Payload as Uint8Array)).toBe('{"a":1}') + + expect(result.statusCode).toBe(200) + expect(result.payload).toBe('{"ok":true}') + expect(result.executionDuration).toBeGreaterThanOrEqual(0) + expect(result.functionError).toBeUndefined() + }) + + test('defaults an empty payload to an empty JSON object', async () => { + const {client, sent} = stubLambda(() => ({StatusCode: 200})) + await new AwsServerlessAdapter(client).invoke('hello', '') + + const command = sent[0] as InvokeCommand + expect(new TextDecoder().decode(command.input.Payload as Uint8Array)).toBe('{}') + }) + + test('surfaces a handler error alongside the response payload', async () => { + const {client} = stubLambda(() => ({ + StatusCode: 200, + FunctionError: 'Unhandled', + Payload: new TextEncoder().encode('{"errorMessage":"boom"}'), + })) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{}') + + expect(result.functionError).toBe('Unhandled') + expect(result.payload).toContain('boom') + }) + + test('decodes the base64 tailed log', async () => { + const {client} = stubLambda(() => ({ + StatusCode: 200, + LogResult: Buffer.from('START RequestId: abc\nEND', 'utf8').toString('base64'), + })) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{}') + + expect(result.logResult).toContain('START RequestId: abc') + }) + + test('handles a missing payload without throwing', async () => { + const {client} = stubLambda(() => ({StatusCode: 202})) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{}') + + expect(result.statusCode).toBe(202) + expect(result.payload).toBe('') + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts new file mode 100644 index 0000000..e02da32 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts @@ -0,0 +1,244 @@ +import {ValidationError} from '../cloud-spi/errors' +import { + CreateFunctionCommand, + DeleteFunctionCommand, + GetFunctionCommand, + InvokeCommand, + type InvokeCommandOutput, + ListFunctionsCommand, + type LambdaClient, +} from "@aws-sdk/client-lambda"; +import { awsServerlessSchema } from "../cloud-spi/serverlessSchema"; +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServerlessInvokeResult, + ServiceSchema, +} from "../cloud-spi/types"; +import { lambda as defaultLambda } from "../aws"; +import { createZipArchive, handlerFileName } from "./zipArchive"; +import {lambdaStarterCode} from '../cloud-spi/lambdaStarterCode' + +export class AwsServerlessAdapter implements CloudServiceAdapter { + readonly cloud = "aws" as const; + readonly service = "serverless" as const; + + constructor(private readonly lambda: LambdaClient = defaultLambda) {} + + schema(): ServiceSchema { + return awsServerlessSchema(); + } + + async list(query: ResourceQuery = {}): Promise { + const res = await this.lambda.send(new ListFunctionsCommand({})); + const resources: CloudResource[] = (res.Functions ?? []).map((fn) => ({ + id: fn.FunctionName ?? fn.FunctionArn ?? "", + name: fn.FunctionName ?? "", + cloud: "aws" as const, + service: "serverless" as const, + type: "lambda", + region: null, + createdAt: null, + status: fn.State ?? null, + metadata: { + arn: fn.FunctionArn, + runtime: fn.Runtime, + handler: fn.Handler, + lastModified: fn.LastModified, + memorySize: fn.MemorySize, + timeout: fn.Timeout, + codeSize: fn.CodeSize, + packageType: fn.PackageType, + description: fn.Description, + }, + })); + + return filterBySearch(resources, query.search); + } + + async get(id: string): Promise { + try { + const res = await this.lambda.send( + new GetFunctionCommand({ FunctionName: id }), + ); + const config = res.Configuration; + + if (!config) return null; + + return { + id: config.FunctionName ?? config.FunctionArn ?? id, + name: config.FunctionName ?? id, + cloud: "aws", + service: "serverless", + type: "lambda", + region: null, + createdAt: null, + status: config.State ?? null, + metadata: { + arn: config.FunctionArn, + runtime: config.Runtime, + handler: config.Handler, + lastModified: config.LastModified, + memorySize: config.MemorySize, + timeout: config.Timeout, + codeSize: config.CodeSize, + packageType: config.PackageType, + description: config.Description, + role: config.Role, + version: config.Version, + codeSha256: config.CodeSha256, + environment: config.Environment?.Variables, +}, + }; + } catch (error) { + if (hasHttpStatus(error, 404)) return null; + throw error; + } + } + + async create(input: CreateResourceInput): Promise { + const values = input.values; + + const functionName = String( + values.functionName ?? values.name ?? "", + ).trim(); + const runtime = String(values.runtime ?? "").trim(); + const architecture = String(values.architecture ?? 'x86_64').trim(); + const handler = String(values.handler ?? "").trim(); + const role = String(values.role ?? "").trim(); + const description = String(values.description ?? "").trim(); + const memorySize = positiveNumberOrDefault(values.memorySize, 128); + const timeout = positiveNumberOrDefault(values.timeout, 3); + const zipFile = String(values.zipFile ?? '').trim(); + const code = + String(values.code ?? "").trim() || + lambdaStarterCode(runtime) + + if (!functionName) throw new ValidationError("functionName is required"); + if (!runtime) throw new ValidationError("runtime is required"); + if (architecture !== 'x86_64' && architecture !== 'arm64') { + throw new ValidationError('architecture must be x86_64 or arm64') + } + if (!handler) throw new ValidationError("handler is required"); + if (!role) throw new ValidationError("role is required"); + + const res = await this.lambda.send( + new CreateFunctionCommand({ + FunctionName: functionName, + Runtime: runtime as never, + Architectures: [architecture as never], + Handler: handler, + Role: role, + Description: description || undefined, + MemorySize: memorySize, + Timeout: timeout, + Code: { + // The browser submits uploaded files as base64. Otherwise package the + // editor source into the same AWS ZIP payload. + ZipFile: zipFile + ? decodeBase64Zip(zipFile) + : createZipArchive([ + { name: handlerFileName(handler, runtime), content: code }, + ]), + }, + }), + ); + + return { + id: res.FunctionName ?? res.FunctionArn ?? functionName, + name: res.FunctionName ?? functionName, + cloud: "aws", + service: "serverless", + type: "lambda", + region: null, + createdAt: null, + status: res.State ?? null, + metadata: { + arn: res.FunctionArn, + runtime: res.Runtime, + handler: res.Handler, + lastModified: res.LastModified, + memorySize: res.MemorySize, + timeout: res.Timeout, + codeSize: res.CodeSize, + packageType: res.PackageType, + description: res.Description, + role: res.Role, + version: res.Version, + codeSha256: res.CodeSha256, + }, + }; + } + + async delete(id: string): Promise { + await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id })); + } + + async invoke(id: string, payload: string): Promise { + const startedAt = performance.now(); + const res = await this.lambda.send( + new InvokeCommand({ + FunctionName: id, + Payload: new TextEncoder().encode(payload || "{}"), + // Tail returns the last 4 KB of the execution log, base64 encoded. + LogType: "Tail", + }), + ); + const executionDuration = Math.round(performance.now() - startedAt); + + return { + statusCode: res.StatusCode ?? 0, + payload: decodePayload(res.Payload), + ...(res.FunctionError ? { functionError: res.FunctionError } : {}), + ...(res.LogResult ? { logResult: decodeLogResult(res.LogResult) } : {}), + executionDuration, + }; + } +} + +function decodeBase64Zip(value: string): Uint8Array { + if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) { + throw new ValidationError('zipFile must be a base64-encoded ZIP archive') + } + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function positiveNumberOrDefault(value: unknown, defaultValue: number): number { + if (value === undefined || value === null || String(value).trim() === '') { + return defaultValue + } + const number = Number(value) + return Number.isFinite(number) && number > 0 ? number : defaultValue +} + +function decodePayload(payload: InvokeCommandOutput["Payload"]): string { + if (!payload) return ""; + return new TextDecoder().decode(payload); +} + +/** Lambda returns the tailed log base64 encoded; surface it as plain text. */ +function decodeLogResult(logResult: string): string { + try { + return Buffer.from(logResult, "base64").toString("utf8"); + } catch { + return logResult; + } +} + +function filterBySearch( + resources: CloudResource[], + search?: string, +): CloudResource[] { + const normalized = search?.trim().toLowerCase(); + if (!normalized) return resources; + return resources.filter((r) => r.name.toLowerCase().includes(normalized)); +} + +function hasHttpStatus(error: unknown, status: number): boolean { + if (typeof error !== "object" || error === null) return false; + const metadata = (error as { $metadata?: { httpStatusCode?: number } }) + .$metadata; + return metadata?.httpStatusCode === status; +} diff --git a/packages/api/src/adapter-aws/AwsSesAdapter.test.ts b/packages/api/src/adapter-aws/AwsSesAdapter.test.ts new file mode 100644 index 0000000..076e1e3 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSesAdapter.test.ts @@ -0,0 +1,99 @@ +import {describe, expect, test} from 'bun:test' +import {AwsSesAdapter} from './AwsSesAdapter' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: {'content-type': 'application/json'}, + }) +} + +describe('AwsSesAdapter', () => { + test('maps captured Floci emails to normalized mailbox resources', async () => { + const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({ + messages: [{ + Id: 'message-1', + Region: 'us-east-1', + Source: 'sender@example.test', + Destination: {ToAddresses: ['recipient@example.test'], CcAddresses: ['copy@example.test']}, + ReplyToAddresses: ['reply@example.test'], + Subject: 'Welcome', + Body: {text_part: 'Hello', html_part: 'Hello'}, + Timestamp: '2026-08-30T12:00:00.000Z', + }], + })) + + const resources = await adapter.list() + + expect(resources).toEqual([expect.objectContaining({ + id: 'message-1', + name: 'Welcome', + cloud: 'aws', + service: 'email', + type: 'email', + region: 'us-east-1', + createdAt: '2026-08-30T12:00:00.000Z', + status: 'captured', + metadata: expect.objectContaining({ + source: 'sender@example.test', + toAddresses: ['recipient@example.test'], + ccAddresses: ['copy@example.test'], + replyToAddresses: ['reply@example.test'], + textBody: 'Hello', + htmlBody: 'Hello', + messageType: 'simple', + }), + })]) + }) + + test('filters messages by subject, sender, and recipients', async () => { + const adapter = new AwsSesAdapter('http://floci.test', async () => jsonResponse({ + messages: [ + {Id: 'welcome', Source: 'sender@example.test', Destination: {ToAddresses: ['a@example.test']}, Subject: 'Welcome'}, + {Id: 'invoice', Source: 'billing@example.test', Destination: {ToAddresses: ['customer@example.test']}, Subject: 'Invoice'}, + ], + })) + + await expect(adapter.list({search: 'CUSTOMER'})).resolves.toMatchObject([{id: 'invoice'}]) + await expect(adapter.list({search: 'sender'})).resolves.toMatchObject([{id: 'welcome'}]) + }) + + test('gets one message through Floci inspection filtering', async () => { + let requestedUrl = '' + const adapter = new AwsSesAdapter('http://floci.test/', async (input) => { + requestedUrl = String(input) + return jsonResponse({messages: [{Id: 'message with space', Subject: 'Test'}]}) + }) + + const message = await adapter.get('message with space') + + expect(requestedUrl).toBe('http://floci.test/_aws/ses?id=message%20with%20space') + expect(message).toMatchObject({id: 'message with space', name: 'Test'}) + }) + + test('clears the captured inbox through the Floci inspection endpoint', async () => { + let requestedUrl = '' + let method = '' + const adapter = new AwsSesAdapter('http://floci.test', async (input, init) => { + requestedUrl = String(input) + method = init?.method ?? '' + return new Response(null, {status: 200}) + }) + + await adapter.clearEmailInbox() + + expect(requestedUrl).toBe('http://floci.test/_aws/ses') + expect(method).toBe('DELETE') + }) + + test('returns the SES mailbox schema', () => { + const adapter = new AwsSesAdapter() + + expect(adapter.schema()).toMatchObject({ + cloud: 'aws', + service: 'email', + displayName: 'SES Mailbox', + actions: ['list', 'inspect'], + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsSesAdapter.ts b/packages/api/src/adapter-aws/AwsSesAdapter.ts new file mode 100644 index 0000000..d9ef553 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSesAdapter.ts @@ -0,0 +1,154 @@ +import {httpStatusToCloudError, NotSupportedError, RuntimeError, RuntimeUnavailableError} from '../cloud-spi/errors' +import {awsSesEmailSchema} from '../cloud-spi/emailSchema' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' + +interface FlociSesMessage { + Id?: string + Region?: string | null + Source?: string + Destination?: { + ToAddresses?: string[] + CcAddresses?: string[] + BccAddresses?: string[] + } + ReplyToAddresses?: string[] + Subject?: string + Body?: { + text_part?: string | null + html_part?: string | null + } + RawData?: string + Timestamp?: string +} + +interface FlociSesInbox { + messages?: FlociSesMessage[] +} + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise + +/** + * Adapts Floci's documented SES inspection surface to normalized email resources. + * It deliberately does not send email: applications under test keep using the + * AWS SES SDK, and this mailbox shows exactly what the runtime captured. + */ +export class AwsSesAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'email' as const + + constructor( + private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566', + private readonly fetcher: FetchLike = globalThis.fetch, + ) {} + + schema(): ServiceSchema { + return awsSesEmailSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const messages = await this.fetchInbox() + const resources = messages.map(messageToResource) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + const messages = await this.fetchInbox(id) + const message = messages.find((entry) => entry.Id === id) + return message ? messageToResource(message) : null + } + + async create(_input: CreateResourceInput): Promise { + throw new NotSupportedError('Sending email from the mailbox is not implemented yet') + } + + async delete(_id: string): Promise { + throw new NotSupportedError('Deleting individual captured emails is not supported by Floci SES') + } + + async clearEmailInbox(): Promise { + await this.request('/_aws/ses', {method: 'DELETE'}) + } + + private async fetchInbox(id?: string): Promise { + const search = id ? `?id=${encodeURIComponent(id)}` : '' + const response = await this.request(`/_aws/ses${search}`, {method: 'GET'}) + let body: FlociSesInbox + try { + body = await response.json() as FlociSesInbox + } catch (error) { + throw new RuntimeError('Floci SES inspection returned invalid JSON', {cause: error}) + } + if (!Array.isArray(body.messages)) { + throw new RuntimeError('Floci SES inspection response did not contain messages') + } + return body.messages + } + + private async request(path: string, init: RequestInit): Promise { + let response: Response + try { + response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init) + } catch (error) { + throw new RuntimeUnavailableError(`Cannot reach Floci SES at ${this.endpoint}`, {cause: error}) + } + if (response.ok) return response + + const detail = await response.text().catch(() => '') + throw httpStatusToCloudError( + response.status, + detail || `Floci SES inspection returned HTTP ${response.status}`, + ) + } +} + +function messageToResource(message: FlociSesMessage): CloudResource { + const id = message.Id ?? '' + const raw = typeof message.RawData === 'string' + return { + id, + name: message.Subject?.trim() || '(no subject)', + cloud: 'aws', + service: 'email', + type: 'email', + region: message.Region ?? null, + createdAt: message.Timestamp ?? null, + status: 'captured', + metadata: { + provider: 'aws', + emailService: 'ses', + source: message.Source ?? null, + toAddresses: message.Destination?.ToAddresses ?? [], + ccAddresses: message.Destination?.CcAddresses ?? [], + bccAddresses: message.Destination?.BccAddresses ?? [], + replyToAddresses: message.ReplyToAddresses ?? [], + textBody: message.Body?.text_part ?? null, + htmlBody: message.Body?.html_part ?? null, + rawData: message.RawData ?? null, + messageType: raw ? 'raw' : 'simple', + }, + } +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => { + const metadata = resource.metadata + const searchable = [ + resource.name, + stringValue(metadata.source), + ...stringList(metadata.toAddresses), + ...stringList(metadata.ccAddresses), + ...stringList(metadata.bccAddresses), + ].join(' ').toLowerCase() + return searchable.includes(normalized) + }) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] +} diff --git a/packages/api/src/adapter-aws/AwsSqsAdapter.test.ts b/packages/api/src/adapter-aws/AwsSqsAdapter.test.ts new file mode 100644 index 0000000..ea28ea7 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSqsAdapter.test.ts @@ -0,0 +1,244 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateQueueCommand, + DeleteQueueCommand, + GetQueueAttributesCommand, + GetQueueUrlCommand, + ListQueuesCommand, + type SQSClient, +} from '@aws-sdk/client-sqs' +import {AwsSqsAdapter} from './AwsSqsAdapter' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const BASE = 'http://localhost:4566/000000000000' + +/** Attribute shape captured from Floci core. */ +const ORDERS_ATTRIBUTES = { + QueueArn: 'arn:aws:sqs:us-east-1:000000000000:orders-queue', + ApproximateNumberOfMessages: '4', + ApproximateNumberOfMessagesNotVisible: '1', + VisibilityTimeout: '30', + MessageRetentionPeriod: '345600', + MaximumMessageSize: '1048576', + DelaySeconds: '0', + CreatedTimestamp: '1785177503', + LastModifiedTimestamp: '1785177503', +} + +function stubSqs(handlers: { + queues?: string[] + attributes?: Record + missingQueue?: boolean +} = {}) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + if (command instanceof ListQueuesCommand) { + return {QueueUrls: handlers.queues ?? [`${BASE}/orders-queue`]} + } + if (command instanceof GetQueueUrlCommand) { + if (handlers.missingQueue) { + const err = new Error('The specified queue does not exist') + err.name = 'QueueDoesNotExist' + throw err + } + return {QueueUrl: `${BASE}/${(command as GetQueueUrlCommand).input.QueueName}`} + } + if (command instanceof GetQueueAttributesCommand) { + return {Attributes: handlers.attributes ?? ORDERS_ATTRIBUTES} + } + if (command instanceof CreateQueueCommand) { + return {QueueUrl: `${BASE}/${(command as CreateQueueCommand).input.QueueName}`} + } + return {} + }, + } as unknown as SQSClient + return {client, sent} +} + +describe('AwsSqsAdapter', () => { + test('identifies itself as the AWS messaging adapter', () => { + const adapter = new AwsSqsAdapter(stubSqs().client) + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('messaging') + expect(adapter.schema().displayName).toBe('AWS SQS') + }) + + test('uses the queue name as the id, not the URL', async () => { + // A URL embeds the endpoint and changes if the runtime is re-pointed, + // which would make resource links unstable. + const {client} = stubSqs() + const [resource] = await new AwsSqsAdapter(client).list() + + expect(resource?.id).toBe('orders-queue') + expect(resource?.name).toBe('orders-queue') + expect(resource?.metadata.queueUrl).toBe(`${BASE}/orders-queue`) + }) + + test('normalizes queue attributes', async () => { + const {client} = stubSqs() + const [resource] = await new AwsSqsAdapter(client).list() + + expect(resource).toMatchObject({cloud: 'aws', service: 'messaging', type: 'queue'}) + expect(resource?.metadata.arn).toBe('arn:aws:sqs:us-east-1:000000000000:orders-queue') + expect(resource?.metadata.approximateMessages).toBe(4) + expect(resource?.metadata.messagesInFlight).toBe(1) + expect(resource?.metadata.visibilityTimeout).toBe(30) + }) + + test('converts SQS epoch-second timestamps to ISO', async () => { + const {client} = stubSqs() + const [resource] = await new AwsSqsAdapter(client).list() + + expect(resource?.createdAt).toBe(new Date(1785177503 * 1000).toISOString()) + }) + + test('marks a FIFO queue with its own type', async () => { + const {client} = stubSqs({attributes: {...ORDERS_ATTRIBUTES, FifoQueue: 'true'}}) + const [resource] = await new AwsSqsAdapter(client).list() + + expect(resource?.type).toBe('fifo-queue') + }) + + test('still lists a queue whose attributes cannot be read', async () => { + // A describe failure should not blank the whole list. + const client = { + async send(command: object) { + if (command instanceof ListQueuesCommand) return {QueueUrls: [`${BASE}/orders-queue`]} + throw new Error('AccessDenied') + }, + } as unknown as SQSClient + + const [resource] = await new AwsSqsAdapter(client).list() + expect(resource?.id).toBe('orders-queue') + expect(resource?.metadata.arn).toBeUndefined() + }) + + test('handles an empty queue list', async () => { + const {client} = stubSqs({queues: []}) + await expect(new AwsSqsAdapter(client).list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + const {client} = stubSqs({queues: [`${BASE}/orders-queue`, `${BASE}/dead-letter-queue`]}) + const adapter = new AwsSqsAdapter(client) + + await expect(adapter.list({search: 'orders'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'queue'})).resolves.toHaveLength(2) + }) + + test('resolves a name to a URL when inspecting', async () => { + const {client, sent} = stubSqs() + const resource = await new AwsSqsAdapter(client).get('orders-queue') + + expect(sent[0]).toBeInstanceOf(GetQueueUrlCommand) + expect(resource?.id).toBe('orders-queue') + }) + + test('returns null for a queue that does not exist', async () => { + const {client} = stubSqs({missingQueue: true}) + await expect(new AwsSqsAdapter(client).get('nope')).resolves.toBeNull() + }) + + test('creates a queue with optional attributes', async () => { + const {client, sent} = stubSqs() + await new AwsSqsAdapter(client).create({ + values: {queueName: 'orders-queue', visibilityTimeout: '60', messageRetentionPeriod: '600'}, + }) + + const command = sent[0] as CreateQueueCommand + expect(command).toBeInstanceOf(CreateQueueCommand) + expect(command.input.QueueName).toBe('orders-queue') + expect(command.input.Attributes).toEqual({VisibilityTimeout: '60', MessageRetentionPeriod: '600'}) + }) + + test('omits the attributes block when nothing was supplied', async () => { + const {client, sent} = stubSqs() + await new AwsSqsAdapter(client).create({values: {queueName: 'orders-queue'}}) + + expect((sent[0] as CreateQueueCommand).input.Attributes).toBeUndefined() + }) + + test('requires a queue name', async () => { + const {client} = stubSqs() + await expect(new AwsSqsAdapter(client).create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('rejects a name SQS would refuse but accepts a FIFO name', async () => { + const adapter = new AwsSqsAdapter(stubSqs().client) + + for (const name of ['has space', 'has/slash', 'a'.repeat(81), 'plain.name']) { + await expect(adapter.create({values: {queueName: name}})).rejects.toBeInstanceOf(ValidationError) + } + await expect(adapter.create({values: {queueName: 'orders.fifo'}})).resolves.toBeDefined() + }) + + test('sets FifoQueue when the name ends in .fifo', async () => { + // Real SQS rejects a .fifo create without this attribute. Floci core + // infers it from the suffix, so a stub or the emulator alone would not + // catch its absence. + const {client, sent} = stubSqs() + await new AwsSqsAdapter(client).create({values: {queueName: 'orders.fifo'}}) + + expect((sent[0] as CreateQueueCommand).input.Attributes).toEqual({FifoQueue: 'true'}) + }) + + test('does not set FifoQueue for a standard queue', async () => { + const {client, sent} = stubSqs() + await new AwsSqsAdapter(client).create({values: {queueName: 'orders-queue'}}) + + expect((sent[0] as CreateQueueCommand).input.Attributes).toBeUndefined() + }) + + test('deletes a queue by resolving its URL first', async () => { + const {client, sent} = stubSqs() + await new AwsSqsAdapter(client).delete('orders-queue') + + expect(sent[0]).toBeInstanceOf(GetQueueUrlCommand) + expect((sent[1] as DeleteQueueCommand).input.QueueUrl).toBe(`${BASE}/orders-queue`) + }) + + test('reports a missing queue on delete rather than silently succeeding', async () => { + const {client} = stubSqs({missingQueue: true}) + await expect(new AwsSqsAdapter(client).delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) + + test('health lists queues without describing each one', async () => { + const {client, sent} = stubSqs() + await new AwsSqsAdapter(client).health() + + expect(sent).toHaveLength(1) + expect(sent[0]).toBeInstanceOf(ListQueuesCommand) + }) + + /** + * Bounds are SQS's own. Without them a non-numeric value only fails at the + * CreateQueue call, so the form reports a runtime error instead of a field error. + */ + test('rejects out-of-range and non-numeric queue attributes', async () => { + const {client} = stubSqs() + const adapter = new AwsSqsAdapter(client) + + await expect( + adapter.create({values: {queueName: 'q', visibilityTimeout: 'soon'}}), + ).rejects.toThrow(ValidationError) + await expect( + adapter.create({values: {queueName: 'q', visibilityTimeout: '43201'}}), + ).rejects.toThrow(ValidationError) + await expect( + adapter.create({values: {queueName: 'q', messageRetentionPeriod: '59'}}), + ).rejects.toThrow(ValidationError) + }) + + test('accepts attributes inside the SQS range and omits blanks', async () => { + const {client, sent} = stubSqs() + + await new AwsSqsAdapter(client).create({ + values: {queueName: 'q', visibilityTimeout: '30', messageRetentionPeriod: ''}, + }) + + const create = sent.find((command) => command instanceof CreateQueueCommand) + expect(create?.input.Attributes).toEqual({VisibilityTimeout: '30'}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsSqsAdapter.ts b/packages/api/src/adapter-aws/AwsSqsAdapter.ts new file mode 100644 index 0000000..50bd1e0 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsSqsAdapter.ts @@ -0,0 +1,223 @@ +import { + CreateQueueCommand, + DeleteQueueCommand, + GetQueueAttributesCommand, + GetQueueUrlCommand, + ListQueuesCommand, + type SQSClient, +} from '@aws-sdk/client-sqs' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' +import {awsMessagingSchema} from '../cloud-spi/messagingSchema' +import {sqs as defaultSqs} from '../aws' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * SQS queues as `messaging` resources. + * + * SQS identifies a queue by URL, but a URL is a poor id for the console: it is + * long, embeds the endpoint, and changes if the runtime is re-pointed. The + * adapter uses the queue name as the resource id and resolves it back to a URL + * per call via GetQueueUrl, which is the same thing the AWS console does. + */ + +interface QueueAttributes { + QueueArn?: string + ApproximateNumberOfMessages?: string + ApproximateNumberOfMessagesNotVisible?: string + ApproximateNumberOfMessagesDelayed?: string + VisibilityTimeout?: string + MessageRetentionPeriod?: string + MaximumMessageSize?: string + DelaySeconds?: string + CreatedTimestamp?: string + LastModifiedTimestamp?: string + FifoQueue?: string +} + +export class AwsSqsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'messaging' as const + + constructor(private readonly sqs: SQSClient = defaultSqs) {} + + schema(): ServiceSchema { + return awsMessagingSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const res = await this.sqs.send(new ListQueuesCommand({})) + const urls = res.QueueUrls ?? [] + + const resources = await Promise.all( + urls.map(async (url) => { + const name = queueNameFromUrl(url) + // A queue with no attributes is still a queue, so a failed + // describe degrades to the bare row rather than failing the list. + const attributes = await this.attributesFor(url).catch(() => ({})) + return toResource(name, url, attributes) + }), + ) + + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + const url = await this.queueUrl(id) + if (!url) return null + return toResource(id, url, await this.attributesFor(url)) + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.queueName ?? input.values.name) + if (!name) throw new ValidationError('queueName is required') + if (!isValidQueueName(name)) { + throw new ValidationError( + 'Use a valid SQS queue name: up to 80 alphanumeric characters, hyphens, or underscores.', + ) + } + + const attributes: Record = {} + // Bounds are SQS's own, so a bad value fails in the form rather than as a + // 400 from the runtime several seconds later. + const visibilityTimeout = numericAttribute(input.values.visibilityTimeout, 'visibilityTimeout', 0, 43_200) + const retention = numericAttribute(input.values.messageRetentionPeriod, 'messageRetentionPeriod', 60, 1_209_600) + if (visibilityTimeout) attributes.VisibilityTimeout = visibilityTimeout + if (retention) attributes.MessageRetentionPeriod = retention + // A .fifo name alone is not enough: real SQS rejects the create unless + // FifoQueue is set. Floci core infers it from the suffix, so omitting it + // works locally and then fails against AWS. + if (isFifoName(name)) attributes.FifoQueue = 'true' + + const res = await this.sqs.send( + new CreateQueueCommand({ + QueueName: name, + ...(Object.keys(attributes).length > 0 ? {Attributes: attributes} : {}), + }), + ) + + const url = res.QueueUrl ?? (await this.queueUrl(name)) ?? '' + return toResource(name, url, await this.attributesFor(url).catch(() => ({}))) + } + + async delete(id: string): Promise { + const url = await this.queueUrl(id) + if (!url) throw new NotFoundError(`Queue ${id} does not exist`) + await this.sqs.send(new DeleteQueueCommand({QueueUrl: url})) + } + + /** Listing queue names is cheaper than describing each one. */ + async health(): Promise { + await this.sqs.send(new ListQueuesCommand({})) + } + + private async queueUrl(name: string): Promise { + try { + const res = await this.sqs.send(new GetQueueUrlCommand({QueueName: name})) + return res.QueueUrl ?? null + } catch (error) { + if (isQueueMissing(error)) return null + throw error + } + } + + private async attributesFor(url: string): Promise { + if (!url) return {} + const res = await this.sqs.send( + new GetQueueAttributesCommand({QueueUrl: url, AttributeNames: ['All']}), + ) + return (res.Attributes ?? {}) as QueueAttributes + } +} + +function toResource(name: string, url: string, attributes: QueueAttributes): CloudResource { + return { + id: name, + name, + cloud: 'aws', + service: 'messaging', + type: attributes.FifoQueue === 'true' ? 'fifo-queue' : 'queue', + region: null, + createdAt: epochSecondsToIso(attributes.CreatedTimestamp), + metadata: { + provider: 'aws', + messagingService: 'sqs', + queueUrl: url, + arn: attributes.QueueArn, + approximateMessages: numberValue(attributes.ApproximateNumberOfMessages), + messagesInFlight: numberValue(attributes.ApproximateNumberOfMessagesNotVisible), + messagesDelayed: numberValue(attributes.ApproximateNumberOfMessagesDelayed), + visibilityTimeout: numberValue(attributes.VisibilityTimeout), + messageRetentionPeriod: numberValue(attributes.MessageRetentionPeriod), + maximumMessageSize: numberValue(attributes.MaximumMessageSize), + delaySeconds: numberValue(attributes.DelaySeconds), + lastModified: epochSecondsToIso(attributes.LastModifiedTimestamp), + }, + } +} + +/** SQS reports timestamps as epoch seconds; the contract uses ISO strings. */ +function epochSecondsToIso(value: string | undefined): string | null { + if (!value) return null + const seconds = Number(value) + return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : null +} + +function numberValue(value: string | undefined): number | null { + if (value === undefined) return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +function queueNameFromUrl(url: string): string { + return url.split('/').filter(Boolean).pop() ?? url +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +/** + * Validate an optional numeric queue attribute against SQS's documented range. + * + * Returns '' when the caller left it blank — these are optional, and an empty + * field must not become an attribute. + */ +function numericAttribute(value: unknown, field: string, min: number, max: number): string { + const raw = stringValue(value) + if (!raw) return '' + if (!/^\d+$/.test(raw)) { + throw new ValidationError(`${field} must be a whole number of seconds between ${min} and ${max}.`) + } + const parsed = Number(raw) + if (parsed < min || parsed > max) { + throw new ValidationError(`${field} must be between ${min} and ${max} seconds.`) + } + return raw +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isFifoName(value: string): boolean { + return value.endsWith('.fifo') +} + +function isValidQueueName(value: string): boolean { + // FIFO queues must end in .fifo, which is the one place a dot is allowed. + return /^[A-Za-z0-9_-]{1,80}$/.test(value) || /^[A-Za-z0-9_-]{1,75}\.fifo$/.test(value) +} + +function isQueueMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const name = (error as {name?: string}).name + return name === 'QueueDoesNotExist' || name === 'AWS.SimpleQueueService.NonExistentQueue' +} diff --git a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts new file mode 100644 index 0000000..940eb53 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.test.ts @@ -0,0 +1,224 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateStateMachineCommand, + DeleteStateMachineCommand, + DescribeStateMachineCommand, + ListStateMachinesCommand, + type SFNClient, +} from '@aws-sdk/client-sfn' +import {AwsStepFunctionsAdapter} from './AwsStepFunctionsAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +function stubSfn(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as SFNClient + return {client, sent} +} + +const ARN = 'arn:aws:states:us-east-1:000000000000:stateMachine:orders' +const DEFINITION = `{ + "StartAt": "Done", + "States": { + "Done": {"Type": "Succeed"} + } +}` +const ROLE = 'arn:aws:iam::000000000000:role/sfn' + +const summary = { + stateMachineArn: ARN, + name: 'orders', + type: 'STANDARD', + creationDate: new Date('2026-07-28T10:00:00.000Z'), +} + +const described = { + ...summary, + definition: DEFINITION, + roleArn: ROLE, + status: 'ACTIVE', +} + +function runtimeStub() { + return stubSfn((command) => { + if (command instanceof ListStateMachinesCommand) return {stateMachines: [summary]} + if (command instanceof DescribeStateMachineCommand) return described + if (command instanceof CreateStateMachineCommand) return {stateMachineArn: ARN, creationDate: new Date()} + return {} + }) +} + +const validValues = {name: 'orders', roleArn: ROLE, definition: DEFINITION} + +describe('AwsStepFunctionsAdapter', () => { + test('identifies itself as the AWS workflows adapter', () => { + const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('workflows') + expect(adapter.schema().displayName).toBe('AWS Step Functions') + }) + + test('lists state machines from the summary alone', async () => { + const {client, sent} = runtimeStub() + const [resource] = await new AwsStepFunctionsAdapter(client).list() + + expect(sent[0]).toBeInstanceOf(ListStateMachinesCommand) + expect(resource).toMatchObject({ + // The ARN is the id: DescribeStateMachine and DeleteStateMachine both + // take one, and a name alone cannot address the machine. + id: ARN, + name: 'orders', + cloud: 'aws', + service: 'workflows', + type: 'state-machine', + region: 'us-east-1', + createdAt: '2026-07-28T10:00:00.000Z', + }) + expect(resource?.metadata.machineType).toBe('STANDARD') + }) + + test('does not describe each row while listing', async () => { + // ListStateMachines omits status and definition, and real Step Functions + // does too. Fetching them per row would make the table an N+1. + const {client, sent} = runtimeStub() + await new AwsStepFunctionsAdapter(client).list() + + expect(sent.some((c) => c instanceof DescribeStateMachineCommand)).toBe(false) + }) + + test('follows the pagination token', async () => { + let call = 0 + const {client, sent} = stubSfn((command) => { + if (!(command instanceof ListStateMachinesCommand)) return {} + call += 1 + if (call === 1) return {stateMachines: [summary], nextToken: 'page-2'} + return {stateMachines: [{...summary, name: 'refunds', stateMachineArn: `${ARN}-2`}]} + }) + + const resources = await new AwsStepFunctionsAdapter(client).list() + + expect(resources.map((r) => r.name)).toEqual(['orders', 'refunds']) + expect((sent[1] as ListStateMachinesCommand).input.nextToken).toBe('page-2') + }) + + test('filters by name and ARN', async () => { + const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) + + await expect(adapter.list({search: 'ord'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'stateMachine:orders'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a state machine and surfaces the definition and status', async () => { + const {client, sent} = runtimeStub() + const resource = await new AwsStepFunctionsAdapter(client).get(ARN) + + expect((sent[0] as DescribeStateMachineCommand).input.stateMachineArn).toBe(ARN) + // status is the canonical CloudResource field, not a metadata entry. + expect(resource?.status).toBe('ACTIVE') + expect(resource?.metadata).toMatchObject({definition: DEFINITION, roleArn: ROLE}) + }) + + test('leaves status null in a listing, where the runtime does not return it', async () => { + const {client} = runtimeStub() + const [resource] = await new AwsStepFunctionsAdapter(client).list() + + expect(resource?.status).toBeNull() + }) + + test('returns null on StateMachineDoesNotExist even though the runtime answers 400', async () => { + // Step Functions reports a missing machine as StateMachineDoesNotExist with + // HTTP 400, not 404, so matching on the status would rethrow and show a + // runtime error where an empty result belongs. + const {client} = stubSfn(() => { + throw Object.assign(new Error('State machine does not exist'), { + name: 'StateMachineDoesNotExist', + $metadata: {httpStatusCode: 400}, + }) + }) + await expect(new AwsStepFunctionsAdapter(client).get(ARN)).resolves.toBeNull() + }) + + test('rethrows a failure that is not a missing machine', async () => { + const {client} = stubSfn(() => { + throw Object.assign(new Error('AccessDenied'), { + name: 'AccessDeniedException', + $metadata: {httpStatusCode: 403}, + }) + }) + await expect(new AwsStepFunctionsAdapter(client).get(ARN)).rejects.toThrow('AccessDenied') + }) + + test('creates a state machine and reads it back', async () => { + const {client, sent} = runtimeStub() + const resource = await new AwsStepFunctionsAdapter(client).create({values: validValues}) + + const command = sent[0] as CreateStateMachineCommand + expect(command).toBeInstanceOf(CreateStateMachineCommand) + expect(command.input.name).toBe('orders') + expect(command.input.definition).toBe(DEFINITION) + expect(command.input.definition).toContain('\n') + expect(command.input.roleArn).toBe(ROLE) + // create returns only an ARN, so the machine is described to build the + // resource rather than synthesising one from the input. + expect(sent[1]).toBeInstanceOf(DescribeStateMachineCommand) + expect(resource.id).toBe(ARN) + }) + + test('requires the fields the schema marks required', async () => { + const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) + + const cases: Array<[Record, string]> = [ + [{}, 'name is required'], + [{name: 'orders'}, 'roleArn is required'], + [{name: 'orders', roleArn: ROLE}, 'definition is required'], + ] + + for (const [values, message] of cases) { + await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('rejects a definition that is not JSON', async () => { + const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) + + await expect(adapter.create({values: {...validValues, definition: 'not json'}})).rejects.toThrow( + ValidationError, + ) + }) + + test('rejects a definition missing StartAt or States', async () => { + // Valid JSON is not a valid state machine. The runtime rejects it, but with + // a parser message; naming the missing field is more actionable. + const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) + + await expect(adapter.create({values: {...validValues, definition: '{"States":{}}'}})).rejects.toThrow( + new ValidationError('definition must contain StartAt'), + ) + await expect(adapter.create({values: {...validValues, definition: '{"StartAt":"Done"}'}})).rejects.toThrow( + new ValidationError('definition must contain States'), + ) + }) + + test('rejects a name or role ARN the runtime would refuse', async () => { + const adapter = new AwsStepFunctionsAdapter(runtimeStub().client) + + await expect(adapter.create({values: {...validValues, name: 'has spaces'}})).rejects.toThrow(ValidationError) + await expect(adapter.create({values: {...validValues, name: 'a'.repeat(81)}})).rejects.toThrow(ValidationError) + await expect(adapter.create({values: {...validValues, roleArn: 'not-an-arn'}})).rejects.toThrow(ValidationError) + }) + + test('deletes a state machine by ARN', async () => { + const {client, sent} = runtimeStub() + await new AwsStepFunctionsAdapter(client).delete(ARN) + + expect((sent[0] as DeleteStateMachineCommand).input.stateMachineArn).toBe(ARN) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts new file mode 100644 index 0000000..f81ec6a --- /dev/null +++ b/packages/api/src/adapter-aws/AwsStepFunctionsAdapter.ts @@ -0,0 +1,186 @@ +import { + CreateStateMachineCommand, + DeleteStateMachineCommand, + DescribeStateMachineCommand, + ListStateMachinesCommand, + type SFNClient, + type StateMachineListItem, +} from '@aws-sdk/client-sfn' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {STATE_MACHINE_NAME_PATTERN, awsWorkflowsSchema} from '../cloud-spi/workflowsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to Step Functions — verified against Floci core 1.5.33. + * + * Notes from probing the runtime: + * - A missing state machine is `StateMachineDoesNotExist` with HTTP **400**, not + * 404, so `get` matches on the error name. `awsErrors.ts` now maps that name so + * the route reports 404 rather than a validation failure. + * - `ListStateMachines` returns only the ARN, name, type and creation date — real + * Step Functions does the same. Status and definition live on + * `DescribeStateMachine`, so they are fetched on inspect only; doing it per row + * would make the table an N+1. + * - `CreateStateMachine` returns only an ARN and a date, so create describes the + * machine afterwards rather than synthesising a resource from its own input. + * - The runtime does validate the definition (`InvalidDefinition`), but its + * message is a JSON parser error, so the obvious problems are caught here with + * something more actionable. + */ +type DescribedMachine = { + stateMachineArn?: string + name?: string + type?: string + status?: string + definition?: string + roleArn?: string + creationDate?: Date + label?: string + revisionId?: string +} + +export class AwsStepFunctionsAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'workflows' as const + + constructor(private readonly sfn: SFNClient) {} + + schema(): ServiceSchema { + return awsWorkflowsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const machines: StateMachineListItem[] = [] + let nextToken: string | undefined + + do { + const res = await this.sfn.send(new ListStateMachinesCommand(nextToken ? {nextToken} : {})) + machines.push(...(res.stateMachines ?? [])) + nextToken = res.nextToken + } while (nextToken) + + return filterBySearch(machines.map(toResource), query.search) + } + + async get(id: string): Promise { + try { + const res = await this.sfn.send(new DescribeStateMachineCommand({stateMachineArn: id})) + return toResource(res as DescribedMachine) + } catch (error) { + if (isMissing(error)) return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(STATE_MACHINE_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must be up to 80 characters of letters, digits, hyphens and underscores', + ) + } + const roleArn = requiredString(input.values.roleArn, 'roleArn') + if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) { + throw new ValidationError('roleArn must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role') + } + const definition = requiredDefinition(input.values.definition) + + const res = await this.sfn.send(new CreateStateMachineCommand({name, definition, roleArn})) + const arn = res.stateMachineArn + if (!arn) throw new RuntimeError(`Step Functions did not return an ARN for ${name}`) + + // create returns only the ARN, so read the machine back for the real shape. + const created = await this.get(arn) + if (!created) throw new RuntimeError(`State machine ${name} was created but could not be read back`) + return created + } + + async delete(id: string): Promise { + await this.sfn.send(new DeleteStateMachineCommand({stateMachineArn: id})) + } +} + +function toResource(machine: StateMachineListItem | DescribedMachine): CloudResource { + const described = machine as DescribedMachine + const arn = machine.stateMachineArn ?? '' + + return { + // The ARN is the identity: Describe and Delete both take one, and a name + // alone cannot address the machine. It survives the generic route because + // HttpClient encodes path params. + id: arn, + name: machine.name ?? '', + cloud: 'aws', + service: 'workflows', + type: 'state-machine', + region: regionFromArn(arn), + createdAt: machine.creationDate ? machine.creationDate.toISOString() : null, + // Only DescribeStateMachine returns status, so this is null in a listing. + status: described.status ?? null, + metadata: { + arn, + /** `type` is taken by CloudResource, so the machine's own type is here. */ + machineType: machine.type, + definition: described.definition, + roleArn: described.roleArn, + label: described.label, + revisionId: described.revisionId, + }, + } +} + +/** `arn:aws:states:us-east-1:000000000000:stateMachine:name` — region is field 4. */ +function regionFromArn(arn: string): string | null { + return arn.split(':')[3] || null +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + + return resources.filter( + (resource) => + resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized), + ) +} + +/** + * Valid JSON is not a valid state machine. The runtime rejects a bad definition + * with a JSON parser message; naming the missing field is more actionable. + */ +function requiredDefinition(value: unknown): string { + const raw = requiredString(value, 'definition') + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new ValidationError('definition must be valid JSON') + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new ValidationError('definition must be a JSON object') + } + for (const field of ['StartAt', 'States'] as const) { + if (!(field in parsed)) throw new ValidationError(`definition must contain ${field}`) + } + return raw +} + +function isMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + return (error as {name?: string}).name === 'StateMachineDoesNotExist' +} + +function requiredString(value: unknown, field: string): string { + if (value === undefined || value === null || value === '') throw new ValidationError(`${field} is required`) + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + const trimmed = value.trim() + if (!trimmed) throw new ValidationError(`${field} is required`) + return trimmed +} diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts new file mode 100644 index 0000000..0926915 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.test.ts @@ -0,0 +1,103 @@ +import {describe, expect, test} from 'bun:test' +import { + GetBucketTaggingCommand, + ListBucketsCommand, + type S3Client, +} from '@aws-sdk/client-s3' +import {AwsStorageAdapter} from './AwsStorageAdapter' + +function fakeS3(overrides: Partial<{ + buckets: Array<{Name: string; CreationDate?: Date}> + tagSet: Array<{Key: string; Value: string}> + taggingError: Error + onGetBucketTagging: (bucketName: string) => void +}> = {}): S3Client { + return { + send: async (command: unknown) => { + if (command instanceof ListBucketsCommand) { + return { + Buckets: overrides.buckets ?? [ + {Name: 'my-tf-test-bucket', CreationDate: new Date('2026-01-01')}, + ], + } + } + if (command instanceof GetBucketTaggingCommand) { + overrides.onGetBucketTagging?.((command as {input: {Bucket: string}}).input.Bucket) + if (overrides.taggingError) throw overrides.taggingError + return {TagSet: overrides.tagSet ?? [{Key: 'Environment', Value: 'dev'}]} + } + throw new Error(`Unexpected command: ${command?.constructor?.name}`) + }, + } as unknown as S3Client +} + +describe('AwsStorageAdapter', () => { + test('list includes bucket tags in metadata', async () => { + const adapter = new AwsStorageAdapter(fakeS3()) + const result = await adapter.list() + + expect(result).toHaveLength(1) + expect(result[0].metadata.tags).toEqual([{key: 'Environment', value: 'dev'}]) + expect(result[0].metadata.tagsUnavailable).toBe(false) + }) + + test('list returns empty tags for untagged bucket instead of throwing', async () => { + const error = new Error('The TagSet does not exist') + error.name = 'NoSuchTagSet' + const adapter = new AwsStorageAdapter(fakeS3({taggingError: error})) + + const result = await adapter.list() + + expect(result[0].metadata.tags).toEqual([]) + expect(result[0].metadata.tagsUnavailable).toBe(false) + }) + + test('list does not fail entirely when tag fetch is denied for a bucket', async () => { + const error = new Error('Access Denied') + error.name = 'AccessDenied' + const adapter = new AwsStorageAdapter(fakeS3({taggingError: error})) + + const result = await adapter.list() + + expect(result).toHaveLength(1) + expect(result[0].metadata.tags).toEqual([]) + }) + + test('list marks tags unavailable (not empty) when tag fetch is denied', async () => { + const error = new Error('Access Denied') + error.name = 'AccessDenied' + const adapter = new AwsStorageAdapter(fakeS3({taggingError: error})) + + const result = await adapter.list() + + expect(result[0].metadata.tagsUnavailable).toBe(true) + }) + + test('list marks tags unavailable on transport/runtime failures too', async () => { + const error = new Error('socket hang up') + error.name = 'TimeoutError' + const adapter = new AwsStorageAdapter(fakeS3({taggingError: error})) + + const result = await adapter.list() + + expect(result[0].metadata.tags).toEqual([]) + expect(result[0].metadata.tagsUnavailable).toBe(true) + }) + + test('search filters buckets before fetching tags, not after', async () => { + const requestedBuckets: string[] = [] + const adapter = new AwsStorageAdapter(fakeS3({ + buckets: [ + {Name: 'prod-logs'}, + {Name: 'dev-logs'}, + {Name: 'prod-assets'}, + ], + onGetBucketTagging: (bucketName) => requestedBuckets.push(bucketName), + })) + + const result = await adapter.list({search: 'prod'}) + + expect(result.map((r) => r.name).sort()).toEqual(['prod-assets', 'prod-logs']) + expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs']) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.ts new file mode 100644 index 0000000..ba72961 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.ts @@ -0,0 +1,200 @@ +import {ValidationError} from '../cloud-spi/errors' +import { + type Bucket, + CopyObjectCommand, + CreateBucketCommand, + DeleteBucketCommand, + DeleteObjectCommand, + GetBucketTaggingCommand, + GetObjectCommand, + ListBucketsCommand, + ListObjectsV2Command, + PutObjectCommand, + type S3Client, +} from '@aws-sdk/client-s3' +import {s3 as defaultS3} from '../aws' +import {awsStorageSchema} from '../cloud-spi/storageSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + StorageObjectDownload, + StorageObjectList, +} from '../cloud-spi/types' + +export class AwsStorageAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'storage' as const + + constructor(private readonly s3: S3Client = defaultS3) {} + + schema(): ServiceSchema { + return awsStorageSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const res = await this.s3.send(new ListBucketsCommand({})) + const buckets = filterBucketsBySearch(res.Buckets ?? [], query.search) + + return Promise.all(buckets.map(async (bucket): Promise => { + const {tags, tagsUnavailable} = await this.bucketTags(bucket.Name ?? '') + return { + id: bucket.Name ?? '', + name: bucket.Name ?? '', + cloud: 'aws', + service: 'storage', + type: 'bucket', + region: null, + createdAt: bucket.CreationDate?.toISOString() ?? null, + metadata: { + provider: 'aws', + storageService: 's3', + tags, + tagsUnavailable, + }, + } + })) + } + + private async bucketTags(bucketName: string): Promise<{tags: Array<{key: string; value: string}>; tagsUnavailable: boolean}> { + if (!bucketName) return {tags: [], tagsUnavailable: false} + try { + const res = await this.s3.send(new GetBucketTaggingCommand({Bucket: bucketName})) + return { + tags: (res.TagSet ?? []).map((tag) => ({key: tag.Key ?? '', value: tag.Value ?? ''})), + tagsUnavailable: false, + } + } catch (err) { + // NoSuchTagSet is a real, valid answer: the bucket has no tags. Anything else + // (AccessDenied, throttling, transport failures) means we don't actually know + // whether the bucket has tags, so callers must not treat it as "no tags". + if (err instanceof Error && err.name === 'NoSuchTagSet') { + return {tags: [], tagsUnavailable: false} + } + return {tags: [], tagsUnavailable: true} + } + } + + async get(id: string): Promise { + const resources = await this.list() + return resources.find((resource) => resource.id === id) ?? null + } + + async create(input: CreateResourceInput): Promise { + const bucketName = stringValue(input.values.bucketName) + if (!bucketName) throw new ValidationError('bucketName is required') + if (!isValidS3BucketName(bucketName)) { + throw new ValidationError('Use a valid S3 bucket name: 3-63 lowercase characters, numbers, dots, or hyphens.') + } + + await this.s3.send(new CreateBucketCommand({Bucket: bucketName})) + return { + id: bucketName, + name: bucketName, + cloud: 'aws', + service: 'storage', + type: 'bucket', + region: stringValue(input.values.region) || null, + createdAt: null, + metadata: { + provider: 'aws', + storageService: 's3', + }, + } + } + + async delete(id: string): Promise { + await this.s3.send(new DeleteBucketCommand({Bucket: id})) + } + + async listObjects(resourceId: string, prefix = ''): Promise { + const res = await this.s3.send(new ListObjectsV2Command({ + Bucket: resourceId, + Prefix: prefix || undefined, + Delimiter: '/', + })) + + return { + prefix, + objects: [ + ...(res.CommonPrefixes ?? []).map((item) => { + const key = item.Prefix ?? '' + return { + key, + name: objectName(key, prefix), + type: 'folder' as const, + size: null, + lastModified: null, + metadata: { + provider: 'aws', + storageService: 's3', + prefix: key, + }, + } + }), + ...(res.Contents ?? []) + .filter((item) => item.Key && item.Key !== prefix) + .map((item) => ({ + key: item.Key ?? '', + name: objectName(item.Key ?? '', prefix), + type: 'object' as const, + size: item.Size ?? null, + lastModified: item.LastModified?.toISOString() ?? null, + metadata: { + provider: 'aws', + storageService: 's3', + etag: item.ETag?.replace(/"/g, ''), + storageClass: item.StorageClass, + }, + })), + ], + } + } + + async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise { + await this.s3.send(new PutObjectCommand({Bucket: resourceId, Key: key, Body: body, ContentType: contentType})) + } + + async getObject(resourceId: string, key: string): Promise { + const res = await this.s3.send(new GetObjectCommand({Bucket: resourceId, Key: key})) + return { + body: res.Body as BodyInit, + contentType: res.ContentType ?? 'application/octet-stream', + contentLength: res.ContentLength ?? null, + } + } + + async deleteObject(resourceId: string, key: string): Promise { + await this.s3.send(new DeleteObjectCommand({Bucket: resourceId, Key: key})) + } + + async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise { + const destBucket = destResourceId ?? srcResourceId + await this.s3.send(new CopyObjectCommand({ + Bucket: destBucket, + Key: destKey, + CopySource: `${srcResourceId}/${srcKey}`, + })) + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBucketsBySearch(buckets: Bucket[], search?: string): Bucket[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return buckets + return buckets.filter((bucket) => (bucket.Name ?? '').toLowerCase().includes(normalized)) +} + +function objectName(key: string, prefix: string): string { + const relative = key.startsWith(prefix) ? key.slice(prefix.length) : key + return relative.replace(/\/$/, '') || key +} + +function isValidS3BucketName(value: string): boolean { + return /^(?!\d+\.\d+\.\d+\.\d+$)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value) +} diff --git a/packages/api/src/adapter-aws/awsErrors.test.ts b/packages/api/src/adapter-aws/awsErrors.test.ts new file mode 100644 index 0000000..ee61c9a --- /dev/null +++ b/packages/api/src/adapter-aws/awsErrors.test.ts @@ -0,0 +1,129 @@ +import {describe, expect, test} from 'bun:test' +import type {CloudErrorCode, CloudErrorStatus} from '../cloud-spi/errors' +import {isAwsSdkError, mapAwsSdkError} from './awsErrors' + +/** Build an error shaped like an AWS SDK v3 failure. */ +function sdkError(name: string, extra: Record = {}): Error { + const err = new Error(`${name} from the runtime`) + err.name = name + return Object.assign(err, {$fault: 'client', $metadata: {httpStatusCode: 500}, ...extra}) +} + +describe('isAwsSdkError', () => { + test('recognises the SDK envelope', () => { + expect(isAwsSdkError(sdkError('NoSuchBucket'))).toBe(true) + expect(isAwsSdkError(Object.assign(new Error('x'), {$retryable: {}}))).toBe(true) + }) + + test('rejects anything else so the generic mapper can handle it', () => { + expect(isAwsSdkError(new Error('plain'))).toBe(false) + expect(isAwsSdkError(null)).toBe(false) + expect(isAwsSdkError('string')).toBe(false) + }) +}) + +describe('mapAwsSdkError', () => { + test('returns null for a non-SDK error', () => { + expect(mapAwsSdkError(new Error('plain'))).toBeNull() + }) + + const cases: Array<[string, CloudErrorStatus, CloudErrorCode]> = [ + // throttling — must win over everything, including the 500 metadata status + ['ThrottlingException', 429, 'rate_limited'], + ['ProvisionedThroughputExceededException', 429, 'rate_limited'], + ['SlowDown', 429, 'rate_limited'], + // conflicts — previously all 502 + ['BucketAlreadyExists', 409, 'resource_conflict'], + ['BucketAlreadyOwnedByYou', 409, 'resource_conflict'], + ['EntityAlreadyExists', 409, 'resource_conflict'], + ['ResourceExistsException', 409, 'resource_conflict'], + ['StateMachineAlreadyExists', 409, 'resource_conflict'], + ['ResourceInUseException', 409, 'resource_conflict'], + ['QueueNameExists', 409, 'resource_conflict'], + // access + ['AccessDenied', 403, 'access_denied'], + ['UnauthorizedOperation', 403, 'access_denied'], + ['SignatureDoesNotMatch', 403, 'access_denied'], + // validation + ['ValidationException', 400, 'invalid_request'], + ['InvalidParameterValue', 400, 'invalid_request'], + ['MalformedPolicyDocument', 400, 'invalid_request'], + // A bad parameter set, not an existing-resource clash. + ['InvalidParameterCombination', 400, 'invalid_request'], + // not found + ['NoSuchBucket', 404, 'resource_not_found'], + ['NoSuchKey', 404, 'resource_not_found'], + ['ResourceNotFoundException', 404, 'resource_not_found'], + ['QueueDoesNotExist', 404, 'resource_not_found'], + // EC2 uses a dotted suffix convention + ['InvalidVpcID.NotFound', 404, 'resource_not_found'], + ['InvalidInstanceID.NotFound', 404, 'resource_not_found'], + // runtime gaps + ['NotImplemented', 501, 'operation_not_implemented'], + ['UnsupportedOperation', 501, 'operation_not_implemented'], + ['ServiceUnavailable', 503, 'runtime_unavailable'], + ] + + for (const [name, status, code] of cases) { + test(`${name} maps to ${status}`, () => { + const mapped = mapAwsSdkError(sdkError(name)) + expect(mapped?.status).toBe(status) + expect(mapped?.code).toBe(code) + expect(mapped?.message).toContain(name) + }) + } + + test('matches the AlreadyExists suffix convention for unlisted names', () => { + expect(mapAwsSdkError(sdkError('WidgetAlreadyExistsException'))?.status).toBe(409) + }) + + test('honours the $retryable throttling flag even for an unknown name', () => { + const mapped = mapAwsSdkError(sdkError('SomeNewException', {$retryable: {throttling: true}})) + expect(mapped?.status).toBe(429) + }) + + test('falls back to the metadata status only when the name is unknown', () => { + expect(mapAwsSdkError(sdkError('MysteryFailure', {$metadata: {httpStatusCode: 404}}))?.status).toBe(404) + expect(mapAwsSdkError(sdkError('MysteryFailure', {$metadata: {httpStatusCode: 409}}))?.status).toBe(409) + }) + + test('prefers the error name over a misleading metadata status', () => { + // Local emulators frequently answer 500 for what is really a conflict. + const mapped = mapAwsSdkError(sdkError('BucketAlreadyExists', {$metadata: {httpStatusCode: 500}})) + expect(mapped?.status).toBe(409) + }) + + test('defaults to 502 when neither name nor status is informative', () => { + expect(mapAwsSdkError(sdkError('MysteryFailure'))?.status).toBe(502) + }) +}) + +/** + * CloudWatch Logs returns both of these with HTTP 400, verified against the + * runtime on 2026-07-28: + * DescribeLogStreams on a missing group -> 400 ResourceNotFoundException + * duplicate CreateLogGroup -> 400 ResourceAlreadyExistsException + * + * Both already map correctly, because the name tables run ahead of the + * `$metadata.httpStatusCode` floor. Nothing pinned that ordering against a + * 400-carrying not-found, and an unpinned ordering is how #168 shipped a green + * suite over a 400-instead-of-404 bug. + */ +describe('logs errors carry HTTP 400 and must not fall through to it', () => { + test('a not-found keeps its 404 despite the 400 status', () => { + const mapped = mapAwsSdkError( + sdkError('ResourceNotFoundException', {$metadata: {httpStatusCode: 400}}), + ) + + expect(mapped?.status).toBe(404) + expect(mapped?.code).toBe('resource_not_found') + }) + + test('an already-exists keeps its 409 despite the 400 status', () => { + const mapped = mapAwsSdkError( + sdkError('ResourceAlreadyExistsException', {$metadata: {httpStatusCode: 400}}), + ) + + expect(mapped?.status).toBe(409) + }) +}) diff --git a/packages/api/src/adapter-aws/awsErrors.ts b/packages/api/src/adapter-aws/awsErrors.ts new file mode 100644 index 0000000..39c138f --- /dev/null +++ b/packages/api/src/adapter-aws/awsErrors.ts @@ -0,0 +1,183 @@ +/** + * Maps AWS SDK v3 errors onto cloud-SPI errors. + * + * Vendor knowledge lives here rather than in `cloud-spi/` so the generic mapper + * stays provider-neutral. Match order matters: throttling first, then the name + * tables, and `$metadata.httpStatusCode` only as a last resort — local emulators + * are less consistent about status codes than they are about error names. + */ + +import { + AccessDeniedError, + type CloudError, + ConflictError, + NotFoundError, + NotImplementedByRuntimeError, + RateLimitedError, + RuntimeError, + RuntimeUnavailableError, + ValidationError, + isUnreachableCause, +} from '../cloud-spi/errors' + +interface AwsSdkErrorShape { + name?: string + message?: string + $fault?: 'client' | 'server' + $retryable?: {throttling?: boolean} + $metadata?: {httpStatusCode?: number} +} + +const THROTTLING_NAMES = new Set([ + 'ThrottlingException', + 'Throttling', + 'ThrottledException', + 'TooManyRequestsException', + 'RequestLimitExceeded', + 'ProvisionedThroughputExceededException', + 'RequestThrottled', + 'RequestThrottledException', + 'SlowDown', + 'LimitExceededException', +]) + +const CONFLICT_NAMES = new Set([ + 'BucketAlreadyExists', + 'BucketAlreadyOwnedByYou', + 'EntityAlreadyExists', + 'EntityAlreadyExistsException', + 'ResourceExistsException', + 'ResourceInUseException', + 'ResourceConflictException', + 'ConditionalCheckFailedException', + 'QueueNameExists', + 'QueueDeletedRecently', + 'InvalidChangeBatch', + 'DBInstanceAlreadyExists', + 'DBInstanceAlreadyExistsFault', + 'ConcurrentModificationException', + 'IncorrectState', + 'DeleteConflictException', +]) + +const ACCESS_DENIED_NAMES = new Set([ + 'AccessDenied', + 'AccessDeniedException', + 'UnauthorizedOperation', + 'AuthFailure', + 'InvalidClientTokenId', + 'SignatureDoesNotMatch', + 'Forbidden', + 'MissingAuthenticationToken', + 'InvalidAccessKeyId', +]) + +const VALIDATION_NAMES = new Set([ + 'ValidationException', + 'ValidationError', + 'InvalidParameterValue', + 'InvalidParameterValueException', + 'InvalidParameterCombination', + 'InvalidRequestException', + 'InvalidInput', + 'InvalidInputException', + 'MissingParameter', + 'MissingRequiredParameter', + 'SerializationException', + 'MalformedPolicyDocument', + 'MalformedPolicyDocumentException', + 'InvalidArgsException', + 'InvalidBucketName', + 'InvalidArgumentException', + 'InvalidParameterException', +]) + +const NOT_FOUND_NAMES = new Set([ + 'NoSuchBucket', + 'NoSuchKey', + 'NoSuchEntity', + 'NoSuchEntityException', + 'NotFound', + 'NotFoundException', + 'ResourceNotFoundException', + 'ResourceNotFoundFault', + 'DBInstanceNotFound', + 'DBInstanceNotFoundFault', + 'ParameterNotFound', + 'QueueDoesNotExist', + 'StateMachineDoesNotExist', + 'ExecutionDoesNotExist', + 'LoadBalancerNotFound', + 'LoadBalancerNotFoundException', + 'TargetGroupNotFound', + 'TargetGroupNotFoundException', + 'SecretNotFoundException', + 'NoSuchLogGroup', + 'NoSuchResourceException', + 'InvalidInstanceID.NotFound', + 'InvalidVpcID.NotFound', + 'InvalidSubnetID.NotFound', + 'InvalidGroupId.NotFound', + 'InvalidAMIID.NotFound', +]) + +const NOT_IMPLEMENTED_NAMES = new Set([ + 'NotImplemented', + 'UnsupportedOperation', + 'UnsupportedOperationException', +]) + +const UNAVAILABLE_NAMES = new Set([ + 'NetworkingError', + 'TimeoutError', + 'ServiceUnavailable', + 'ServiceUnavailableException', + 'RequestTimeout', + 'InternalFailure', + 'InternalError', +]) + +/** True when the value carries the AWS SDK v3 error envelope. */ +export function isAwsSdkError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false + return '$metadata' in err || '$fault' in err || '$retryable' in err +} + +/** + * Translate an AWS SDK error, or return `null` when it is not one so the caller + * can fall through to the generic mapping. + */ +export function mapAwsSdkError(err: unknown): CloudError | null { + if (!isAwsSdkError(err)) return null + + const sdkError = err as AwsSdkErrorShape + const name = sdkError.name ?? '' + const message = sdkError.message ?? name ?? 'AWS request failed' + const options = {cause: err} + + if (sdkError.$retryable?.throttling === true || THROTTLING_NAMES.has(name)) { + return new RateLimitedError(message, options) + } + if (CONFLICT_NAMES.has(name) || /AlreadyExists(Exception|Fault)?$/.test(name)) { + return new ConflictError(message, options) + } + if (ACCESS_DENIED_NAMES.has(name)) return new AccessDeniedError(message, options) + if (VALIDATION_NAMES.has(name)) return new ValidationError(message, options) + if (NOT_FOUND_NAMES.has(name) || /\.NotFound$/.test(name)) return new NotFoundError(message, options) + if (NOT_IMPLEMENTED_NAMES.has(name)) return new NotImplementedByRuntimeError(message, options) + if (UNAVAILABLE_NAMES.has(name) || isUnreachableCause(err)) { + return new RuntimeUnavailableError(message, options) + } + + // Emulator status codes are less reliable than names, so this is the floor. + const status = sdkError.$metadata?.httpStatusCode + if (status === 400) return new ValidationError(message, options) + if (status === 401 || status === 403) return new AccessDeniedError(message, options) + if (status === 404) return new NotFoundError(message, options) + if (status === 409) return new ConflictError(message, options) + if (status === 429) return new RateLimitedError(message, options) + if (status === 501) return new NotImplementedByRuntimeError(message, options) + if (status === 503 || status === 504) return new RuntimeUnavailableError(message, options) + + return new RuntimeError(message, options) +} diff --git a/packages/api/src/adapter-aws/zipArchive.test.ts b/packages/api/src/adapter-aws/zipArchive.test.ts new file mode 100644 index 0000000..3bccb44 --- /dev/null +++ b/packages/api/src/adapter-aws/zipArchive.test.ts @@ -0,0 +1,118 @@ +import {describe, expect, test} from 'bun:test' +import {execFile} from 'node:child_process' +import {rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {createZipArchive, crc32, handlerFileName} from './zipArchive' + +describe('crc32', () => { + // Reference values from the standard CRC-32 (IEEE 802.3) test vectors. + test('matches known checksums', () => { + expect(crc32(new TextEncoder().encode(''))).toBe(0x00000000) + expect(crc32(new TextEncoder().encode('a'))).toBe(0xe8b7be43) + expect(crc32(new TextEncoder().encode('abc'))).toBe(0x352441c2) + expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926) + }) +}) + +describe('createZipArchive', () => { + test('writes the ZIP magic number and end-of-central-directory record', () => { + const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + + expect(view.getUint32(0, true)).toBe(0x04034b50) + expect(view.getUint32(archive.length - 22, true)).toBe(0x06054b50) + // One entry, recorded in both the disk and total counts. + expect(view.getUint16(archive.length - 22 + 8, true)).toBe(1) + expect(view.getUint16(archive.length - 22 + 10, true)).toBe(1) + }) + + test('records the entry name and an accurate checksum', () => { + const content = 'exports.handler = async () => ({statusCode: 200})' + const archive = createZipArchive([{name: 'index.js', content}]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + const encoded = new TextEncoder().encode(content) + + expect(view.getUint32(14, true)).toBe(crc32(encoded)) + expect(view.getUint32(18, true)).toBe(encoded.length) + expect(view.getUint32(22, true)).toBe(encoded.length) + expect(new TextDecoder().decode(archive.slice(30, 38))).toBe('index.js') + }) + + test('is readable by an independent ZIP implementation', async () => { + // Validates against a real reader rather than trusting our writer to agree + // with itself — a self-consistent but malformed archive is exactly the bug + // this file exists to prevent. + const archive = createZipArchive([ + {name: 'index.js', content: 'exports.handler = 1'}, + {name: 'lib/util.js', content: 'module.exports = {}'}, + ]) + + const path = `${tmpdir()}/floci-zip-${process.pid}.zip` + await writeFile(path, archive) + try { + const listing = await new Promise((resolve, reject) => { + execFile('unzip', ['-Z1', path], (err, stdout) => (err ? reject(err) : resolve(stdout))) + }).catch(() => null) + + if (listing === null) { + // No unzip on this machine; assert the structure we can check. + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + expect(view.getUint16(archive.length - 22 + 8, true)).toBe(2) + return + } + + expect(listing.split('\n').filter(Boolean).sort()).toEqual(['index.js', 'lib/util.js']) + + const extracted = await new Promise((resolve, reject) => { + execFile('unzip', ['-p', path, 'index.js'], (err, stdout) => (err ? reject(err) : resolve(stdout))) + }) + expect(extracted).toBe('exports.handler = 1') + } finally { + await rm(path, {force: true}) + } + }) + + test('supports multiple entries with distinct offsets', () => { + const archive = createZipArchive([ + {name: 'a.js', content: 'a'}, + {name: 'b.js', content: 'bb'}, + ]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + + expect(view.getUint16(archive.length - 22 + 8, true)).toBe(2) + // Second local header follows the first entry's header plus its 1 byte body. + expect(view.getUint32(30 + 4 + 1, true)).toBe(0x04034b50) + }) + + test('accepts binary content', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]) + const archive = createZipArchive([{name: 'blob.bin', content: bytes}]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + + expect(view.getUint32(14, true)).toBe(crc32(bytes)) + expect(view.getUint32(18, true)).toBe(bytes.length) + }) + + test('produces a byte-identical archive for identical input', () => { + // No timestamps, so a deployment package is reproducible. + const build = () => createZipArchive([{name: 'index.js', content: 'x'}]) + expect(build()).toEqual(build()) + }) +}) + +describe('handlerFileName', () => { + test('derives the module file from the handler string', () => { + expect(handlerFileName('index.handler', 'nodejs20.x')).toBe('index.js') + expect(handlerFileName('app.main', 'nodejs18.x')).toBe('app.js') + expect(handlerFileName('src/app.run', 'nodejs20.x')).toBe('src/app.js') + }) + + test('uses the runtime language extension', () => { + expect(handlerFileName('lambda_function.lambda_handler', 'python3.12')).toBe('lambda_function.py') + expect(handlerFileName('function.handler', 'ruby3.3')).toBe('function.rb') + }) + + test('falls back to index when the handler has no module part', () => { + expect(handlerFileName('handler', 'nodejs20.x')).toBe('index.js') + }) +}) diff --git a/packages/api/src/adapter-aws/zipArchive.ts b/packages/api/src/adapter-aws/zipArchive.ts new file mode 100644 index 0000000..8f9779e --- /dev/null +++ b/packages/api/src/adapter-aws/zipArchive.ts @@ -0,0 +1,132 @@ +/** + * Minimal ZIP writer for Lambda deployment packages. + * + * `CreateFunction`'s `Code.ZipFile` must be a real ZIP archive — passing raw + * source text makes the runtime reject the call with "Handler file not found in + * deployment package". Nothing in the dependency tree can build one, and the + * archives here are a single small source file, so entries are written with the + * STORED method (no compression): that needs only a CRC-32 and keeps this to a + * format-exact, dependency-free implementation. + * + * Spec: PKWARE APPNOTE 6.3.x, sections 4.3.7 (local header), 4.3.12 (central + * directory) and 4.3.16 (end of central directory). + */ + +const CRC32_TABLE = buildCrc32Table() + +function buildCrc32Table(): Uint32Array { + const table = new Uint32Array(256) + for (let i = 0; i < 256; i += 1) { + let value = i + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + } + table[i] = value >>> 0 + } + return table +} + +export function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff + for (const byte of bytes) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +export interface ZipEntry { + name: string + content: string | Uint8Array +} + +/** Build a ZIP archive containing the given files, stored uncompressed. */ +export function createZipArchive(entries: ZipEntry[]): Uint8Array { + const encoder = new TextEncoder() + const localParts: Uint8Array[] = [] + const centralParts: Uint8Array[] = [] + let offset = 0 + let localSize = 0 + + for (const entry of entries) { + const nameBytes = encoder.encode(entry.name) + const data = typeof entry.content === 'string' ? encoder.encode(entry.content) : entry.content + const checksum = crc32(data) + + const localHeader = new Uint8Array(30 + nameBytes.length) + const localView = new DataView(localHeader.buffer) + localView.setUint32(0, 0x04034b50, true) // local file header signature + localView.setUint16(4, 20, true) // version needed to extract (2.0) + localView.setUint16(6, 0, true) // general purpose bit flag + localView.setUint16(8, 0, true) // compression method: STORED + localView.setUint16(10, 0, true) // last mod time — fixed for reproducibility + localView.setUint16(12, 0x0021, true) // last mod date: 1980-01-01, the epoch ZIP allows + localView.setUint32(14, checksum, true) + localView.setUint32(18, data.length, true) // compressed size + localView.setUint32(22, data.length, true) // uncompressed size + localView.setUint16(26, nameBytes.length, true) + localView.setUint16(28, 0, true) // extra field length + localHeader.set(nameBytes, 30) + + const centralHeader = new Uint8Array(46 + nameBytes.length) + const centralView = new DataView(centralHeader.buffer) + centralView.setUint32(0, 0x02014b50, true) // central directory signature + centralView.setUint16(4, 20, true) // version made by + centralView.setUint16(6, 20, true) // version needed to extract + centralView.setUint16(8, 0, true) + centralView.setUint16(10, 0, true) // STORED + centralView.setUint16(12, 0, true) + centralView.setUint16(14, 0x0021, true) + centralView.setUint32(16, checksum, true) + centralView.setUint32(20, data.length, true) + centralView.setUint32(24, data.length, true) + centralView.setUint16(28, nameBytes.length, true) + centralView.setUint16(30, 0, true) // extra field length + centralView.setUint16(32, 0, true) // file comment length + centralView.setUint16(34, 0, true) // disk number start + centralView.setUint16(36, 0, true) // internal file attributes + centralView.setUint32(38, 0o100644 << 16, true) // external attrs: regular file, rw-r--r-- + centralView.setUint32(42, offset, true) // relative offset of local header + centralHeader.set(nameBytes, 46) + + localParts.push(localHeader, data) + centralParts.push(centralHeader) + offset += localHeader.length + data.length + localSize += localHeader.length + data.length + } + + const centralSize = centralParts.reduce((total, part) => total + part.length, 0) + const endRecord = new Uint8Array(22) + const endView = new DataView(endRecord.buffer) + endView.setUint32(0, 0x06054b50, true) // end of central directory signature + endView.setUint16(4, 0, true) // this disk number + endView.setUint16(6, 0, true) // disk with central directory + endView.setUint16(8, entries.length, true) + endView.setUint16(10, entries.length, true) + endView.setUint32(12, centralSize, true) + endView.setUint32(16, localSize, true) // central directory offset + endView.setUint16(20, 0, true) // comment length + + return concat([...localParts, ...centralParts, endRecord]) +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(total) + let cursor = 0 + for (const part of parts) { + out.set(part, cursor) + cursor += part.length + } + return out +} + +/** + * Derive the deployment-package filename Lambda will look for from a handler + * string. `index.handler` means the exported `handler` lives in `index.js`; + * `src/app.run` means `run` lives in `src/app.js`. + */ +export function handlerFileName(handler: string, runtime: string): string { + const modulePath = handler.split('.').slice(0, -1).join('.') || 'index' + const extension = runtime.startsWith('python') ? 'py' : runtime.startsWith('ruby') ? 'rb' : 'js' + return `${modulePath}.${extension}` +} diff --git a/packages/api/src/adapter-azure/AzureAksAdapter.test.ts b/packages/api/src/adapter-azure/AzureAksAdapter.test.ts new file mode 100644 index 0000000..82ea47c --- /dev/null +++ b/packages/api/src/adapter-azure/AzureAksAdapter.test.ts @@ -0,0 +1,176 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureAksAdapter} from './AzureAksAdapter' +import {AzureRestRuntimeClient} from '../azure' +import {NotSupportedError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4577' +const SUB = '00000000-0000-0000-0000-000000000001' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): AzureAksAdapter { + return new AzureAksAdapter(new AzureRestRuntimeClient(ENDPOINT, 'devstoreaccount1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status}) +} + +function cluster(name: string, rg = 'rg-app', provisioningState = 'Succeeded') { + return { + id: `/subscriptions/${SUB}/resourceGroups/${rg}/providers/Microsoft.ContainerService/managedClusters/${name}`, + name, + type: 'Microsoft.ContainerService/managedClusters', + location: 'eastus', + properties: { + provisioningState, + kubernetesVersion: '1.29.0', + currentKubernetesVersion: '1.29.0', + dnsPrefix: `${name}dns`, + fqdn: `${name}dns.hcp.eastus.azmk8s.io`, + enableRBAC: true, + nodeResourceGroup: `MC_${rg}_${name}_eastus`, + agentPoolProfiles: [ + {name: 'nodepool1', count: 2, vmSize: 'Standard_D2s_v3', osType: 'Linux', mode: 'System'}, + ], + }, + } +} + +function runtimeStub(clusters: unknown[] = [cluster('prod')]) { + return stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/managedClusters/')) return json(clusters[0] ?? {}) + return json({value: clusters}) + }) +} + +describe('AzureAksAdapter', () => { + test('identifies itself as the Azure k8s adapter', () => { + const a = adapter() + + expect(a.cloud).toBe('azure') + expect(a.service).toBe('k8s') + expect(a.schema().displayName).toBe('Azure AKS') + }) + + test('lists clusters and maps the ARM shape onto the shared columns', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + // Same addressing as Azure VMs: ARM cannot reach a cluster without its + // resource group, and the generic route passes one id. + id: 'rg-app/prod', + name: 'prod', + cloud: 'azure', + service: 'k8s', + type: 'cluster', + region: 'eastus', + status: 'Succeeded', + version: '1.29.0', + }) + expect(resource?.metadata).toMatchObject({ + resourceGroup: 'rg-app', + fqdn: 'proddns.hcp.eastus.azmk8s.io', + nodeResourceGroup: 'MC_rg-app_prod_eastus', + enableRBAC: true, + nodeCount: 2, + }) + }) + + test('reports a failed provisioning state rather than hiding it', async () => { + // The local runtime never actually provisions a cluster: every create + // settles at Failed. Showing that is the whole point of the column. + runtimeStub([cluster('broken', 'rg-app', 'Failed')]) + const [resource] = await adapter().list() + + expect(resource?.status).toBe('Failed') + }) + + test('summarises the agent pools', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource?.metadata.agentPools).toEqual([ + {name: 'nodepool1', count: 2, vmSize: 'Standard_D2s_v3', osType: 'Linux', mode: 'System'}, + ]) + }) + + test('filters the list by search term', async () => { + runtimeStub([cluster('prod'), cluster('staging')]) + const a = adapter() + + await expect(a.list({search: 'prod'})).resolves.toHaveLength(1) + await expect(a.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a cluster addressed by resource group and name', async () => { + const calls = runtimeStub() + const resource = await adapter().get('rg-app/prod') + + const call = calls.find((c) => c.url.includes('/managedClusters/prod')) + expect(call?.url).toContain(`/resourceGroups/rg-app/providers/Microsoft.ContainerService/managedClusters/prod`) + expect(resource?.id).toBe('rg-app/prod') + }) + + test('rejects an id that does not name a resource group', async () => { + await expect(adapter().get('prod')).rejects.toThrow(ValidationError) + }) + + test('returns null when a cluster does not exist', async () => { + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + return json({error: {code: 'ResourceNotFound', message: 'not found'}}, 404) + }) + await expect(adapter().get('rg-app/nope')).resolves.toBeNull() + }) + + test('refuses create and delete because this runtime cannot provision a cluster', async () => { + // Deliberately a runtime-specific reason, not a category-wide one: GCP's + // GKE adapter does support create and delete, so "k8s is read-only + // everywhere" is false. What holds here is narrower and verified — the + // shipped floci-az config runs AKS unmocked with no Docker socket to + // start k3s with, so a created cluster sits at provisioningState Failed + // forever. Advertising create would be advertising a dead end. + const a = adapter() + + await expect(a.create({values: {name: 'prod'}})).rejects.toThrow(NotSupportedError) + await expect(a.delete('rg-app/prod')).rejects.toThrow(NotSupportedError) + }) + + test('sends the accept header the other Azure adapters send', async () => { + // azureJson and cosmosJson both set it, and some ARM endpoints are + // Accept-sensitive; diverging here makes error handling inconsistent. + const calls = runtimeStub() + await adapter().list() + + const headers = calls[0]?.init?.headers as Record | undefined + expect(headers?.accept).toBe('application/json') + }) + + test('treats an empty ARM response as no cluster rather than a parse error', async () => { + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + return new Response(null, {status: 204}) + }) + + await expect(adapter().get('rg-app/prod')).resolves.toBeNull() + }) + + test('advertises only list and inspect', () => { + expect(adapter().schema().actions).toEqual(['list', 'inspect']) + }) +}) diff --git a/packages/api/src/adapter-azure/AzureAksAdapter.ts b/packages/api/src/adapter-azure/AzureAksAdapter.ts new file mode 100644 index 0000000..e3b71f8 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureAksAdapter.ts @@ -0,0 +1,195 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {NotSupportedError, RuntimeError, ValidationError} from '../cloud-spi/errors' +import {azureAksSchema} from '../cloud-spi/eksSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-AZ runtime's ARM surface for + * Microsoft.ContainerService/managedClusters — verified against `floci/floci-az` + * 0.9.0: + * + * list GET /subscriptions/{s}/providers/Microsoft.ContainerService/managedClusters + * get GET /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.ContainerService/managedClusters/{n} + * + * Read-only, and for a reason specific to this runtime rather than to the + * category: GCP's GKE adapter does support create and delete, so "k8s is + * read-only everywhere" is not true. What is true here is that a cluster created + * against floci-az settles at `provisioningState: Failed` and stays there, and + * `docker ps` shows nothing starts — unlike GKE on floci-gcp, which really + * launches k3s. Advertising create would advertise something that cannot + * succeed. + * + * `DELETE` does work on the runtime (202, and the cluster disappears), but a + * delete-only surface in a category that cannot create is worse than none. + */ +const API_VERSION = '2023-10-01' + +interface ArmList { + value?: T[] +} + +interface AgentPoolProfile { + name?: string + count?: number + vmSize?: string + osType?: string + mode?: string +} + +interface AksCluster { + id?: string + name?: string + location?: string + tags?: Record + properties?: { + provisioningState?: string + kubernetesVersion?: string + currentKubernetesVersion?: string + dnsPrefix?: string + fqdn?: string + enableRBAC?: boolean + nodeResourceGroup?: string + agentPoolProfiles?: AgentPoolProfile[] + } +} + +export class AzureAksAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'k8s' as const + + private subscriptionId: string | null = null + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureAksSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/providers/Microsoft.ContainerService/managedClusters?api-version=${API_VERSION}`, + ) + return filterBySearch((body?.value ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const subscription = await this.subscription() + const path = `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.ContainerService/managedClusters/${encodeURIComponent(name)}` + + const cluster = await this.json( + `${path}?api-version=${API_VERSION}`, + {}, + {emptyOnNotFound: true}, + ) + return cluster ? toResource(cluster) : null + } + + async create(_input: CreateResourceInput): Promise { + throw new NotSupportedError('AKS cluster creation is not supported from the dynamic Cloud Explorer.') + } + + async delete(_id: string): Promise { + throw new NotSupportedError('AKS cluster deletion is not supported from the dynamic Cloud Explorer.') + } + + /** The runtime ignores the subscription id, but a real one keeps ids honest. */ + private async subscription(): Promise { + if (this.subscriptionId) return this.subscriptionId + + const body = await this.json>('/subscriptions') + const id = body?.value?.[0]?.subscriptionId + if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope cluster requests to') + + this.subscriptionId = id + return id + } + + /** + * Mirrors the `azureJson`/`cosmosJson` helpers the other Azure adapters use: + * ARM endpoints can be Accept-sensitive, and a 204 carries no body to parse. + * + * Several near-identical copies of this now exist under adapter-azure/. Worth + * lifting onto AzureRuntimeClient once the open Azure PRs have merged — doing + * it in parallel branches would only conflict. + */ + private async json(path: string, init: RequestInit = {}, options = {}): Promise { + const res = await this.client.fetch( + path, + { + ...init, + headers: { + accept: 'application/json', + ...(init.headers ?? {}), + }, + }, + options, + ) + if (!res || res.status === 204) return null + + const text = await res.text() + if (!text) return null + return JSON.parse(text) as T + } +} + +function toResource(cluster: AksCluster): CloudResource { + const properties = cluster.properties ?? {} + const name = cluster.name ?? '' + const resourceGroup = resourceGroupOf(cluster.id ?? '') + const pools = properties.agentPoolProfiles ?? [] + + return { + id: resourceGroup ? `${resourceGroup}/${name}` : name, + name, + cloud: 'azure', + service: 'k8s', + type: 'cluster', + region: cluster.location ?? null, + // ARM does not return a creation time for a managed cluster, and inventing + // one would be fake data. + createdAt: null, + status: properties.provisioningState ?? null, + version: properties.currentKubernetesVersion ?? properties.kubernetesVersion ?? null, + metadata: { + provider: 'azure', + resourceId: cluster.id, + resourceGroup, + dnsPrefix: properties.dnsPrefix, + fqdn: properties.fqdn, + enableRBAC: properties.enableRBAC, + nodeResourceGroup: properties.nodeResourceGroup, + agentPools: pools, + nodeCount: pools.reduce((total, pool) => total + (pool.count ?? 0), 0), + tags: cluster.tags, + }, + } +} + +/** ARM cannot address a cluster without its resource group, so the id carries both. */ +function parseId(id: string): {resourceGroup: string; name: string} { + const [resourceGroup, ...rest] = id.split('/') + const name = rest.join('/') + if (!resourceGroup || !name) { + throw new ValidationError(`cluster id must be "resourceGroup/name", got "${id}"`) + } + return {resourceGroup, name} +} + +function resourceGroupOf(resourceId: string): string { + const match = resourceId.match(/resourceGroups\/([^/]+)/i) + return match ? match[1] : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts b/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts new file mode 100644 index 0000000..2639ad0 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureComputeAdapter.test.ts @@ -0,0 +1,275 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureComputeAdapter} from './AzureComputeAdapter' +import {AzureRestRuntimeClient} from '../azure' +import {ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4577' +const SUB = '00000000-0000-0000-0000-000000000001' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): AzureComputeAdapter { + return new AzureComputeAdapter(new AzureRestRuntimeClient(ENDPOINT, 'devstoreaccount1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status}) +} + +function vm(name: string, rg = 'rg-app', overrides: Record = {}) { + return { + id: `/subscriptions/${SUB}/resourceGroups/${rg}/providers/Microsoft.Compute/virtualMachines/${name}`, + name, + type: 'Microsoft.Compute/virtualMachines', + location: 'eastus', + properties: { + hardwareProfile: {vmSize: 'Standard_B1s'}, + storageProfile: { + imageReference: {publisher: 'Canonical', offer: 'UbuntuServer', sku: '20.04-LTS', version: 'latest'}, + }, + osProfile: {computerName: name, adminUsername: 'azureuser'}, + vmId: '4190719c-60b8-4a37-9346-6bf7be1730e0', + provisioningState: 'Succeeded', + timeCreated: '2026-07-28T09:37:46.091123168Z', + ...overrides, + }, + } +} + +function instanceView(power = 'PowerState/running') { + return { + computerName: 'vm', + osName: 'Linux', + statuses: [ + {code: 'ProvisioningState/succeeded', displayStatus: 'Provisioning succeeded', level: 'Info'}, + {code: power, displayStatus: 'VM running', level: 'Info'}, + ], + } +} + +/** Routes each request to what the runtime actually answers for it. */ +function runtimeStub(vms: unknown[] = [vm('web')], power = 'PowerState/running') { + return stubFetch((url) => { + if (url.includes('/subscriptions') && url.endsWith('/subscriptions')) { + return json({value: [{subscriptionId: SUB, displayName: 'floci-az local', state: 'Enabled'}]}) + } + if (url.includes('/instanceView')) return json(instanceView(power)) + if (url.includes('/virtualMachines/')) return json(vms[0] ?? {}) + if (url.includes('/resourceGroups') && !url.includes('/providers')) { + return json({value: [{name: 'rg-app', id: `/subscriptions/${SUB}/resourceGroups/rg-app`}]}) + } + return json({value: vms}) + }) +} + +describe('AzureComputeAdapter', () => { + test('identifies itself as the Azure compute adapter', () => { + const a = adapter() + + expect(a.cloud).toBe('azure') + expect(a.service).toBe('compute') + expect(a.schema().displayName).toBe('Azure Virtual Machines') + }) + + test('lists VMs and maps the ARM resource shape', async () => { + runtimeStub() + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + // The id carries the resource group, because ARM cannot address a VM + // without it and the generic route only passes one id. + id: 'rg-app/web', + name: 'web', + cloud: 'azure', + service: 'compute', + type: 'instance', + region: 'eastus', + instanceClass: 'Standard_B1s', + createdAt: '2026-07-28T09:37:46.091123168Z', + }) + expect(resource?.metadata).toMatchObject({ + resourceGroup: 'rg-app', + provisioningState: 'Succeeded', + adminUsername: 'azureuser', + image: 'Canonical:UbuntuServer:20.04-LTS', + }) + }) + + test('reports the power state from the instance view', async () => { + runtimeStub([vm('web')], 'PowerState/stopped') + const [resource] = await adapter().list() + + expect(resource?.status).toBe('stopped') + }) + + test('still lists VMs when the instance view fails', async () => { + // Power state is enrichment fanned out per VM. An unisolated failure would + // reject the whole list and show nothing. + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/instanceView')) return json({error: {message: 'boom'}}, 500) + return json({value: [vm('web')]}) + }) + const [resource] = await adapter().list() + + expect(resource?.id).toBe('rg-app/web') + expect(resource?.status).toBeNull() + expect(resource?.metadata.powerStateUnavailable).toBe(true) + }) + + test('filters the list by search term', async () => { + runtimeStub([vm('web'), vm('api')]) + const a = adapter() + + await expect(a.list({search: 'we'})).resolves.toHaveLength(1) + await expect(a.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a VM addressed by resource group and name', async () => { + const calls = runtimeStub() + const resource = await adapter().get('rg-app/web') + + const vmCall = calls.find((c) => c.url.includes('/virtualMachines/web')) + expect(vmCall?.url).toContain(`/subscriptions/${SUB}/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/web`) + expect(resource?.id).toBe('rg-app/web') + }) + + test('rejects an id that does not name a resource group', async () => { + await expect(adapter().get('web')).rejects.toThrow(ValidationError) + }) + + test('returns null when a VM does not exist', async () => { + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + return json({error: {message: 'not found', code: 'ResourceNotFound'}}, 404) + }) + await expect(adapter().get('rg-app/nope')).resolves.toBeNull() + }) + + test('creates a VM with the image reference the label maps to', async () => { + const calls = runtimeStub() + await adapter().create({ + values: {name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s', image: 'Ubuntu 22.04 LTS'}, + }) + + const put = calls.find((c) => c.init?.method === 'PUT') + expect(put?.url).toContain('/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/web') + const body = JSON.parse(String(put?.init?.body)) + expect(body.location).toBe('eastus') + expect(body.properties.hardwareProfile.vmSize).toBe('Standard_B1s') + expect(body.properties.storageProfile.imageReference).toEqual({ + publisher: 'Canonical', + offer: '0001-com-ubuntu-server-jammy', + sku: '22_04-lts', + version: 'latest', + }) + expect(body.properties.osProfile.adminUsername).toBe('azureuser') + }) + + test('verifies the resource group exists, because the runtime does not', async () => { + // The local runtime happily creates a VM in a resource group that does not + // exist and returns 201. Real Azure answers ResourceGroupNotFound, so the + // check belongs here or the console would create unreachable resources. + stubFetch((url) => { + if (url.endsWith('/subscriptions')) return json({value: [{subscriptionId: SUB}]}) + if (url.includes('/resourceGroups') && !url.includes('/providers')) return json({value: []}) + return json(vm('web')) + }) + + await expect( + adapter().create({ + values: {name: 'web', resourceGroup: 'rg-missing', vmSize: 'Standard_B1s', image: 'Debian 11'}, + }), + ).rejects.toThrow(ValidationError) + }) + + test('accepts a resource group whose case differs from the runtime', async () => { + // Azure treats resource group names as case-insensitive, so rejecting + // "RG-App" when "rg-app" exists would block a create that should succeed. + const calls = runtimeStub() + await adapter().create({ + values: {name: 'web', resourceGroup: 'RG-App', vmSize: 'Standard_B1s', image: 'Debian 11'}, + }) + + expect(calls.some((c) => c.init?.method === 'PUT')).toBe(true) + }) + + test("uses the runtime's spelling of the resource group, not the caller's", async () => { + // The id is `resourceGroup/name`, so echoing the caller's casing would emit + // an id that does not match what list() returns and inspect would 404. + const calls = runtimeStub() + const resource = await adapter().create({ + values: {name: 'web', resourceGroup: 'RG-App', vmSize: 'Standard_B1s', image: 'Debian 11'}, + }) + + const put = calls.find((c) => c.init?.method === 'PUT') + expect(put?.url).toContain('/resourceGroups/rg-app/') + expect(put?.url).not.toContain('RG-App') + expect(resource.id).toBe('rg-app/web') + }) + + test('requires the fields the schema marks required', async () => { + runtimeStub() + const a = adapter() + + const cases: Array<[Record, string]> = [ + [{}, 'name is required'], + [{name: 'web'}, 'resourceGroup is required'], + [{name: 'web', resourceGroup: 'rg-app'}, 'vmSize is required'], + [{name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s'}, 'image is required'], + ] + + for (const [values, message] of cases) { + await expect(a.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('rejects values the schema does not offer', async () => { + runtimeStub() + const a = adapter() + const base = {name: 'web', resourceGroup: 'rg-app', vmSize: 'Standard_B1s', image: 'Debian 11'} + + await expect(a.create({values: {...base, vmSize: 'Standard_MEGA'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...base, image: 'TempleOS'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...base, location: 'mars1'}})).rejects.toThrow(ValidationError) + await expect(a.create({values: {...base, name: 'bad name!'}})).rejects.toThrow(ValidationError) + }) + + test('deletes a VM', async () => { + const calls = runtimeStub() + await adapter().delete('rg-app/web') + + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('/virtualMachines/web') + }) + + describe('lifecycle verbs map to the ARM actions', () => { + const cases: Array<['start' | 'stop' | 'reboot', string]> = [ + ['start', '/start'], + ['stop', '/powerOff'], + ['reboot', '/restart'], + ] + + for (const [verb, suffix] of cases) { + test(`${verb} posts to ${suffix}`, async () => { + const calls = runtimeStub() + await adapter()[verb]('rg-app/web') + + const post = calls.find((c) => c.init?.method === 'POST') + expect(post?.url).toContain(`/virtualMachines/web${suffix}`) + }) + } + }) +}) diff --git a/packages/api/src/adapter-azure/AzureComputeAdapter.ts b/packages/api/src/adapter-azure/AzureComputeAdapter.ts new file mode 100644 index 0000000..ad745af --- /dev/null +++ b/packages/api/src/adapter-azure/AzureComputeAdapter.ts @@ -0,0 +1,329 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { + AZURE_LOCATIONS, + AZURE_VM_IMAGES, + AZURE_VM_NAME_PATTERN, + AZURE_VM_SIZES, + azureComputeSchema, +} from '../cloud-spi/computeSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-AZ runtime's ARM surface for Microsoft.Compute — verified + * against `floci/floci-az` 0.9.0: + * + * list GET /subscriptions/{s}/providers/Microsoft.Compute/virtualMachines + * get GET /subscriptions/{s}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{n} + * create PUT (same path as get) -> 201 + * delete DELETE (same path as get) -> 204 + * power GET (get path)/instanceView -> statuses[] + * start POST (get path)/start -> 202 + * stop POST (get path)/powerOff -> 202 + * reboot POST (get path)/restart -> 202 + * + * This corrects an earlier assumption that floci-az answers 501 for everything + * beyond blob and Cosmos. That is true of the *legacy* endpoints (`/functions`, + * `/Tables`) but not of ARM: the provider paths are real handlers, confirmed + * because a bogus provider 404s with `Unsupported Microsoft.Compute path`. + * + * Notes from probing the runtime: + * - It does **not** partition by subscription: any subscription id returns the + * same resources. The subscription is therefore discovered from + * `/subscriptions` and used as a fixed scope; it is never presented as a + * selectable scope, which would be fake data. + * - Lifecycle verbs genuinely change power state, so they are advertised. + * - `create` succeeds with 201 even when the resource group does not exist. Real + * Azure answers ResourceGroupNotFound, so this adapter checks first. + * - Resource group DELETE returns 200 and does nothing, so this adapter does not + * manage resource groups at all. + */ +const API_VERSION = '2023-03-01' +const RESOURCE_API_VERSION = '2021-04-01' + +interface ArmList { + value?: T[] +} + +interface AzureVm { + id?: string + name?: string + location?: string + tags?: Record + properties?: { + hardwareProfile?: {vmSize?: string} + storageProfile?: { + imageReference?: {publisher?: string; offer?: string; sku?: string; version?: string} + osDisk?: Record + } + osProfile?: {computerName?: string; adminUsername?: string} + vmId?: string + provisioningState?: string + timeCreated?: string + } +} + +interface AzureInstanceView { + computerName?: string + osName?: string + statuses?: Array<{code?: string; displayStatus?: string; level?: string}> +} + +/** Power state plus whether the lookup failed, so the two stay distinguishable. */ +type PowerLookup = {state: string | null; unavailable?: true} + +export class AzureComputeAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'compute' as const + + private subscriptionId: string | null = null + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureComputeSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/providers/Microsoft.Compute/virtualMachines?api-version=${API_VERSION}`, + ) + + const resources = await Promise.all( + (body?.value ?? []).map(async (machine) => toResource(machine, await this.powerState(machine.id ?? ''))), + ) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vmPath(resourceGroup, name) + const machine = await this.json(`${path}?api-version=${API_VERSION}`, {}, {emptyOnNotFound: true}) + if (!machine) return null + return toResource(machine, await this.powerState(machine.id ?? '')) + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (!new RegExp(AZURE_VM_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must start with a letter or digit and contain only letters, digits, hyphens, underscores and periods', + ) + } + const requestedGroup = requiredString(input.values.resourceGroup, 'resourceGroup') + const vmSize = requiredOneOf(input.values.vmSize, AZURE_VM_SIZES, 'vmSize') + const imageLabel = requiredOneOf( + input.values.image, + Object.keys(AZURE_VM_IMAGES) as Array, + 'image', + ) + const location = optionalOneOf(input.values.location, AZURE_LOCATIONS, 'location') ?? 'eastus' + const adminUsername = optionalString(input.values.adminUsername, 'adminUsername') ?? 'azureuser' + + const resourceGroup = await this.resolveResourceGroup(requestedGroup) + + const path = await this.vmPath(resourceGroup, name) + await this.json(`${path}?api-version=${API_VERSION}`, { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + location, + properties: { + hardwareProfile: {vmSize}, + storageProfile: {imageReference: AZURE_VM_IMAGES[imageLabel]}, + osProfile: {computerName: name, adminUsername}, + }, + }), + }) + + const created = await this.get(`${resourceGroup}/${name}`) + if (!created) throw new RuntimeError(`VM ${name} was created but could not be read back`) + return created + } + + async delete(id: string): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vmPath(resourceGroup, name) + await this.client.fetch(`${path}?api-version=${API_VERSION}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + async start(id: string): Promise { + await this.power(id, 'start') + } + + /** ARM calls it powerOff; `deallocate` also exists but releases the compute. */ + async stop(id: string): Promise { + await this.power(id, 'powerOff') + } + + async reboot(id: string): Promise { + await this.power(id, 'restart') + } + + private async power(id: string, action: 'start' | 'powerOff' | 'restart'): Promise { + const {resourceGroup, name} = parseId(id) + const path = await this.vmPath(resourceGroup, name) + await this.client.fetch(`${path}/${action}?api-version=${API_VERSION}`, {method: 'POST'}) + } + + /** + * The runtime ignores the subscription id, but a real one keeps the emitted + * resource ids honest, so it is discovered once rather than hardcoded. + */ + private async subscription(): Promise { + if (this.subscriptionId) return this.subscriptionId + + const body = await this.json>('/subscriptions') + const id = body?.value?.[0]?.subscriptionId + if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope compute requests to') + + this.subscriptionId = id + return id + } + + private async vmPath(resourceGroup: string, name: string): Promise { + const subscription = await this.subscription() + return `/subscriptions/${subscription}/resourceGroups/${encodeURIComponent(resourceGroup)}/providers/Microsoft.Compute/virtualMachines/${encodeURIComponent(name)}` + } + + /** + * Resolve a caller-supplied resource group to the spelling the runtime uses, + * failing if it does not exist. + * + * Two reasons this is not a plain equality check. The runtime creates a VM in a + * nonexistent resource group and returns 201 while real Azure answers + * ResourceGroupNotFound, so the existence check has to live here. And Azure + * treats these names case-insensitively, so `RG-App` must match `rg-app` — but + * the *runtime's* casing is what gets used, because the resource id is + * `resourceGroup/name` and echoing the caller's casing would emit an id that + * does not match the one `list()` reports. + */ + private async resolveResourceGroup(resourceGroup: string): Promise { + const subscription = await this.subscription() + const body = await this.json>( + `/subscriptions/${subscription}/resourceGroups?api-version=${RESOURCE_API_VERSION}`, + ) + const match = (body?.value ?? []).find( + (group) => group.name?.toLowerCase() === resourceGroup.toLowerCase(), + ) + if (!match?.name) { + throw new ValidationError(`resource group ${resourceGroup} does not exist; create it before adding a VM`) + } + return match.name + } + + /** + * Power state comes from a per-VM instanceView call, so a failure degrades the + * row rather than the request — `list` fans these out through `Promise.all`. + */ + private async powerState(resourceId: string): Promise { + if (!resourceId) return {state: null} + + try { + const view = await this.json(`${resourceId}/instanceView?api-version=${API_VERSION}`) + const power = (view?.statuses ?? []).find((status) => status.code?.startsWith('PowerState/')) + return {state: power?.code?.slice('PowerState/'.length) ?? null} + } catch { + return {state: null, unavailable: true} + } + } + + private async json(path: string, init: RequestInit = {}, options = {}): Promise { + const res = await this.client.fetch(path, init, options) + if (!res) return null + + const text = await res.text() + if (!text) return null + return JSON.parse(text) as T + } +} + +function toResource(machine: AzureVm, power: PowerLookup): CloudResource { + const properties = machine.properties ?? {} + const name = machine.name ?? '' + const resourceGroup = resourceGroupOf(machine.id ?? '') + const image = properties.storageProfile?.imageReference + + return { + id: resourceGroup ? `${resourceGroup}/${name}` : name, + name, + cloud: 'azure', + service: 'compute', + type: 'instance', + region: machine.location ?? null, + createdAt: properties.timeCreated ?? null, + status: power.state, + instanceClass: properties.hardwareProfile?.vmSize ?? null, + metadata: { + provider: 'azure', + resourceId: machine.id, + resourceGroup, + vmId: properties.vmId, + provisioningState: properties.provisioningState, + computerName: properties.osProfile?.computerName, + adminUsername: properties.osProfile?.adminUsername, + image: image ? `${image.publisher}:${image.offer}:${image.sku}` : undefined, + imageVersion: image?.version, + osDisk: properties.storageProfile?.osDisk, + tags: machine.tags, + ...(power.unavailable ? {powerStateUnavailable: true} : {}), + }, + } +} + +/** ARM cannot address a VM without its resource group, so the id carries both. */ +function parseId(id: string): {resourceGroup: string; name: string} { + const [resourceGroup, ...rest] = id.split('/') + const name = rest.join('/') + if (!resourceGroup || !name) { + throw new ValidationError(`VM id must be "resourceGroup/name", got "${id}"`) + } + return {resourceGroup, name} +} + +function resourceGroupOf(resourceId: string): string { + const match = resourceId.match(/resourceGroups\/([^/]+)/i) + return match ? match[1] : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function requiredString(value: unknown, field: string): string { + const raw = optionalString(value, field) + if (raw === undefined) throw new ValidationError(`${field} is required`) + return raw +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`) + return value.trim() || undefined +} + +function requiredOneOf(value: unknown, allowed: T, field: string): T[number] { + const raw = requiredString(value, field) + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} + +function optionalOneOf( + value: unknown, + allowed: T, + field: string, +): T[number] | undefined { + const raw = optionalString(value, field) + if (raw === undefined) return undefined + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts new file mode 100644 index 0000000..5a31e38 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts @@ -0,0 +1,104 @@ +import {describe, expect, test} from 'bun:test' +import type { + CloudResource, + CreateResourceInput, + ResourceQuery, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' +import {AzureDatabaseAdapter} from './AzureDatabaseAdapter' + +describe('AzureDatabaseAdapter', () => { + test('combines engines and dispatches create and data operations', async () => { + const calls: string[] = [] + const sql = engineAdapter('sql-server', 'azure-sql', calls) + const postgres = engineAdapter('postgres-flexible-server', 'postgresql', calls) + const adapter = new AzureDatabaseAdapter(sql, postgres) + + await expect(adapter.list()).resolves.toHaveLength(2) + await expect(adapter.create({values: {engine: 'postgresql'}})).resolves.toMatchObject({engine: 'postgresql'}) + await adapter.querySql('postgres-server', { + username: 'pgadmin', + password: 'password', + engine: 'postgresql', + }, 'SELECT 1') + + expect(calls).toEqual([ + 'azure-sql:list', + 'postgresql:list', + 'postgresql:create', + 'postgresql:query', + ]) + }) + + test('still lists one engine when the other is unavailable', async () => { + const calls: string[] = [] + const sql = engineAdapter('sql-server', 'azure-sql', calls) + const postgres = engineAdapter('postgres-flexible-server', 'postgresql', calls) + postgres.list = async () => { + throw new Error('PostgreSQL is not configured on this runtime') + } + + const resources = await new AzureDatabaseAdapter(sql, postgres).list() + + expect(resources.map((resource) => resource.type)).toEqual(['sql-server']) + }) + + test('surfaces the failure when both engines are unavailable', async () => { + const calls: string[] = [] + const sql = engineAdapter('sql-server', 'azure-sql', calls) + const postgres = engineAdapter('postgres-flexible-server', 'postgresql', calls) + sql.list = async () => { + throw new Error('Cannot reach Floci-AZ') + } + postgres.list = async () => { + throw new Error('PostgreSQL is not configured on this runtime') + } + + await expect(new AzureDatabaseAdapter(sql, postgres).list()).rejects.toThrow('Cannot reach Floci-AZ') + }) +}) + +function engineAdapter( + type: 'sql-server' | 'postgres-flexible-server', + engine: 'azure-sql' | 'postgresql', + calls: string[], +) { + const resource: CloudResource = { + id: `${type}:server`, + name: 'server', + cloud: 'azure', + service: 'database', + type, + region: 'eastus', + createdAt: null, + engine, + metadata: {}, + } + return { + async list(_query?: ResourceQuery): Promise { + calls.push(`${engine}:list`) + return [resource] + }, + async get(): Promise { + return resource + }, + async create(_input: CreateResourceInput): Promise { + calls.push(`${engine}:create`) + return resource + }, + async delete(): Promise {}, + async listSqlDatabases(_serverId: string, _connection: SqlConnectionInput): Promise { + return [] + }, + async listSqlTables(_serverId: string, _connection: SqlConnectionInput): Promise { + return [] + }, + async querySql(_serverId: string, _connection: SqlConnectionInput, _query: string): Promise { + calls.push(`${engine}:query`) + return {resultSets: [], rowsAffected: [], durationMs: 0} + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts new file mode 100644 index 0000000..9380b64 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts @@ -0,0 +1,94 @@ +import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' +import {AzurePostgresAdapter, isPostgresServerResourceId} from './AzurePostgresAdapter' +import {AzureSqlAdapter, isSqlServerResourceId} from './AzureSqlAdapter' + +interface AzureDatabaseEngineAdapter { + list(query?: ResourceQuery): Promise + get(id: string): Promise + create(input: CreateResourceInput): Promise + delete(id: string): Promise + listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise + listSqlTables(serverId: string, connection: SqlConnectionInput): Promise + querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise +} + +export class AzureDatabaseAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'database' as const + + constructor( + private readonly sql: AzureDatabaseEngineAdapter = new AzureSqlAdapter(), + private readonly postgres: AzureDatabaseEngineAdapter = new AzurePostgresAdapter(), + ) {} + + schema(): ServiceSchema { + return azureDatabaseSchema() + } + + // The two engines are independent resource groups. A deployment that runs only one of + // them must still list that one, so a single engine failing is tolerated; both failing + // still surfaces, and so does any failure once the other engine has answered nothing. + async list(query: ResourceQuery = {}): Promise { + const [sqlServers, postgresServers] = await Promise.allSettled([ + this.sql.list(query), + this.postgres.list(query), + ]) + + if (sqlServers.status === 'rejected' && postgresServers.status === 'rejected') { + throw sqlServers.reason + } + + return [ + ...(sqlServers.status === 'fulfilled' ? sqlServers.value : []), + ...(postgresServers.status === 'fulfilled' ? postgresServers.value : []), + ] + } + + async get(id: string): Promise { + if (isPostgresServerResourceId(id)) return this.postgres.get(id) + if (isSqlServerResourceId(id)) return this.sql.get(id) + const sqlServer = await this.sql.get(id) + return sqlServer ?? this.postgres.get(id) + } + + create(input: CreateResourceInput): Promise { + return input.values.engine === 'postgresql' + ? this.postgres.create(input) + : this.sql.create(input) + } + + delete(id: string): Promise { + return isPostgresServerResourceId(id) + ? this.postgres.delete(id) + : this.sql.delete(id) + } + + listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + return this.dataAdapter(serverId, connection).listSqlDatabases(serverId, connection) + } + + listSqlTables(serverId: string, connection: SqlConnectionInput): Promise { + return this.dataAdapter(serverId, connection).listSqlTables(serverId, connection) + } + + querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise { + return this.dataAdapter(serverId, connection).querySql(serverId, connection, query) + } + + private dataAdapter(serverId: string, connection: SqlConnectionInput): AzureDatabaseEngineAdapter { + return connection.engine === 'postgresql' || isPostgresServerResourceId(serverId) + ? this.postgres + : this.sql + } +} diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts new file mode 100644 index 0000000..f1ff108 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts @@ -0,0 +1,210 @@ +import {describe, expect, test} from 'bun:test' +import {AzureKeyVaultAdapter} from './AzureKeyVaultAdapter' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' + +interface RecordedCall { + path: string + init: RequestInit + options: AzureRuntimeFetchOptions +} + +describe('AzureKeyVaultAdapter', () => { + test('lists and filters Key Vault secrets without exposing values', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets?api-version=7.4': { + value: [listedSecretRecord('api-token')], + nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4&next=page-2', + }, + '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2': { + value: [listedSecretRecord('database-password', {env: 'test'})], + nextLink: null, + }, + }, calls)) + + await expect(adapter.list({search: 'database'})).resolves.toEqual([{ + id: 'database-password', + name: 'database-password', + cloud: 'azure', + service: 'secrets', + type: 'secret', + region: null, + createdAt: '2026-05-20T20:00:00.000Z', + status: 'enabled', + version: null, + metadata: { + provider: 'azure', + secretsService: 'key-vault', + vaultAccount: 'devstoreaccount1', + contentType: 'text/plain', + updatedAt: '2026-05-20T20:00:01.000Z', + expiresAt: null, + notBefore: null, + recoveryLevel: 'Purgeable', + recoverableDays: 7, + tags: [{key: 'env', value: 'test'}], + }, + }]) + expect(calls.map((call) => call.path)).toEqual([ + '/devstoreaccount1-keyvault/secrets?api-version=7.4', + '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2', + ]) + expect(calls.every((call) => call.options.includeStorageApiVersion === false)).toBe(true) + }) + + test('stops paging when the runtime echoes the current page as its nextLink', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets?api-version=7.4': { + value: [listedSecretRecord('api-token')], + nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4', + }, + }, calls)) + + await expect(adapter.list()).resolves.toHaveLength(1) + expect(calls).toHaveLength(1) + }) + + test('gets a secret and omits its value from normalized metadata', async () => { + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/database-password?api-version=7.4': { + ...secretRecord('database-password', 'v1'), + value: 'super-secret', + }, + })) + + const resource = await adapter.get('database-password') + + expect(resource).toMatchObject({id: 'database-password', version: 'v1'}) + expect(resource?.metadata).not.toHaveProperty('value') + }) + + test('creates secrets through the Azure Key Vault data-plane contract', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': { + ...secretRecord('api-key', 'created-version'), + value: 'abc123', + contentType: 'application/json', + }, + }, calls)) + + const created = await adapter.create({values: { + secretName: 'api-key', + secretValue: 'abc123', + contentType: 'application/json', + }}) + + expect(created).toMatchObject({id: 'api-key', version: 'created-version'}) + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe('/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4') + expect(calls[0].init.method).toBe('PUT') + expect(calls[0].init.headers).toMatchObject({ + authorization: 'Bearer floci-ui', + 'content-type': 'application/json', + }) + expect(calls[0].options.includeStorageApiVersion).toBe(false) + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + value: 'abc123', + contentType: 'application/json', + }) + }) + + test('deletes secrets using soft-delete endpoint', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {}, + }, calls)) + + await adapter.delete('api-key') + + expect(calls).toHaveLength(1) + expect(calls[0].init.method).toBe('DELETE') + expect(calls[0].init.headers).toMatchObject({authorization: 'Bearer floci-ui'}) + expect(calls[0].options.includeStorageApiVersion).toBe(false) + expect(calls[0].options.emptyOnNotFound).toBeUndefined() + }) + + test('surfaces a delete of a secret that does not exist', async () => { + const adapter = new AzureKeyVaultAdapter({ + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string) { + throw new Error(`Azure runtime request failed: HTTP 404 ${path}`) + }, + }) + + await expect(adapter.delete('missing')).rejects.toThrow('HTTP 404') + }) + + test('normalizes missing secrets to null and missing lists to empty', async () => { + const adapter = new AzureKeyVaultAdapter(testClient({})) + + await expect(adapter.get('missing')).resolves.toBeNull() + await expect(adapter.list()).resolves.toEqual([]) + }) + + test('validates required inputs before calling the runtime', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({}, calls)) + + await expect(adapter.create({values: {secretName: 'not valid', secretValue: 'value'}})) + .rejects.toThrow('Use a valid Key Vault secret name') + await expect(adapter.create({values: {secretName: 'valid-name', secretValue: ''}})) + .rejects.toThrow('secretValue is required') + expect(calls).toHaveLength(0) + }) + + test('exposes Azure Key Vault CRUD capabilities in its schema', () => { + const schema = new AzureKeyVaultAdapter(testClient({})).schema() + + expect(schema.service).toBe('secrets') + expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect']) + expect(schema.fields.find((field) => field.name === 'secretValue')?.type).toBe('password') + const namePattern = schema.fields.find((field) => field.name === 'secretName')?.validation?.pattern + expect(() => new RegExp(namePattern ?? '', 'v')).not.toThrow() + }) +}) + +// The list endpoint returns base identifiers; only a single-secret read carries a version. +function listedSecretRecord(name: string, tags: Record = {}) { + return secretRecord(name, null, tags) +} + +function secretRecord(name: string, version: string | null, tags: Record = {}) { + return { + id: `https://devstoreaccount1.vault.azure.net/secrets/${name}${version ? `/${version}` : ''}`, + attributes: { + enabled: true, + created: 1779307200, + updated: 1779307201, + exp: null, + nbf: null, + recoveryLevel: 'Purgeable', + recoverableDays: 7, + }, + contentType: 'text/plain', + tags, + } +} + +function testClient( + responses: Record, + calls: RecordedCall[] = [], +): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init, options}) + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + throw new Error(`Unexpected Key Vault path: ${path}`) + } + return new Response(JSON.stringify(responses[path]), { + status: 200, + headers: {'content-type': 'application/json'}, + }) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts new file mode 100644 index 0000000..65c04e4 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts @@ -0,0 +1,218 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {azureSecretsSchema, SECRET_NAME_MESSAGE, SECRET_NAME_PATTERN} from '../cloud-spi/secretsSchema' +import {ValidationError} from '../cloud-spi/errors' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +const API_VERSION = '7.4' + +interface KeyVaultSecretAttributes { + enabled?: boolean + created?: number + updated?: number + exp?: number | null + nbf?: number | null + recoveryLevel?: string + recoverableDays?: number +} + +interface KeyVaultSecretRecord { + id?: string + value?: string + attributes?: KeyVaultSecretAttributes + contentType?: string + tags?: Record +} + +interface KeyVaultSecretListResponse { + value?: KeyVaultSecretRecord[] + nextLink?: string | null +} + +export class AzureKeyVaultAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'secrets' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureSecretsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const records: KeyVaultSecretRecord[] = [] + const visited = new Set() + let path: string | null = `/secrets?api-version=${API_VERSION}` + + // A runtime that echoes the current page as its own nextLink would otherwise + // loop forever, so stop as soon as a page repeats. + while (path && !visited.has(path)) { + visited.add(path) + const body: KeyVaultSecretListResponse | null = await this.keyVaultJson( + path, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + records.push(...(body?.value ?? [])) + path = body?.nextLink ? keyVaultNextPath(body.nextLink) : null + } + + return filterBySearch(records.map((record) => toSecretResource(record)), query.search) + } + + async get(id: string): Promise { + const body = await this.keyVaultJson( + `/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + return body ? toSecretResource(body, id) : null + } + + async create(input: CreateResourceInput): Promise { + const secretName = stringValue(input.values.secretName ?? input.values.name) + const secretValue = stringValue(input.values.secretValue ?? input.values.value, false) + const contentType = stringValue(input.values.contentType) + + if (!secretName) throw new ValidationError('secretName is required') + if (!new RegExp(SECRET_NAME_PATTERN).test(secretName)) throw new ValidationError(SECRET_NAME_MESSAGE) + if (!secretValue) throw new ValidationError('secretValue is required') + + const body = await this.keyVaultJson( + `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`, + { + method: 'PUT', + body: JSON.stringify({ + value: secretValue, + ...(contentType ? {contentType} : {}), + }), + }, + ) + + if (!body) throw new Error('Azure Key Vault create returned an empty response') + return toSecretResource(body, secretName) + } + + async delete(id: string): Promise { + await this.client.fetch( + this.keyVaultPath(`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`), + {method: 'DELETE', headers: keyVaultHeaders()}, + {includeStorageApiVersion: false}, + ) + } + + private async keyVaultJson( + path: string, + init: RequestInit, + options?: {emptyOnNotFound?: boolean}, + ): Promise { + const res = await this.client.fetch( + this.keyVaultPath(path), + { + ...init, + headers: { + ...keyVaultHeaders(), + ...(init.headers ?? {}), + }, + }, + {...options, includeStorageApiVersion: false}, + ) + + if (!res || res.status === 204) return null + return await res.json() as T + } + + private keyVaultPath(path: string): string { + return `/${encodeURIComponent(this.client.accountName)}-keyvault${path}` + } +} + +function keyVaultNextPath(nextLink: string): string { + try { + const url = new URL(nextLink) + return `${url.pathname}${url.search}` + } catch { + return nextLink.startsWith('/') ? nextLink : `/${nextLink}` + } +} + +function keyVaultHeaders(): Record { + return { + accept: 'application/json', + authorization: 'Bearer floci-ui', + 'content-type': 'application/json', + } +} + +function toSecretResource(record: KeyVaultSecretRecord, fallbackName = ''): CloudResource { + const parsed = parseSecretId(record.id) + const name = parsed.name || fallbackName + const attributes = record.attributes ?? {} + + return { + id: name, + name, + cloud: 'azure', + service: 'secrets', + type: 'secret', + region: null, + createdAt: epochDate(attributes.created), + status: attributes.enabled === false ? 'disabled' : 'enabled', + version: parsed.version, + metadata: { + provider: 'azure', + secretsService: 'key-vault', + vaultAccount: parsed.account, + contentType: record.contentType || null, + updatedAt: epochDate(attributes.updated), + expiresAt: epochDate(attributes.exp), + notBefore: epochDate(attributes.nbf), + recoveryLevel: attributes.recoveryLevel, + recoverableDays: attributes.recoverableDays, + tags: Object.entries(record.tags ?? {}).map(([key, value]) => ({key, value})), + }, + } +} + +function parseSecretId(id?: string): {account: string | null; name: string; version: string | null} { + if (!id) return {account: null, name: '', version: null} + try { + const url = new URL(id) + const parts = url.pathname.split('/').filter(Boolean) + return { + account: url.hostname.split('.')[0] || null, + name: decodeURIComponent(parts[1] ?? ''), + version: parts[2] ? decodeURIComponent(parts[2]) : null, + } + } catch { + const parts = id.split('/').filter(Boolean) + const secretsIndex = parts.lastIndexOf('secrets') + return { + account: null, + name: decodeURIComponent(parts[secretsIndex + 1] ?? ''), + version: parts[secretsIndex + 2] ? decodeURIComponent(parts[secretsIndex + 2]) : null, + } + } +} + +function epochDate(value?: number | null): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return new Date(value * 1000).toISOString() +} + +function stringValue(value: unknown, trim = true): string { + if (typeof value !== 'string') return '' + return trim ? value.trim() : value +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts b/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts new file mode 100644 index 0000000..f2be243 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts @@ -0,0 +1,155 @@ +import {describe, expect, test} from 'bun:test' +import {AzureNoSqlAdapter} from './AzureNoSqlAdapter' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' + +describe('AzureNoSqlAdapter', () => { + test('normalizes Cosmos databases as cloud NoSQL resources', async () => { + const adapter = new AzureNoSqlAdapter(testClient({ + '/dbs': { + _count: 1, + Databases: [{id: 'appdb', _rid: 'db-rid', _etag: '"etag"', _ts: 1779307200}], + }, + })) + + await expect(adapter.list()).resolves.toEqual([{ + id: 'appdb', + name: 'appdb', + cloud: 'azure', + service: 'nosql', + type: 'cosmos-database', + region: null, + createdAt: '2026-05-20T20:00:00.000Z', + status: 'available', + engine: 'cosmos-nosql', + version: 'NoSQL API', + instanceClass: null, + metadata: { + provider: 'azure', + databaseService: 'cosmos', + api: 'nosql', + resourceId: 'db-rid', + self: undefined, + etag: 'etag', + }, + }]) + }) + + test('lists containers and documents from the selected database', async () => { + const adapter = new AzureNoSqlAdapter(testClient({ + '/dbs/appdb/colls': { + _count: 1, + DocumentCollections: [{ + id: 'items', + _rid: 'coll-rid', + _etag: '"coll-etag"', + _ts: 1779307200, + partitionKey: {paths: ['/category'], kind: 'Hash'}, + }], + }, + '/dbs/appdb/colls/items': { + id: 'items', + partitionKey: {paths: ['/category'], kind: 'Hash'}, + }, + '/dbs/appdb/colls/items/docs': { + _count: 1, + Documents: [{id: 'item-1', category: 'demo', _etag: '"doc-etag"', _ts: 1779307201}], + }, + })) + + await expect(adapter.listCosmosContainers('appdb')).resolves.toMatchObject([{ + id: 'items', + databaseId: 'appdb', + partitionKeyPath: '/category', + }]) + await expect(adapter.listCosmosItems('appdb', 'items')).resolves.toMatchObject([{ + id: 'item-1', + databaseId: 'appdb', + containerId: 'items', + partitionKey: 'demo', + etag: 'doc-etag', + document: {id: 'item-1', category: 'demo', _etag: '"doc-etag"', _ts: 1779307201}, + }]) + }) + + test('sends document partition key when deleting an item', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const adapter = new AzureNoSqlAdapter(testClient({ + '/dbs/appdb/colls/items/docs/item-1': null, + }, calls)) + + await adapter.deleteCosmosItem('appdb', 'items', 'item-1', 'demo') + + expect(calls[0].path).toBe('/dbs/appdb/colls/items/docs/item-1') + expect(calls[0].init.method).toBe('DELETE') + expect(calls[0].init.headers).toMatchObject({'x-ms-documentdb-partitionkey': '["demo"]'}) + }) + + test('queries documents using the Cosmos SQL query endpoint', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const adapter = new AzureNoSqlAdapter(testClient({ + '/dbs/appdb/colls/items/docs': { + _count: 1, + Documents: [{id: 'item-1'}], + }, + }, calls)) + + await expect(adapter.queryCosmosItems('appdb', 'items', 'SELECT * FROM c')).resolves.toEqual({ + count: 1, + items: [{id: 'item-1'}], + }) + expect(calls[0].path).toBe('/dbs/appdb/colls/items/docs') + expect(calls[0].init.method).toBe('POST') + expect(calls[0].init.headers).toMatchObject({'x-ms-documentdb-isquery': 'True'}) + }) + + test('falls back to account-suffixed path when root routing is not implemented', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const adapter = new AzureNoSqlAdapter(testClient({ + '/dbs': 'not-implemented', + '/devstoreaccount1-cosmos/dbs': { + _count: 1, + Databases: [{id: 'fallbackdb'}], + }, + }, calls)) + + await expect(adapter.list()).resolves.toMatchObject([{id: 'fallbackdb'}]) + expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual(['/dbs', '/devstoreaccount1-cosmos/dbs']) + }) + + test('falls back to named NoSQL engine path after root and default account routes fail', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const adapter = new AzureNoSqlAdapter(testClient({ + '/dbs': 'not-implemented', + '/devstoreaccount1-cosmos/dbs': 'not-implemented', + '/devstoreaccount1-cosmos-nosql/dbs': { + _count: 1, + Databases: [{id: 'nosqldb'}], + }, + }, calls)) + + await expect(adapter.list()).resolves.toMatchObject([{id: 'nosqldb'}]) + expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual([ + '/dbs', + '/devstoreaccount1-cosmos/dbs', + '/devstoreaccount1-cosmos-nosql/dbs', + ]) + }) +}) + +function testClient(responses: Record, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init}) + if (responses[path] === 'not-implemented') { + throw new Error('Azure runtime request failed: HTTP 501') + } + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + return new Response('Not Found', {status: 404}) + } + return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts b/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts new file mode 100644 index 0000000..30eb804 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts @@ -0,0 +1,335 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {CosmosNoSqlUnavailableError} from './CosmosNoSqlUnavailableError' +import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' +import type { + CloudResource, + CloudServiceAdapter, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +interface CosmosListResponse { + _count?: number + Databases?: T[] + DocumentCollections?: T[] + Documents?: T[] +} + +type CosmosRecord = Record + +export class AzureNoSqlAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'nosql' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureNoSqlSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.cosmosJson>('/dbs', {method: 'GET'}, {emptyOnNotFound: true}) + const databases = body?.Databases ?? [] + return filterBySearch(databases.map(toDatabaseResource), query.search) + } + + async get(id: string): Promise { + const body = await this.cosmosJson(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true}) + return body ? toDatabaseResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const databaseName = stringValue(input.values.databaseName) + if (!databaseName) throw new ValidationError('databaseName is required') + if (!isValidCosmosId(databaseName)) throw new ValidationError('Use a valid Cosmos database name.') + + const body = await this.cosmosJson('/dbs', { + method: 'POST', + body: JSON.stringify({id: databaseName}), + }) + if (!body) throw new RuntimeError('Cosmos database creation returned an empty response') + return toDatabaseResource(body) + } + + async delete(id: string): Promise { + await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'}) + } + + async listCosmosContainers(databaseId: string): Promise { + const body = await this.cosmosJson>( + `/dbs/${encodeSegment(databaseId)}/colls`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return (body?.DocumentCollections ?? []).map((container) => toContainer(databaseId, container)) + } + + async createCosmosContainer(databaseId: string, input: CreateResourceInput): Promise { + const containerName = stringValue(input.values.containerName) + const partitionKeyPath = normalizePartitionKeyPath(stringValue(input.values.partitionKeyPath) || '/id') + if (!containerName) throw new ValidationError('containerName is required') + if (!isValidCosmosId(containerName)) throw new ValidationError('Use a valid Cosmos container name.') + + const body = await this.cosmosJson(`/dbs/${encodeSegment(databaseId)}/colls`, { + method: 'POST', + body: JSON.stringify({ + id: containerName, + partitionKey: {paths: [partitionKeyPath], kind: 'Hash'}, + }), + }) + if (!body) throw new RuntimeError('Cosmos container creation returned an empty response') + return toContainer(databaseId, body) + } + + async deleteCosmosContainer(databaseId: string, containerId: string): Promise { + await this.cosmosFetch(`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, {method: 'DELETE'}) + } + + async listCosmosItems(databaseId: string, containerId: string): Promise { + const [container, body] = await Promise.all([ + this.getCosmosContainer(databaseId, containerId), + this.cosmosJson>( + `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ), + ]) + const pkPath = container ? partitionKeyPath(container) : '/id' + return (body?.Documents ?? []).map((document) => toItem(databaseId, containerId, document, pkPath)) + } + + async upsertCosmosItem(databaseId: string, containerId: string, document: Record): Promise { + if (!isRecord(document)) throw new ValidationError('document must be a JSON object') + const id = stringValue(document.id) + if (!id) throw new ValidationError('Cosmos document id is required') + + const container = await this.getCosmosContainer(databaseId, containerId) + const pkPath = container ? partitionKeyPath(container) : '/id' + const body = await this.cosmosJson( + `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`, + { + method: 'POST', + body: JSON.stringify(document), + headers: { + 'x-ms-documentdb-is-upsert': 'True', + ...partitionKeyHeader(partitionKeyValue(document, pkPath)), + }, + }, + ) + if (!body) throw new RuntimeError('Cosmos document upsert returned an empty response') + return toItem(databaseId, containerId, body, pkPath) + } + + async deleteCosmosItem(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise { + await this.cosmosFetch( + `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs/${encodeSegment(itemId)}`, + { + method: 'DELETE', + headers: partitionKeyHeader(partitionKey ?? null), + }, + ) + } + + async queryCosmosItems(databaseId: string, containerId: string, query: string): Promise { + const sql = query.trim() || 'SELECT * FROM c' + const body = await this.cosmosJson | string | number | boolean | null>>( + `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}/docs`, + { + method: 'POST', + body: JSON.stringify({query: sql, parameters: []}), + headers: { + 'content-type': 'application/query+json', + 'x-ms-documentdb-isquery': 'True', + 'x-ms-documentdb-query-enablecrosspartition': 'True', + }, + }, + ) + if (!body) throw new RuntimeError('Cosmos query returned an empty response') + const items = body.Documents ?? [] + return {items, count: body._count ?? items.length} + } + + private getCosmosContainer(databaseId: string, containerId: string): Promise { + return this.cosmosJson( + `/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + } + + private cosmosFetch(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { + const request: RequestInit = { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + } + return this.fetchWithFallbacks(path, request, options) + } + + private async cosmosJson(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { + const res = await this.cosmosFetch(path, init, options) + if (!res) return null + if (res.status === 204) return null + return await res.json() as T + } + + private async fetchWithFallbacks(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise { + const attempts = [ + path, + `${cosmosAccountPath(this.client)}${path}`, + `${cosmosNoSqlAccountPath(this.client)}${path}`, + ] + const failures: string[] = [] + + for (const attempt of attempts) { + try { + return await this.client.fetch(attempt, init, options) + } catch (error) { + if (!isRetriableRoutingError(error)) throw error + failures.push(`${attempt}: ${errorMessage(error)}`) + } + } + + throw new CosmosNoSqlUnavailableError(`Cosmos NoSQL request failed on all known routes. ${failures.join(' | ')}`) + } +} + +function cosmosAccountPath(client: AzureRuntimeClient): string { + return `/${encodeSegment(`${client.accountName}-cosmos`)}` +} + +function cosmosNoSqlAccountPath(client: AzureRuntimeClient): string { + return `/${encodeSegment(`${client.accountName}-cosmos-nosql`)}` +} + +function toDatabaseResource(database: CosmosRecord): CloudResource { + const name = stringValue(database.id) + return { + id: name, + name, + cloud: 'azure', + service: 'nosql', + type: 'cosmos-database', + region: null, + createdAt: timestampToIso(database._ts), + status: 'available', + engine: 'cosmos-nosql', + version: 'NoSQL API', + instanceClass: null, + metadata: { + provider: 'azure', + databaseService: 'cosmos', + api: 'nosql', + resourceId: database._rid, + self: database._self, + etag: unquote(stringValue(database._etag)), + }, + } +} + +function toContainer(databaseId: string, container: CosmosRecord): CosmosContainer { + return { + id: stringValue(container.id), + name: stringValue(container.id), + databaseId, + partitionKeyPath: partitionKeyPath(container), + createdAt: timestampToIso(container._ts), + metadata: { + provider: 'azure', + databaseService: 'cosmos', + api: 'nosql', + resourceId: container._rid, + self: container._self, + etag: unquote(stringValue(container._etag)), + indexingPolicy: container.indexingPolicy, + }, + } +} + +function toItem(databaseId: string, containerId: string, document: CosmosRecord, pkPath = '/id'): CosmosItem { + return { + id: stringValue(document.id), + databaseId, + containerId, + partitionKey: partitionKeyValue(document, pkPath), + etag: unquote(stringValue(document._etag)), + timestamp: timestampToIso(document._ts), + document, + } +} + +function partitionKeyValue(document: CosmosRecord, pkPath: string): string | null { + const segments = normalizePartitionKeyPath(pkPath).slice(1).split('/').filter(Boolean) + let current: unknown = document + for (const segment of segments) { + if (!isRecord(current)) return null + current = current[segment] + } + if (current === undefined || current === null) return null + if (typeof current === 'string') return current + if (typeof current === 'number' || typeof current === 'boolean') return String(current) + return JSON.stringify(current) +} + +function partitionKeyHeader(partitionKey: string | null): Record { + return partitionKey === null ? {} : {'x-ms-documentdb-partitionkey': JSON.stringify([partitionKey])} +} + +function partitionKeyPath(container: CosmosRecord): string { + const pk = container.partitionKey + if (!isRecord(pk) || !Array.isArray(pk.paths) || typeof pk.paths[0] !== 'string') return '/id' + return pk.paths[0] +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : value === null || value === undefined ? '' : String(value) +} + +function timestampToIso(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return new Date(value * 1000).toISOString() +} + +function normalizePartitionKeyPath(value: string): string { + const trimmed = value.trim() || '/id' + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +function isValidCosmosId(value: string): boolean { + return value.length > 0 && value.length <= 255 && /^[A-Za-z0-9._-]+$/.test(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function unquote(value: string): string | null { + if (!value) return null + return value.replace(/^"|"$/g, '') +} + +function encodeSegment(value: string): string { + return encodeURIComponent(value) +} + +function isRetriableRoutingError(error: unknown): boolean { + return error instanceof Error && (error.message.includes('HTTP 404') || error.message.includes('HTTP 501')) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/api/src/adapter-azure/AzurePostgresAdapter.test.ts b/packages/api/src/adapter-azure/AzurePostgresAdapter.test.ts new file mode 100644 index 0000000..b410804 --- /dev/null +++ b/packages/api/src/adapter-azure/AzurePostgresAdapter.test.ts @@ -0,0 +1,197 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +import {toHttpError} from '../cloud-spi/errors' +import {AzurePostgresAdapter} from './AzurePostgresAdapter' +import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient' + +const SERVERS_PATH = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.DBforPostgreSQL/flexibleServers' +const API_VERSION = '?api-version=2025-08-01' + +describe('AzurePostgresAdapter', () => { + test('normalizes PostgreSQL flexible servers', async () => { + const adapter = new AzurePostgresAdapter(testClient({ + [`${SERVERS_PATH}${API_VERSION}`]: { + value: [postgresRecord()], + }, + })) + + await expect(adapter.list()).resolves.toEqual([{ + id: 'postgres-flexible-server:app-postgres', + name: 'app-postgres', + cloud: 'azure', + service: 'database', + type: 'postgres-flexible-server', + region: 'eastus', + createdAt: null, + status: 'Ready', + engine: 'postgresql', + version: '17', + instanceClass: 'Standard_B1ms', + metadata: { + provider: 'azure', + databaseService: 'postgresql', + resourceKind: 'flexible-server', + armId: `${SERVERS_PATH}/app-postgres`, + serverName: 'app-postgres', + administratorLogin: 'pgadmin', + fullyQualifiedDomainName: 'app-postgres.local', + publicNetworkAccess: 'Enabled', + storageSizeGB: 32, + endpoint: {address: 'app-postgres.local', port: 5432}, + tags: [{key: 'environment', value: 'test'}], + }, + }]) + }) + + test('creates a PostgreSQL flexible server through the ARM contract', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const serverPath = `${SERVERS_PATH}/app-postgres${API_VERSION}` + const adapter = new AzurePostgresAdapter(testClient({ + [serverPath]: postgresRecord(), + }, calls)) + + await expect(adapter.create({values: { + serverName: 'app-postgres', + location: 'eastus', + administratorLogin: 'pgadmin', + administratorLoginPassword: 'StrongPassw0rd!', + }})).resolves.toMatchObject({ + id: 'postgres-flexible-server:app-postgres', + engine: 'postgresql', + }) + + expect(calls[0].path).toBe(serverPath) + expect(calls[0].init.method).toBe('PUT') + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + location: 'eastus', + sku: {name: 'Standard_B1ms', tier: 'Burstable'}, + properties: { + administratorLogin: 'pgadmin', + administratorLoginPassword: 'StrongPassw0rd!', + version: '17', + storage: {storageSizeGB: 32}, + }, + }) + }) + + test('browses databases and tables and runs PostgreSQL SQL', async () => { + const serverPath = `${SERVERS_PATH}/app-postgres${API_VERSION}` + const dataCalls: Array<{connection: SqlDataConnection; query: string}> = [] + const dataClient: SqlDataClient = { + async query(connection, query) { + dataCalls.push({connection, query}) + if (query.includes('FROM pg_database')) { + return sqlResult([{name: 'postgres', state: 'ONLINE', createdAt: null, isSystem: true}]) + } + if (query.includes('FROM pg_class')) { + return sqlResult([{schemaName: 'public', name: 'orders', objectType: 'table', rowCount: '3'}]) + } + return sqlResult([{order_id: 1042}], [1]) + }, + } + const adapter = new AzurePostgresAdapter(testClient({ + [serverPath]: postgresRecord(), + }), dataClient) + const connection = { + username: 'pgadmin', + password: 'StrongPassw0rd!', + engine: 'postgresql' as const, + } + + await expect(adapter.listSqlDatabases('app-postgres', connection)).resolves.toEqual([{ + name: 'postgres', + state: 'ONLINE', + createdAt: null, + isSystem: true, + }]) + await expect(adapter.listSqlTables('app-postgres', {...connection, database: 'appdb'})).resolves.toEqual([{ + schema: 'public', + name: 'orders', + type: 'table', + rowCount: 3, + }]) + await expect(adapter.querySql('app-postgres', {...connection, database: 'appdb'}, 'SELECT * FROM orders')).resolves.toEqual( + sqlResult([{order_id: 1042}], [1]), + ) + + expect(dataCalls[0].connection).toEqual({ + server: 'app-postgres.local', + port: 5432, + database: 'postgres', + username: 'pgadmin', + password: 'StrongPassw0rd!', + tlsMode: 'disable', + }) + expect(dataCalls[1].connection.database).toBe('appdb') + expect(dataCalls[2].query).toBe('SELECT * FROM orders') + }) + + test('reports bad input as 400 and a missing server as 404', async () => { + const existing = new AzurePostgresAdapter(testClient({ + [`${SERVERS_PATH}/app-postgres${API_VERSION}`]: postgresRecord(), + })) + const missing = new AzurePostgresAdapter(testClient({})) + + expect(await httpStatusOf(() => existing.create({values: {}}))).toBe(400) + expect(await httpStatusOf(() => existing.create({values: {serverName: 'App_Postgres'}}))).toBe(400) + expect(await httpStatusOf(() => existing.create({values: {serverName: 'app-postgres'}}))).toBe(400) + expect(await httpStatusOf(() => existing.querySql('app-postgres', {username: 'pgadmin', password: 'x'}, ''))).toBe(400) + expect(await httpStatusOf(() => existing.querySql('app-postgres', {username: '', password: ''}, 'SELECT 1'))).toBe(400) + expect(await httpStatusOf(() => existing.get(''))).toBe(400) + + expect(await httpStatusOf(() => missing.querySql('gone', {username: 'pgadmin', password: 'x'}, 'SELECT 1'))).toBe(404) + }) +}) + +async function httpStatusOf(operation: () => Promise): Promise { + const failure = await operation().then(() => null, (err: unknown) => err) + expect(failure).not.toBeNull() + return toHttpError(failure).status +} + +function postgresRecord() { + return { + id: `${SERVERS_PATH}/app-postgres`, + name: 'app-postgres', + type: 'Microsoft.DBforPostgreSQL/flexibleServers', + location: 'eastus', + tags: {environment: 'test'}, + sku: {name: 'Standard_B1ms', tier: 'Burstable'}, + properties: { + administratorLogin: 'pgadmin', + version: '17', + state: 'Ready', + fullyQualifiedDomainName: 'app-postgres.local', + localPort: 5432, + storage: {storageSizeGB: 32}, + network: {publicNetworkAccess: 'Enabled'}, + }, + } +} + +function sqlResult(rows: Array>, rowsAffected: number[] = []) { + return { + resultSets: [{ + columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'text'})), + rows, + truncated: false, + }], + rowsAffected, + durationMs: 1, + } +} + +function testClient(responses: Record, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init}) + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + throw new Error(`Unexpected Azure runtime request: ${path}`) + } + return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzurePostgresAdapter.ts b/packages/api/src/adapter-azure/AzurePostgresAdapter.ts new file mode 100644 index 0000000..14dbdf5 --- /dev/null +++ b/packages/api/src/adapter-azure/AzurePostgresAdapter.ts @@ -0,0 +1,272 @@ +import { + azure, + azureResourceGroup, + type AzureRuntimeClient, + azureSubscriptionId, +} from '../azure' +import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' +import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient' +import {PostgresDataClient} from './PostgresDataClient' +import {postgresTlsMode} from './sqlTransport' +import { + filterBySearch, + isValidServerName, + numberValue, + rawStringValue, + recordValue, + stringValue, + tagList, + withApiVersion, +} from './sqlAdapterUtils' + +const API_VERSION = '2025-08-01' +const RESOURCE_ID_PREFIX = 'postgres-flexible-server:' +const LIST_DATABASES_QUERY = ` +SELECT + datname AS name, + CASE WHEN datallowconn THEN 'ONLINE' ELSE 'OFFLINE' END AS state, + NULL::text AS "createdAt", + datname IN ('postgres', 'template0', 'template1') AS "isSystem" +FROM pg_database +WHERE datallowconn +ORDER BY "isSystem" DESC, datname` +const LIST_TABLES_QUERY = ` +SELECT + namespace.nspname AS "schemaName", + relation.relname AS name, + CASE WHEN relation.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END AS "objectType", + CASE + WHEN relation.relkind IN ('r', 'p') AND relation.reltuples >= 0 + THEN relation.reltuples::bigint + ELSE NULL + END AS "rowCount" +FROM pg_class AS relation +INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace +WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema') + AND namespace.nspname NOT LIKE 'pg_toast%' + AND relation.relkind IN ('r', 'p', 'v', 'm') +ORDER BY namespace.nspname, relation.relname` + +interface AzurePostgresListResponse { + value?: AzurePostgresRecord[] +} + +interface AzurePostgresRecord { + id?: string + name?: string + type?: string + location?: string + tags?: Record + sku?: Record + properties?: Record +} + +export class AzurePostgresAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'database' as const + + constructor( + private readonly client: AzureRuntimeClient = azure, + private readonly dataClient: SqlDataClient = new PostgresDataClient(), + ) {} + + schema(): ServiceSchema { + return azureDatabaseSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.postgresJson(postgresServersPath(), {method: 'GET'}, true) + return filterBySearch((body?.value ?? []).map(toPostgresResource), query.search) + } + + async get(id: string): Promise { + const serverName = postgresServerName(id) + const body = await this.postgresJson(postgresServerPath(serverName), {method: 'GET'}, true) + return body ? toPostgresResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const serverName = stringValue(input.values.serverName) + const location = stringValue(input.values.location) || 'eastus' + const administratorLogin = stringValue(input.values.administratorLogin) + const administratorLoginPassword = stringValue(input.values.administratorLoginPassword) + + if (!serverName) throw new ValidationError('serverName is required') + if (!isValidServerName(serverName)) throw new ValidationError('Use a valid PostgreSQL flexible server name.') + if (!administratorLogin) throw new ValidationError('administratorLogin is required') + if (!administratorLoginPassword) throw new ValidationError('administratorLoginPassword is required') + + const body = await this.postgresJson(postgresServerPath(serverName), { + method: 'PUT', + body: JSON.stringify({ + location, + sku: {name: 'Standard_B1ms', tier: 'Burstable'}, + properties: { + administratorLogin, + administratorLoginPassword, + version: '17', + storage: {storageSizeGB: 32}, + }, + }), + }) + if (!body) throw new RuntimeError('PostgreSQL flexible server creation returned an empty response') + return toPostgresResource(body) + } + + async delete(id: string): Promise { + await this.postgresFetch(postgresServerPath(postgresServerName(id)), {method: 'DELETE'}) + } + + async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + const result = await this.querySql(serverId, {...connection, database: 'postgres'}, LIST_DATABASES_QUERY) + return (result.resultSets[0]?.rows ?? []).flatMap((row) => { + const name = stringValue(row.name) + if (!name) return [] + return [{ + name, + state: stringValue(row.state) || 'UNKNOWN', + createdAt: stringValue(row.createdAt) || null, + isSystem: row.isSystem === true, + }] + }) + } + + async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise { + const result = await this.querySql(serverId, connection, LIST_TABLES_QUERY) + return (result.resultSets[0]?.rows ?? []).flatMap((row) => { + const schema = stringValue(row.schemaName) + const name = stringValue(row.name) + if (!schema || !name) return [] + return [{ + schema, + name, + type: row.objectType === 'view' ? 'view' as const : 'table' as const, + rowCount: integerValue(row.rowCount), + }] + }) + } + + async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise { + if (!query.trim()) throw new ValidationError('SQL query is required') + return this.dataClient.query(await this.dataConnection(serverId, connection), query) + } + + private postgresFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + return this.client.fetch(withApiVersion(path, API_VERSION), { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, {emptyOnNotFound}) + } + + private async postgresJson(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + const response = await this.postgresFetch(path, init, emptyOnNotFound) + if (!response || response.status === 204) return null + return await response.json() as T + } + + private async dataConnection(serverId: string, input: SqlConnectionInput): Promise { + const resource = await this.get(serverId) + if (!resource) throw new NotFoundError('PostgreSQL flexible server not found') + + const endpoint = recordValue(resource.metadata.endpoint) + const server = stringValue(endpoint?.address) + const port = numberValue(endpoint?.port) + const username = rawStringValue(input.username) + const password = rawStringValue(input.password) + const database = stringValue(input.database) || 'postgres' + const hasLocalPort = endpoint?.port !== undefined && endpoint.port !== null + + // The endpoint comes from the resource the runtime returned, so a missing + // address is a runtime problem rather than something the caller sent. + if (!server || port === null) throw new RuntimeError('PostgreSQL server endpoint is not available') + if (!username) throw new ValidationError('PostgreSQL username is required') + if (!password) throw new ValidationError('PostgreSQL password is required') + + return { + server, + port, + database, + username, + password, + tlsMode: postgresTlsMode(server, hasLocalPort), + } + } +} + +export function isPostgresServerResourceId(id: string): boolean { + return id.startsWith(RESOURCE_ID_PREFIX) +} + +function postgresServersPath(): string { + return `/subscriptions/${encodeURIComponent(azureSubscriptionId())}/resourceGroups/${encodeURIComponent(azureResourceGroup())}/providers/Microsoft.DBforPostgreSQL/flexibleServers` +} + +function postgresServerPath(serverName: string): string { + return `${postgresServersPath()}/${encodeURIComponent(serverName)}` +} + +function postgresServerName(id: string): string { + const name = isPostgresServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id + if (!name) throw new ValidationError('PostgreSQL flexible server id is required') + return name +} + +function toPostgresResource(server: AzurePostgresRecord): CloudResource { + const name = stringValue(server.name) + const properties = server.properties ?? {} + const sku = recordValue(server.sku) + const network = recordValue(properties.network) + const storage = recordValue(properties.storage) + return { + id: `${RESOURCE_ID_PREFIX}${name}`, + name, + cloud: 'azure', + service: 'database', + type: 'postgres-flexible-server', + region: stringValue(server.location) || null, + createdAt: null, + status: stringValue(properties.state) || stringValue(properties.provisioningState) || null, + engine: 'postgresql', + version: stringValue(properties.version) || null, + instanceClass: stringValue(sku?.name) || null, + metadata: { + provider: 'azure', + databaseService: 'postgresql', + resourceKind: 'flexible-server', + armId: server.id, + serverName: name, + administratorLogin: properties.administratorLogin, + fullyQualifiedDomainName: properties.fullyQualifiedDomainName, + publicNetworkAccess: network?.publicNetworkAccess, + storageSizeGB: storage?.storageSizeGB, + endpoint: { + address: properties.fullyQualifiedDomainName, + port: numberValue(properties.localPort), + }, + tags: tagList(server.tags), + }, + } +} + +function integerValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isSafeInteger(value)) return value + if (typeof value !== 'string' || !/^-?\d+$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null +} diff --git a/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts b/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts new file mode 100644 index 0000000..00f47f7 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, test } from 'bun:test' +import type { AzureRuntimeClient, AzureRuntimeFetchOptions } from '../azure' +import { AzureServerlessAdapter } from './AzureServerlessAdapter' + +interface RecordedCall { + path: string + init: RequestInit + options?: AzureRuntimeFetchOptions +} + +function azureFunction(name: string) { + return { + id: name, + name, + kind: 'functionapp', + location: 'centralus', + type: 'Microsoft.Web/sites/functions', + properties: { + state: 'Running', + runtime: 'node', + functionAppName: 'floci-functions', + lastModifiedTimeUtc: '2026-06-22T05:29:13Z', + scriptHref: `http://localhost:4577/functions/${name}/script`, + invokeUrlTemplate: `http://localhost:4577/functions/${name}/invoke`, + config: { + bindings: [ + { + type: 'httpTrigger', + direction: 'in', + name: 'req', + }, + ], + }, + files: { + 'index.js': 'module.exports = async () => ({statusCode: 200})', + }, + }, + } +} + +function testClient( + handler: ( + path: string, + init: RequestInit, + options?: AzureRuntimeFetchOptions, + ) => Promise, +): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + fetch: handler, + } +} + +describe('AzureServerlessAdapter', () => { + test('lists functions and maps the Azure resource shape', async () => { + const client = testClient(async () => + new Response( + JSON.stringify({ + value: [azureFunction('hello')], + }), + { status: 200 }, + ), + ) + + const resources = await new AzureServerlessAdapter(client).list() + + expect(resources).toEqual([ + { + id: 'hello', + name: 'hello', + cloud: 'azure', + service: 'serverless', + type: 'azure-function', + region: 'centralus', + createdAt: '2026-06-22T05:29:13Z', + status: 'Running', + metadata: { + provider: 'azure', + serverlessService: 'functions', + kind: 'functionapp', + resourceType: 'Microsoft.Web/sites/functions', + runtime: 'node', + functionAppName: 'floci-functions', + lastModified: '2026-06-22T05:29:13Z', + triggerType: 'httpTrigger', + scriptHref: 'http://localhost:4577/functions/hello/script', + invokeUrlTemplate: 'http://localhost:4577/functions/hello/invoke', + config: { + bindings: [ + { + type: 'httpTrigger', + direction: 'in', + name: 'req', + }, + ], + }, + files: { + 'index.js': + 'module.exports = async () => ({statusCode: 200})', + }, + }, + }, + ]) + }) + + test('handles missing trigger metadata gracefully', async () => { + const functionWithoutBindings = azureFunction('hello') + functionWithoutBindings.properties.config = { + bindings: [], + } + + const client = testClient(async () => + new Response( + JSON.stringify({ + value: [functionWithoutBindings], + }), + { status: 200 }, + ), + ) + + const resources = await new AzureServerlessAdapter(client).list() + + expect(resources[0].metadata.lastModified).toBe( + '2026-06-22T05:29:13Z', + ) + expect(resources[0].metadata.triggerType).toBeUndefined() + }) + + test('normalizes a missing list endpoint to an empty list', async () => { + const client = testClient(async (_path, _init, options) => { + if (options?.emptyOnNotFound) return null + return new Response('Not Found', { status: 404 }) + }) + + await expect(new AzureServerlessAdapter(client).list()).resolves.toEqual([]) + }) + + test('filters listed functions by search term', async () => { + const client = testClient(async () => + new Response( + JSON.stringify({ + value: [azureFunction('alpha'), azureFunction('beta')], + }), + { status: 200 }, + ), + ) + + const resources = await new AzureServerlessAdapter(client).list({ + search: 'bet', + }) + + expect(resources.map((resource) => resource.name)).toEqual(['beta']) + }) + + test('gets and maps a single function', async () => { + const client = testClient(async () => + new Response(JSON.stringify(azureFunction('hello')), { status: 200 }), + ) + + const resource = await new AzureServerlessAdapter(client).get('hello') + + expect(resource?.id).toBe('hello') + expect(resource?.status).toBe('Running') + expect(resource?.metadata.runtime).toBe('node') + }) + + test('get returns null when the function is missing', async () => { + const client = testClient(async (_path, _init, options) => { + if (options?.emptyOnNotFound) return null + return new Response('Not Found', { status: 404 }) + }) + + await expect( + new AzureServerlessAdapter(client).get('missing'), + ).resolves.toBeNull() + }) + + test('create posts the expected payload and maps the response', async () => { + const calls: RecordedCall[] = [] + + const client = testClient(async (path, init, options) => { + calls.push({ path, init, options }) + return new Response(JSON.stringify(azureFunction('hello')), { + status: 201, + }) + }) + + const resource = await new AzureServerlessAdapter(client).create({ + values: { + functionName: 'hello', + runtime: 'node', + handler: 'index.handler', + code: 'module.exports.handler = async () => ({statusCode: 200})', + location: 'centralus', + functionAppName: 'floci-functions', + }, + }) + + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe('/functions') + expect(calls[0].init.method).toBe('POST') + expect(calls[0].init.headers).toEqual({ + accept: 'application/json', + 'content-type': 'application/json', + }) + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + name: 'hello', + runtime: 'node', + handler: 'index.handler', + code: 'module.exports.handler = async () => ({statusCode: 200})', + location: 'centralus', + functionAppName: 'floci-functions', + }) + expect(resource.id).toBe('hello') + }) + + test('create defaults runtime to node', async () => { + let sentBody = '' + + const client = testClient(async (_path, init) => { + sentBody = String(init.body) + return new Response(JSON.stringify(azureFunction('hello')), { + status: 201, + }) + }) + + await new AzureServerlessAdapter(client).create({ + values: { + functionName: 'hello', + }, + }) + + expect(JSON.parse(sentBody)).toEqual({ + name: 'hello', + runtime: 'node', + }) + }) + + test('create rejects when functionName is missing', async () => { + const client = testClient(async () => + new Response(JSON.stringify({}), { status: 200 }), + ) + + await expect( + new AzureServerlessAdapter(client).create({ + values: {}, + }), + ).rejects.toThrow('functionName is required') + }) + + test('create rejects when the runtime returns an empty response', async () => { + const client = testClient(async () => + new Response(null, { status: 204 }), + ) + + await expect( + new AzureServerlessAdapter(client).create({ + values: { + functionName: 'hello', + }, + }), + ).rejects.toThrow('Azure Functions create returned an empty response') + }) + + test('delete issues a DELETE request against the function path', async () => { + const calls: RecordedCall[] = [] + + const client = testClient(async (path, init, options) => { + calls.push({ path, init, options }) + return new Response(null, { status: 204 }) + }) + + await new AzureServerlessAdapter(client).delete('hello') + + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe('/functions/hello') + expect(calls[0].init.method).toBe('DELETE') + expect(calls[0].options).toEqual({ + emptyOnNotFound: true, + }) + }) + + test('invoke posts the payload and maps the response', async () => { + const calls: RecordedCall[] = [] + + const client = testClient(async (path, init, options) => { + calls.push({ path, init, options }) + return new Response( + JSON.stringify({ + statusCode: 202, + payload: { + message: 'accepted', + }, + functionError: 'Handled', + logResult: 'execution log', + }), + { status: 200 }, + ) + }) + + const result = await new AzureServerlessAdapter(client).invoke( + 'hello', + '{"name":"Hajira"}', + ) + + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe('/functions/hello/invoke') + expect(calls[0].init.method).toBe('POST') + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + payload: '{"name":"Hajira"}', + }) + expect(result.statusCode).toBe(202) + expect(result.payload).toBe('{"message":"accepted"}') + expect(result.functionError).toBe('Handled') + expect(result.logResult).toBe('execution log') + expect(result.executionDuration).toBeGreaterThanOrEqual(0) + }) + + test('invoke uses an empty JSON object when payload is blank', async () => { + let sentBody = '' + + const client = testClient(async (_path, init) => { + sentBody = String(init.body) + return new Response( + JSON.stringify({ + statusCode: 200, + body: 'ok', + }), + { status: 200 }, + ) + }) + + const result = await new AzureServerlessAdapter(client).invoke( + 'hello', + ' ', + ) + + expect(JSON.parse(sentBody)).toEqual({ + payload: '{}', + }) + expect(result.payload).toBe('ok') + }) + + test('lists functions from a direct array response', async () => { + const client = testClient(async () => + new Response( + JSON.stringify([azureFunction('hello')]), + { status: 200 }, + ), + ) + + const resources = await new AzureServerlessAdapter(client).list() + + expect(resources).toHaveLength(1) + expect(resources[0].id).toBe('hello') + expect(resources[0].type).toBe('azure-function') + }) + test('propagates runtime errors from the Azure client', async () => { + const client = testClient(async () => { + throw new Error('Azure Functions request failed: HTTP 500') + }) + + await expect( + new AzureServerlessAdapter(client).list(), + ).rejects.toThrow('Azure Functions request failed: HTTP 500') + }) +}) diff --git a/packages/api/src/adapter-azure/AzureServerlessAdapter.ts b/packages/api/src/adapter-azure/AzureServerlessAdapter.ts new file mode 100644 index 0000000..d95a41e --- /dev/null +++ b/packages/api/src/adapter-azure/AzureServerlessAdapter.ts @@ -0,0 +1,230 @@ +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { azure, type AzureRuntimeClient } from '../azure' +import { azureServerlessSchema } from '../cloud-spi/serverlessSchema' +import type { + CloudResource, + CloudServiceAdapter, + CloudServiceDescriptorOverride, + CreateResourceInput, + ResourceQuery, + ServerlessInvokeResult, + ServiceSchema, +} from '../cloud-spi/types' + +interface AzureFunctionRecord { + id?: string + name?: string + kind?: string + location?: string + type?: string + properties?: { + state?: string + status?: string + runtime?: string + functionAppName?: string + lastModifiedTimeUtc?: string + scriptHref?: string + invokeUrlTemplate?: string + config?: Record + files?: Record + } +} + +interface AzureFunctionListResponse { + value?: AzureFunctionRecord[] +} + +export class AzureServerlessAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'serverless' as const + + constructor(private readonly client: AzureRuntimeClient = azure) { } + + /** + * floci-az answers 501 NotImplemented on /functions, so this adapter cannot + * currently serve a request even though it is registered. Reporting + * coming_soon keeps the nav honest; remove this once the runtime ships the + * endpoint and the adapter's own tests exercise it against a real response. + */ + descriptorOverride(): CloudServiceDescriptorOverride { + return { + availability: 'coming_soon', + reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.', + } + } + + schema(): ServiceSchema { + return azureServerlessSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.azureJson( + '/functions', + { method: 'GET' }, + { emptyOnNotFound: true }, + ) + + const records = Array.isArray(body) ? body : body?.value ?? [] + return filterBySearch(records.map(toFunctionResource), query.search) + } + + async get(id: string): Promise { + const body = await this.azureJson( + `/functions/${encodeURIComponent(id)}`, + { method: 'GET' }, + { emptyOnNotFound: true }, + ) + + return body ? toFunctionResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const functionName = stringValue(input.values.functionName ?? input.values.name) + const runtime = stringValue(input.values.runtime) || 'node' + const handler = stringValue(input.values.handler) + const code = stringValue(input.values.code) + const location = stringValue(input.values.location) + const functionAppName = stringValue(input.values.functionAppName) + + if (!functionName) throw new ValidationError('functionName is required') + + const body = await this.azureJson( + '/functions', + { + method: 'POST', + body: JSON.stringify({ + name: functionName, + runtime, + handler: handler || undefined, + code: code || undefined, + location: location || undefined, + functionAppName: functionAppName || undefined, + }), + }, + ) + + if (!body) throw new RuntimeError('Azure Functions create returned an empty response') + return toFunctionResource(body) + } + + async delete(id: string): Promise { + await this.client.fetch( + `/functions/${encodeURIComponent(id)}`, + { method: 'DELETE' }, + { emptyOnNotFound: true }, + ) + } + async invoke(id: string, payload: string): Promise { + const startedAt = performance.now() + + const body = await this.azureJson<{ + statusCode?: number + payload?: unknown + body?: unknown + error?: string + functionError?: string + logResult?: string + }>( + `/functions/${encodeURIComponent(id)}/invoke`, + { + method: 'POST', + body: JSON.stringify({ + payload: payload?.trim() ? payload : '{}', + }), + }, + ) + + return { + statusCode: body?.statusCode ?? 200, + payload: stringifyPayload(body?.payload ?? body?.body ?? ''), + functionError: body?.functionError ?? body?.error, + logResult: body?.logResult, + executionDuration: Math.round(performance.now() - startedAt), + } + } + + private async azureJson( + path: string, + init: RequestInit, + options?: { emptyOnNotFound?: boolean }, + ): Promise { + const res = await this.client.fetch( + path, + { + ...init, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, + options, + ) + + if (!res || res.status === 204) return null + return await res.json() as T + } +} + +function toFunctionResource(record: AzureFunctionRecord): CloudResource { + const name = stringValue(record.name ?? record.id) + const props = record.properties ?? {} + + return { + id: name, + name, + cloud: 'azure', + service: 'serverless', + type: 'azure-function', + region: record.location ?? null, + createdAt: props.lastModifiedTimeUtc ?? null, + status: props.state ?? props.status ?? null, + metadata: { + provider: 'azure', + serverlessService: 'functions', + kind: record.kind, + resourceType: record.type, + runtime: props.runtime, + functionAppName: props.functionAppName, + lastModified: props.lastModifiedTimeUtc, + triggerType: getTriggerType(props.config), + scriptHref: props.scriptHref, + invokeUrlTemplate: props.invokeUrlTemplate, + config: props.config, + files: props.files, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function stringifyPayload(value: unknown): string { + if (typeof value === 'string') return value + if (value === undefined || value === null) return '' + return JSON.stringify(value) +} + +function getTriggerType(config?: Record): string | undefined { + const bindings = config?.bindings + if (!Array.isArray(bindings)) return undefined + + const trigger = bindings.find((binding) => { + if (!binding || typeof binding !== 'object') return false + + const type = (binding as Record).type + return typeof type === 'string' && type.toLowerCase().endsWith('trigger') + }) + + if (!trigger || typeof trigger !== 'object') return undefined + + const type = (trigger as Record).type + return typeof type === 'string' ? type : undefined +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} \ No newline at end of file diff --git a/packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts b/packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts new file mode 100644 index 0000000..d2bc5a6 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureServiceBusAdapter.test.ts @@ -0,0 +1,166 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +import {ConflictError, toHttpError, ValidationError} from '../cloud-spi/errors' +import {AzureServiceBusAdapter} from './AzureServiceBusAdapter' + +interface RecordedCall { + path: string + init: RequestInit + options?: AzureRuntimeFetchOptions +} + +describe('AzureServiceBusAdapter', () => { + test('lists and normalizes Service Bus namespaces', async () => { + const client = testClient(async () => new Response(JSON.stringify({ + namespaces: [{ + name: 'orders-bus', + amqpPort: 5672, + amqpsPort: 5671, + tlsCertPem: 'certificate', + mocked: false, + }], + }))) + + await expect(new AzureServiceBusAdapter(client).list()).resolves.toEqual([{ + id: 'orders-bus', + name: 'orders-bus', + cloud: 'azure', + service: 'messaging', + type: 'servicebus-namespace', + region: null, + createdAt: null, + status: 'Running', + metadata: { + provider: 'azure', + messagingService: 'service-bus', + amqpPort: 5672, + amqpsPort: 5671, + mocked: false, + tlsEnabled: true, + }, + }]) + }) + + test('normalizes a missing namespace list endpoint to an empty list', async () => { + const client = testClient(async (_path, _init, options) => { + if (options?.emptyOnNotFound) return null + return new Response(null, {status: 404}) + }) + + await expect(new AzureServiceBusAdapter(client).list()).resolves.toEqual([]) + }) + + test('filters namespaces by search term', async () => { + const client = testClient(async () => new Response(JSON.stringify({ + namespaces: [{name: 'orders-bus', mocked: false}, {name: 'billing-bus', mocked: true}], + }))) + + const resources = await new AzureServiceBusAdapter(client).list({search: 'bill'}) + + expect(resources.map((resource) => resource.name)).toEqual(['billing-bus']) + expect(resources[0].status).toBe('Mocked') + }) + + test('gets a namespace by name', async () => { + const calls: RecordedCall[] = [] + const client = testClient(async (path, init, options) => { + calls.push({path, init, options}) + return new Response(JSON.stringify({name: 'orders-bus', mocked: false})) + }) + + const resource = await new AzureServiceBusAdapter(client).get('orders-bus') + + expect(calls[0].path).toBe('/devstoreaccount1-servicebus/namespaces/orders-bus') + expect(calls[0].init.method).toBe('GET') + expect(calls[0].options).toEqual({emptyOnNotFound: true}) + expect(resource?.id).toBe('orders-bus') + }) + + test('creates a namespace through the emulator management contract', async () => { + const calls: RecordedCall[] = [] + const client = testClient(async (path, init, options) => { + calls.push({path, init, options}) + if (init.method === 'GET') return null + return new Response(JSON.stringify({name: 'orders-bus', amqpPort: 5672, mocked: false}), {status: 201}) + }) + + const resource = await new AzureServiceBusAdapter(client).create({values: {namespaceName: 'orders-bus'}}) + + const put = calls[calls.length - 1] + expect(put.path).toBe('/devstoreaccount1-servicebus/namespaces/orders-bus') + expect(put.init.method).toBe('PUT') + expect(put.init.body).toBe('{}') + expect(put.init.headers).toEqual({accept: 'application/json', 'content-type': 'application/json'}) + expect(resource.id).toBe('orders-bus') + }) + + test('rejects creating a namespace that already exists', async () => { + const methods: (string | undefined)[] = [] + const client = testClient(async (_path, init) => { + methods.push(init.method) + return new Response(JSON.stringify({name: 'orders-bus', mocked: false})) + }) + + const failure = await new AzureServiceBusAdapter(client) + .create({values: {namespaceName: 'orders-bus'}}) + .catch((err: unknown) => err) + expect(failure).toBeInstanceOf(ConflictError) + expect((failure as ConflictError).message).toBe('Service Bus namespace orders-bus already exists') + expect(toHttpError(failure).status).toBe(409) + expect(methods).toEqual(['GET']) + }) + + test('surfaces Service Bus create errors', async () => { + const client = testClient(async (path, init) => { + if (init.method === 'GET') return null + throw new Error(`Azure runtime request failed: HTTP 500 ${path} - namespace store unavailable`) + }) + + await expect(new AzureServiceBusAdapter(client).create({values: {namespaceName: 'orders-bus'}})) + .rejects.toThrow('HTTP 500 /devstoreaccount1-servicebus/namespaces/orders-bus - namespace store unavailable') + }) + + test('rejects invalid namespace names as 400s', async () => { + const adapter = new AzureServiceBusAdapter(testClient(async () => new Response())) + + for (const namespaceName of ['', 'short', 'orders-sb']) { + const failure = await adapter.create({values: {namespaceName}}).catch((err: unknown) => err) + expect(failure).toBeInstanceOf(ValidationError) + expect(toHttpError(failure).status).toBe(400) + } + }) + + test('deletes a namespace through the emulator management contract', async () => { + const calls: RecordedCall[] = [] + const client = testClient(async (path, init, options) => { + calls.push({path, init, options}) + return new Response(null, {status: 204}) + }) + + await new AzureServiceBusAdapter(client).delete('orders-bus') + + expect(calls).toEqual([{ + path: '/devstoreaccount1-servicebus/namespaces/orders-bus', + init: {method: 'DELETE'}, + options: undefined, + }]) + }) + + test('surfaces a delete of a namespace that does not exist', async () => { + const client = testClient(async (path) => { + throw new Error(`Azure runtime request failed: HTTP 404 ${path}`) + }) + + await expect(new AzureServiceBusAdapter(client).delete('missing')).rejects.toThrow('HTTP 404') + }) +}) + +function testClient( + handler: (path: string, init: RequestInit, options?: AzureRuntimeFetchOptions) => Promise, +): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + fetch: handler, + } +} diff --git a/packages/api/src/adapter-azure/AzureServiceBusAdapter.ts b/packages/api/src/adapter-azure/AzureServiceBusAdapter.ts new file mode 100644 index 0000000..f8deb74 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureServiceBusAdapter.ts @@ -0,0 +1,159 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors' +import {azureServiceBusSchema} from '../cloud-spi/servicebusSchema' +import type { + CloudResource, + CloudServiceAdapter, + CloudServiceDescriptorOverride, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +interface ServiceBusNamespaceRecord { + name?: string + amqpPort?: number + amqpsPort?: number + tlsCertPem?: string + mocked?: boolean +} + +interface ServiceBusNamespaceListResponse { + namespaces?: ServiceBusNamespaceRecord[] +} + +export class AzureServiceBusAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'messaging' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureServiceBusSchema() + } + + // The catalog category is the cloud-neutral 'Messaging'; on Azure it is Service Bus. + descriptorOverride(): CloudServiceDescriptorOverride { + return {displayName: 'Service Bus'} + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.azureJson( + namespacePath(this.client), + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + const resources = (body?.namespaces ?? []) + .map(toNamespaceResource) + .filter((resource): resource is CloudResource => resource !== null) + return filterBySearch(resources, query.search) + } + + async get(id: string): Promise { + const body = await this.azureJson( + namespaceResourcePath(this.client, id), + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + return body ? toNamespaceResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const namespaceName = stringValue(input.values.namespaceName) + if (!namespaceName) throw new ValidationError('namespaceName is required') + if (!isValidNamespaceName(namespaceName)) { + throw new ValidationError('Use a valid Service Bus namespace: 6-50 letters, numbers, or hyphens; start with a letter, end with a letter or number, and do not end with -sb or -mgmt.') + } + + // The runtime treats PUT as idempotent and answers 200 for a namespace that already + // exists, so a duplicate would otherwise be reported to the user as a fresh create. + if (await this.get(namespaceName)) { + throw new ConflictError(`Service Bus namespace ${namespaceName} already exists`) + } + + const body = await this.azureJson( + namespaceResourcePath(this.client, namespaceName), + {method: 'PUT', body: '{}'}, + ) + if (!body) throw new RuntimeError('Azure Service Bus create returned an empty response') + const resource = toNamespaceResource(body) + if (!resource) throw new RuntimeError('Azure Service Bus create returned a namespace without a name') + return resource + } + + async delete(id: string): Promise { + await this.client.fetch(namespaceResourcePath(this.client, id), {method: 'DELETE'}) + } + + private async azureJson( + path: string, + init: RequestInit, + options?: {emptyOnNotFound?: boolean}, + ): Promise { + const res = await this.client.fetch( + path, + { + ...init, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, + options, + ) + + if (!res || res.status === 204) return null + return await res.json() as T + } +} + +function namespacePath(client: AzureRuntimeClient): string { + return `/${encodeURIComponent(client.accountName)}-servicebus/namespaces` +} + +function namespaceResourcePath(client: AzureRuntimeClient, namespaceName: string): string { + return `${namespacePath(client)}/${encodeURIComponent(namespaceName)}` +} + +function toNamespaceResource(record: ServiceBusNamespaceRecord): CloudResource | null { + const name = stringValue(record.name) + if (!name) return null + + return { + id: name, + name, + cloud: 'azure', + service: 'messaging', + type: 'servicebus-namespace', + region: null, + createdAt: null, + status: record.mocked ? 'Mocked' : 'Running', + metadata: { + provider: 'azure', + messagingService: 'service-bus', + amqpPort: record.amqpPort, + amqpsPort: record.amqpsPort, + mocked: record.mocked, + tlsEnabled: Boolean(record.tlsCertPem), + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidNamespaceName(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$/.test(value) + && !value.toLowerCase().endsWith('-sb') + && !value.toLowerCase().endsWith('-mgmt') +} diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts new file mode 100644 index 0000000..b46dcd2 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts @@ -0,0 +1,230 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +import {toHttpError} from '../cloud-spi/errors' +import {AzureSqlAdapter} from './AzureSqlAdapter' +import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient' + +const SERVERS_PATH = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers' +const API_VERSION = '?api-version=2021-11-01' + +describe('AzureSqlAdapter', () => { + test('normalizes Azure SQL servers as cloud database resources', async () => { + const adapter = new AzureSqlAdapter(testClient({ + [`${SERVERS_PATH}${API_VERSION}`]: { + value: [{ + id: `${SERVERS_PATH}/app-sql`, + name: 'app-sql', + type: 'Microsoft.Sql/servers', + location: 'eastus', + kind: 'v12.0', + tags: {environment: 'test'}, + properties: { + administratorLogin: 'sa', + version: '12.0', + state: 'Ready', + fullyQualifiedDomainName: 'localhost', + localPort: 14330, + publicNetworkAccess: 'Enabled', + }, + }], + }, + })) + + await expect(adapter.list()).resolves.toEqual([{ + id: 'sql-server:app-sql', + name: 'app-sql', + cloud: 'azure', + service: 'database', + type: 'sql-server', + region: 'eastus', + createdAt: null, + status: 'Ready', + engine: 'azure-sql', + version: '12.0', + instanceClass: 'v12.0', + metadata: { + provider: 'azure', + databaseService: 'sql', + resourceKind: 'server', + armId: `${SERVERS_PATH}/app-sql`, + serverName: 'app-sql', + administratorLogin: 'sa', + fullyQualifiedDomainName: 'localhost', + publicNetworkAccess: 'Enabled', + minimalTlsVersion: undefined, + endpoint: {address: 'localhost', port: 14330}, + tags: [{key: 'environment', value: 'test'}], + }, + }]) + }) + + test('creates an Azure SQL server through the ARM contract', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}` + const adapter = new AzureSqlAdapter(testClient({ + [serverPath]: { + id: `${SERVERS_PATH}/app-sql`, + name: 'app-sql', + location: 'uksouth', + properties: {state: 'Ready', version: '12.0'}, + }, + }, calls)) + + const resource = await adapter.create({values: { + serverName: 'app-sql', + location: 'uksouth', + administratorLogin: 'sqladmin', + administratorLoginPassword: 'StrongPassw0rd!', + }}) + + expect(resource.id).toBe('sql-server:app-sql') + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe(serverPath) + expect(calls[0].init.method).toBe('PUT') + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + location: 'uksouth', + properties: { + administratorLogin: 'sqladmin', + administratorLoginPassword: 'StrongPassw0rd!', + }, + }) + }) + + test('gets and deletes a SQL server by its normalized id', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}` + const adapter = new AzureSqlAdapter(testClient({ + [serverPath]: {name: 'app-sql', location: 'eastus', properties: {state: 'Ready'}}, + }, calls)) + + await expect(adapter.get('sql-server:app-sql')).resolves.toMatchObject({name: 'app-sql', type: 'sql-server'}) + await adapter.delete('sql-server:app-sql') + + expect(calls.map((call) => [call.init.method, call.path])).toEqual([ + ['GET', serverPath], + ['DELETE', serverPath], + ]) + }) + + test('returns null when an Azure SQL server is not found', async () => { + const adapter = new AzureSqlAdapter(testClient({})) + await expect(adapter.get('sql-server:missing')).resolves.toBeNull() + }) + + test('browses databases and tables and runs T-SQL through the server endpoint', async () => { + const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}` + const dataCalls: Array<{connection: SqlDataConnection; query: string}> = [] + const dataClient: SqlDataClient = { + async query(connection, query) { + dataCalls.push({connection, query}) + if (query.includes('FROM sys.databases')) { + return sqlResult([{name: 'master', state: 'ONLINE', createdAt: '2026-01-01T00:00:00.000Z', isSystem: true}]) + } + if (query.includes('FROM sys.tables')) { + return sqlResult([{schemaName: 'dbo', name: 'orders', objectType: 'table', rowCount: '42'}]) + } + return sqlResult([{orderId: 1042}], [1]) + }, + } + const adapter = new AzureSqlAdapter(testClient({ + [serverPath]: { + name: 'app-sql', + properties: { + fullyQualifiedDomainName: 'app-sql.local', + localPort: 1433, + minimalTlsVersion: 'None', + }, + }, + }), dataClient) + const credentials = {username: 'sa', password: 'LocalDev!2026'} + + await expect(adapter.listSqlDatabases('sql-server:app-sql', credentials)).resolves.toEqual([{ + name: 'master', + state: 'ONLINE', + createdAt: '2026-01-01T00:00:00.000Z', + isSystem: true, + }]) + await expect(adapter.listSqlTables('sql-server:app-sql', {...credentials, database: 'ordersdb'})).resolves.toEqual([{ + schema: 'dbo', + name: 'orders', + type: 'table', + rowCount: 42, + }]) + await expect(adapter.querySql('sql-server:app-sql', {...credentials, database: 'ordersdb'}, 'SELECT * FROM dbo.orders')).resolves.toEqual( + sqlResult([{orderId: 1042}], [1]), + ) + + expect(dataCalls).toHaveLength(3) + expect(dataCalls[0].connection).toEqual({ + server: 'app-sql.local', + port: 1433, + database: 'master', + username: 'sa', + password: 'LocalDev!2026', + tlsMode: 'trust-server-certificate', + }) + expect(dataCalls[1].connection.database).toBe('ordersdb') + expect(dataCalls[2].query).toBe('SELECT * FROM dbo.orders') + }) + + test('rejects a blank query before looking the server up', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const adapter = new AzureSqlAdapter(testClient({}, calls)) + + await expect(adapter.querySql('sql-server:missing', {username: 'sa', password: 'LocalDev!2026'}, ' ')) + .rejects.toThrow('SQL query is required') + expect(calls).toEqual([]) + }) + + test('reports bad input as 400 and a missing server as 404', async () => { + const existing = new AzureSqlAdapter(testClient({ + [`${SERVERS_PATH}/app-sql${API_VERSION}`]: { + name: 'app-sql', + properties: {fullyQualifiedDomainName: 'app-sql.local', localPort: 1433}, + }, + })) + const missing = new AzureSqlAdapter(testClient({})) + + expect(await httpStatusOf(() => existing.create({values: {}}))).toBe(400) + expect(await httpStatusOf(() => existing.create({values: {serverName: 'App_SQL'}}))).toBe(400) + expect(await httpStatusOf(() => existing.create({values: {serverName: 'app-sql'}}))).toBe(400) + expect(await httpStatusOf(() => existing.querySql('sql-server:app-sql', {username: 'sa', password: 'x'}, ''))).toBe(400) + expect(await httpStatusOf(() => existing.querySql('sql-server:app-sql', {username: '', password: ''}, 'SELECT 1'))).toBe(400) + expect(await httpStatusOf(() => existing.get(''))).toBe(400) + + expect(await httpStatusOf(() => missing.querySql('sql-server:gone', {username: 'sa', password: 'x'}, 'SELECT 1'))).toBe(404) + }) +}) + +async function httpStatusOf(operation: () => Promise): Promise { + const failure = await operation().then(() => null, (err: unknown) => err) + expect(failure).not.toBeNull() + return toHttpError(failure).status +} + +function sqlResult(rows: Array>, rowsAffected: number[] = []) { + return { + resultSets: [{ + columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'nvarchar'})), + rows, + truncated: false, + }], + rowsAffected, + durationMs: 1, + } +} + +function testClient(responses: Record, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init}) + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + throw new Error(`Unexpected Azure runtime request: ${path}`) + } + return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.ts new file mode 100644 index 0000000..fd8d7b2 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.ts @@ -0,0 +1,262 @@ +import { + azure, + azureResourceGroup, + type AzureRuntimeClient, + azureSubscriptionId, +} from '../azure' +import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' +import {MssqlDataClient, type SqlDataClient, type SqlDataConnection} from './MssqlDataClient' +import {azureSqlTlsMode} from './sqlTransport' +import { + filterBySearch, + isValidServerName, + numberValue, + rawStringValue, + recordValue, + stringValue, + tagList, + withApiVersion, +} from './sqlAdapterUtils' + +const API_VERSION = '2021-11-01' +const RESOURCE_ID_PREFIX = 'sql-server:' +const LIST_DATABASES_QUERY = ` +SELECT + name, + state_desc AS state, + create_date AS createdAt, + CAST(CASE WHEN database_id <= 4 THEN 1 ELSE 0 END AS bit) AS isSystem +FROM sys.databases +ORDER BY database_id` +const LIST_TABLES_QUERY = ` +SELECT + schemas.name AS [schemaName], + tables.name, + 'table' AS [objectType], + SUM(partitions.rows) AS [rowCount] +FROM sys.tables AS tables +INNER JOIN sys.schemas AS schemas ON schemas.schema_id = tables.schema_id +LEFT JOIN sys.partitions AS partitions + ON partitions.object_id = tables.object_id AND partitions.index_id IN (0, 1) +GROUP BY schemas.name, tables.name +UNION ALL +SELECT schemas.name, views.name, 'view', NULL +FROM sys.views AS views +INNER JOIN sys.schemas AS schemas ON schemas.schema_id = views.schema_id +ORDER BY [schemaName], name` + +interface AzureSqlListResponse { + value?: AzureSqlRecord[] +} + +interface AzureSqlRecord { + id?: string + name?: string + type?: string + location?: string + kind?: string + tags?: Record + properties?: Record +} + +export class AzureSqlAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'database' as const + + constructor( + private readonly client: AzureRuntimeClient = azure, + private readonly dataClient: SqlDataClient = new MssqlDataClient(), + ) {} + + schema(): ServiceSchema { + return azureDatabaseSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.sqlJson(sqlServersPath(), {method: 'GET'}, true) + return filterBySearch((body?.value ?? []).map(toSqlServerResource), query.search) + } + + async get(id: string): Promise { + const serverName = sqlServerName(id) + const body = await this.sqlJson(sqlServerPath(serverName), {method: 'GET'}, true) + return body ? toSqlServerResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const serverName = stringValue(input.values.serverName) + const location = stringValue(input.values.location) || 'eastus' + const administratorLogin = stringValue(input.values.administratorLogin) + const administratorLoginPassword = stringValue(input.values.administratorLoginPassword) + + if (!serverName) throw new ValidationError('serverName is required') + if (!isValidServerName(serverName)) throw new ValidationError('Use a valid Azure SQL server name.') + if (!administratorLogin) throw new ValidationError('administratorLogin is required') + if (!administratorLoginPassword) throw new ValidationError('administratorLoginPassword is required') + + const body = await this.sqlJson(sqlServerPath(serverName), { + method: 'PUT', + body: JSON.stringify({ + location, + properties: {administratorLogin, administratorLoginPassword}, + }), + }) + if (!body) throw new RuntimeError('Azure SQL server creation returned an empty response') + return toSqlServerResource(body) + } + + async delete(id: string): Promise { + await this.sqlFetch(sqlServerPath(sqlServerName(id)), {method: 'DELETE'}) + } + + async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + const result = await this.querySql(serverId, {...connection, database: 'master'}, LIST_DATABASES_QUERY) + return (result.resultSets[0]?.rows ?? []).flatMap((row) => { + const name = stringValue(row.name) + if (!name) return [] + return [{ + name, + state: stringValue(row.state) || 'UNKNOWN', + createdAt: stringValue(row.createdAt) || null, + isSystem: row.isSystem === true || row.isSystem === 1, + }] + }) + } + + async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise { + const result = await this.querySql(serverId, connection, LIST_TABLES_QUERY) + return (result.resultSets[0]?.rows ?? []).flatMap((row) => { + const schema = stringValue(row.schemaName) + const name = stringValue(row.name) + if (!schema || !name) return [] + return [{ + schema, + name, + type: row.objectType === 'view' ? 'view' as const : 'table' as const, + rowCount: integerValue(row.rowCount), + }] + }) + } + + async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise { + if (!query.trim()) throw new ValidationError('SQL query is required') + return this.dataClient.query(await this.dataConnection(serverId, connection), query) + } + + private sqlFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + return this.client.fetch(withApiVersion(path, API_VERSION), { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, {emptyOnNotFound}) + } + + private async sqlJson(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + const response = await this.sqlFetch(path, init, emptyOnNotFound) + if (!response || response.status === 204) return null + return await response.json() as T + } + + private async dataConnection(serverId: string, input: SqlConnectionInput): Promise { + const resource = await this.get(serverId) + if (!resource) throw new NotFoundError('Azure SQL server not found') + + const endpoint = recordValue(resource.metadata.endpoint) + const server = stringValue(endpoint?.address) + const port = numberValue(endpoint?.port) + const username = rawStringValue(input.username) + const password = rawStringValue(input.password) + const database = stringValue(input.database) || 'master' + const minimalTlsVersion = stringValue(resource.metadata.minimalTlsVersion) || null + const hasLocalPort = endpoint?.port !== undefined && endpoint.port !== null + + // The endpoint comes from the resource the runtime returned, so a missing + // address is a runtime problem rather than something the caller sent. + if (!server || port === null) throw new RuntimeError('Azure SQL server endpoint is not available') + if (!username) throw new ValidationError('SQL username is required') + if (!password) throw new ValidationError('SQL password is required') + + return { + server, + port, + database, + username, + password, + tlsMode: azureSqlTlsMode(server, minimalTlsVersion, hasLocalPort), + } + } +} + +export function isSqlServerResourceId(id: string): boolean { + return id.startsWith(RESOURCE_ID_PREFIX) +} + +function sqlServersPath(): string { + return `/subscriptions/${encodeURIComponent(azureSubscriptionId())}/resourceGroups/${encodeURIComponent(azureResourceGroup())}/providers/Microsoft.Sql/servers` +} + +function sqlServerPath(serverName: string): string { + return `${sqlServersPath()}/${encodeURIComponent(serverName)}` +} + +function sqlServerName(id: string): string { + const name = isSqlServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id + if (!name) throw new ValidationError('Azure SQL server id is required') + return name +} + +function toSqlServerResource(server: AzureSqlRecord): CloudResource { + const name = stringValue(server.name) + const properties = server.properties ?? {} + return { + id: `${RESOURCE_ID_PREFIX}${name}`, + name, + cloud: 'azure', + service: 'database', + type: 'sql-server', + region: stringValue(server.location) || null, + createdAt: null, + status: stringValue(properties.state) || null, + engine: 'azure-sql', + version: stringValue(properties.version) || null, + instanceClass: stringValue(server.kind) || null, + metadata: { + provider: 'azure', + databaseService: 'sql', + resourceKind: 'server', + armId: server.id, + serverName: name, + administratorLogin: properties.administratorLogin, + fullyQualifiedDomainName: properties.fullyQualifiedDomainName, + publicNetworkAccess: properties.publicNetworkAccess, + minimalTlsVersion: properties.minimalTlsVersion, + endpoint: { + address: properties.fullyQualifiedDomainName, + port: numberValue(properties.localPort), + }, + tags: tagList(server.tags), + }, + } +} + +function integerValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isSafeInteger(value)) return value + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : null +} diff --git a/packages/api/src/adapter-azure/AzureStorageAdapter.test.ts b/packages/api/src/adapter-azure/AzureStorageAdapter.test.ts new file mode 100644 index 0000000..7ebacb6 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureStorageAdapter.test.ts @@ -0,0 +1,110 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureStorageAdapter} from './AzureStorageAdapter' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('AzureStorageAdapter', () => { + test('normalizes missing container list endpoint to an empty list', async () => { + globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch + + const adapter = new AzureStorageAdapter(testClient()) + await expect(adapter.list()).resolves.toEqual([]) + }) + + test('normalizes missing blob list endpoint to an empty list', async () => { + globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch + + const adapter = new AzureStorageAdapter(testClient()) + await expect(adapter.listObjects('container')).resolves.toEqual({prefix: '', objects: []}) + }) + + test('creates folder placeholders with an internal marker blob', async () => { + const paths: string[] = [] + globalThis.fetch = (async (path: RequestInfo | URL) => { + paths.push(String(path)) + return new Response('', {status: 201}) + }) as unknown as typeof fetch + + const adapter = new AzureStorageAdapter(testClient()) + await adapter.putObject('container', 'photos/', new Uint8Array(), 'application/x-directory') + + expect(paths).toEqual(['/devstoreaccount1/container/photos/.floci-folder']) + }) + + test('renders internal marker blobs as folders', async () => { + globalThis.fetch = (async () => new Response(` + + + + + test/.floci-folder + + Wed, 20 May 2026 04:16:27 GMT + 0 + + + + background.png + + Wed, 20 May 2026 04:18:00 GMT + 120 + + + + + `, {status: 200})) as unknown as typeof fetch + + const adapter = new AzureStorageAdapter(testClient()) + await expect(adapter.listObjects('container')).resolves.toEqual({ + prefix: '', + objects: [ + { + key: 'test/', + name: 'test', + type: 'folder', + size: null, + lastModified: 'Wed, 20 May 2026 04:16:27 GMT', + metadata: { + provider: 'azure', + storageService: 'blob', + prefix: 'test/', + marker: '.floci-folder', + }, + }, + { + key: 'background.png', + name: 'background.png', + type: 'object', + size: 120, + lastModified: 'Wed, 20 May 2026 04:18:00 GMT', + metadata: { + provider: 'azure', + storageService: 'blob', + accessTier: undefined, + blobType: undefined, + contentType: undefined, + etag: undefined, + }, + }, + ], + }) + }) +}) + +function testClient(): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + const res = await globalThis.fetch(path, init) + if (options.emptyOnNotFound && res.status === 404) return null + if (!res.ok) throw new Error(`Azure Blob request failed: HTTP ${res.status}`) + return res + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureStorageAdapter.ts b/packages/api/src/adapter-azure/AzureStorageAdapter.ts new file mode 100644 index 0000000..6dec4b6 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureStorageAdapter.ts @@ -0,0 +1,257 @@ +import {NotFoundError, ValidationError} from '../cloud-spi/errors' +import {azureStorageSchema} from '../cloud-spi/storageSchema' +import {azure, type AzureRuntimeClient} from '../azure' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + StorageObject, + StorageObjectDownload, + StorageObjectList, +} from '../cloud-spi/types' + +const FOLDER_MARKER = '.floci-folder' + +export class AzureStorageAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'storage' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureStorageSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const res = await this.client.fetch(`${accountPath(this.client)}?comp=list`, {method: 'GET'}, {emptyOnNotFound: true}) + if (!res) return [] + + const xml = await res.text() + return filterBySearch(parseContainers(xml), query.search) + } + + async get(id: string): Promise { + const resources = await this.list() + return resources.find((resource) => resource.id === id) ?? null + } + + async create(input: CreateResourceInput): Promise { + const containerName = stringValue(input.values.containerName) + if (!containerName) throw new ValidationError('containerName is required') + if (!isValidContainerName(containerName)) { + throw new ValidationError('Use a valid Azure container name: 3-63 lowercase letters, numbers, or single hyphens.') + } + + await this.client.fetch(`${containerPath(this.client, containerName)}?restype=container`, {method: 'PUT'}) + return normalizeContainer(containerName, null) + } + + async delete(id: string): Promise { + await this.client.fetch(`${containerPath(this.client, id)}?restype=container`, {method: 'DELETE'}) + } + + async listObjects(resourceId: string, prefix = ''): Promise { + const qs = new URLSearchParams({restype: 'container', comp: 'list', delimiter: '/'}) + if (prefix) qs.set('prefix', prefix) + const res = await this.client.fetch(`${containerPath(this.client, resourceId)}?${qs}`, {method: 'GET'}, {emptyOnNotFound: true}) + if (!res) return {prefix, objects: []} + + const xml = await res.text() + return {prefix, objects: parseBlobs(xml, prefix)} + } + + async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise { + const blobKey = azureBlobKey(key, contentType) + await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(blobKey)}`, { + method: 'PUT', + body: copyBytes(body), + headers: { + 'content-type': contentType, + 'x-ms-blob-type': 'BlockBlob', + }, + }) + } + + async getObject(resourceId: string, key: string): Promise { + const res = await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(key)}`, {method: 'GET'}) + if (!res) throw new NotFoundError('Azure blob not found') + return { + body: await res.arrayBuffer(), + contentType: res.headers.get('content-type') ?? 'application/octet-stream', + contentLength: numberHeader(res.headers.get('content-length')), + } + } + + async deleteObject(resourceId: string, key: string): Promise { + await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(key)}`, {method: 'DELETE'}) + } + + async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise { + const destContainer = destResourceId ?? srcResourceId + const srcUrl = `${this.client.endpoint}${containerPath(this.client, srcResourceId)}/${encodePath(srcKey)}` + await this.client.fetch(`${containerPath(this.client, destContainer)}/${encodePath(destKey)}`, { + method: 'PUT', + headers: {'x-ms-copy-source': srcUrl}, + }) + } +} + +function accountPath(client: AzureRuntimeClient): string { + return `/${encodeURIComponent(client.accountName)}` +} + +function containerPath(client: AzureRuntimeClient, containerName: string): string { + return `${accountPath(client)}/${encodeURIComponent(containerName)}` +} + +function parseContainers(xml: string): CloudResource[] { + const matches = xml.matchAll(/\s*([^<]+)<\/Name>[\s\S]*?(?:([^<]+)<\/Last-Modified>)?[\s\S]*?<\/Container>/g) + return [...matches].map((match) => normalizeContainer(decodeXml(match[1]), match[2] ? decodeXml(match[2]) : null)) +} + +function parseBlobs(xml: string, prefix: string) { + const folderKeys = new Set() + const prefixes: StorageObject[] = [...xml.matchAll(/\s*([^<]+)<\/Name>\s*<\/BlobPrefix>/g)].map((match) => { + const key = decodeXml(match[1]) + folderKeys.add(key) + return { + key, + name: objectName(key, prefix), + type: 'folder' as const, + size: null, + lastModified: null, + metadata: { + provider: 'azure', + storageService: 'blob', + prefix: key, + }, + } + }) + + const blobs: StorageObject[] = [] + for (const match of xml.matchAll(/\s*([^<]+)<\/Name>[\s\S]*?<\/Blob>/g)) { + const key = decodeXml(match[1]) + const blobXml = match[0] + const lastModified = xmlValue(blobXml, 'Last-Modified') ?? null + const contentLength = numberHeader(xmlValue(blobXml, 'Content-Length') ?? null) + const markerFolderKey = markerFolderPrefix(key) + if (markerFolderKey) { + if (!folderKeys.has(markerFolderKey)) { + folderKeys.add(markerFolderKey) + prefixes.push({ + key: markerFolderKey, + name: objectName(markerFolderKey, prefix), + type: 'folder' as const, + size: null, + lastModified, + metadata: { + provider: 'azure', + storageService: 'blob', + prefix: markerFolderKey, + marker: FOLDER_MARKER, + }, + }) + } + continue + } + + blobs.push({ + key, + name: objectName(key, prefix), + type: 'object' as const, + size: contentLength, + lastModified, + metadata: { + provider: 'azure', + storageService: 'blob', + etag: xmlValue(blobXml, 'Etag') ?? xmlValue(blobXml, 'ETag'), + contentType: xmlValue(blobXml, 'Content-Type'), + blobType: xmlValue(blobXml, 'BlobType'), + accessTier: xmlValue(blobXml, 'AccessTier'), + }, + }) + } + + return [...prefixes, ...blobs] +} + +function normalizeContainer(name: string, createdAt: string | null): CloudResource { + return { + id: name, + name, + cloud: 'azure', + service: 'storage', + type: 'container', + region: null, + createdAt, + metadata: { + provider: 'azure', + storageService: 'blob', + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function decodeXml(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') +} + +function xmlValue(xml: string, tagName: string): string | undefined { + const match = xml.match(new RegExp(`<${escapeRegExp(tagName)}>([^<]+)<\\/${escapeRegExp(tagName)}>`, 'i')) + return match?.[1] ? decodeXml(match[1]) : undefined +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function objectName(key: string, prefix: string): string { + const relative = key.startsWith(prefix) ? key.slice(prefix.length) : key + return relative.replace(/\/$/, '') || key +} + +function encodePath(key: string): string { + return key.split('/').map(encodeURIComponent).join('/') +} + +function numberHeader(value: string | null): number | null { + if (!value) return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +function copyBytes(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return copy.buffer +} + +function azureBlobKey(key: string, contentType: string): string { + if (key.endsWith('/') && contentType === 'application/x-directory') return `${key}${FOLDER_MARKER}` + return key +} + +function markerFolderPrefix(key: string): string | null { + if (!key.endsWith(`/${FOLDER_MARKER}`)) return null + return key.slice(0, -FOLDER_MARKER.length) +} + +function isValidContainerName(value: string): boolean { + return /^[a-z0-9](?:[a-z0-9]|-(?!-)){1,61}[a-z0-9]$/.test(value) +} diff --git a/packages/api/src/adapter-azure/CosmosNoSqlUnavailableError.ts b/packages/api/src/adapter-azure/CosmosNoSqlUnavailableError.ts new file mode 100644 index 0000000..6469094 --- /dev/null +++ b/packages/api/src/adapter-azure/CosmosNoSqlUnavailableError.ts @@ -0,0 +1,12 @@ +import {CloudError, type CloudErrorStatus} from '../cloud-spi/errors' + +/** + * Raised when none of the known Cosmos NoSQL route shapes answered on the + * Floci-AZ runtime. Keeps its own wire code so the frontend can keep telling + * "Cosmos is not enabled on this runtime" apart from a generic runtime failure. + */ +export class CosmosNoSqlUnavailableError extends CloudError { + readonly status: CloudErrorStatus = 502 + readonly code = 'cosmos_nosql_unavailable' as const + protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime' +} diff --git a/packages/api/src/adapter-azure/MssqlDataClient.ts b/packages/api/src/adapter-azure/MssqlDataClient.ts new file mode 100644 index 0000000..e3f88fc --- /dev/null +++ b/packages/api/src/adapter-azure/MssqlDataClient.ts @@ -0,0 +1,84 @@ +import sql from 'mssql' +import type {config as MssqlConfig, IRecordSet} from 'mssql' +import type {SqlColumn, SqlQueryResult, SqlResultSet} from '../cloud-spi/types' +import {mapSqlDataError, type SqlDataPhase} from './sqlDataErrors' +import type {SqlTlsMode} from './sqlTransport' +import {normalizeSqlRow} from './sqlValues' + +const MAX_ROWS_PER_RESULT_SET = 500 + +export interface SqlDataConnection { + server: string + port: number + database: string + username: string + password: string + tlsMode: SqlTlsMode +} + +export interface SqlDataClient { + query(connection: SqlDataConnection, query: string): Promise +} + +export class MssqlDataClient implements SqlDataClient { + async query(connection: SqlDataConnection, query: string): Promise { + const pool = new sql.ConnectionPool(connectionConfig(connection)) + const startedAt = performance.now() + let phase: SqlDataPhase = 'connect' + + try { + await pool.connect() + phase = 'query' + const result = await pool.request().query>(query) + phase = 'result' + return { + resultSets: result.recordsets.map(toResultSet), + rowsAffected: result.rowsAffected, + durationMs: Math.round(performance.now() - startedAt), + } + } catch (error) { + throw mapSqlDataError('Azure SQL', phase, error) + } finally { + await pool.close().catch(() => undefined) + } + } +} + +function connectionConfig(connection: SqlDataConnection): MssqlConfig { + return { + user: connection.username, + password: connection.password, + server: connection.server, + port: connection.port, + database: connection.database, + connectionTimeout: 10_000, + requestTimeout: 30_000, + pool: {min: 0, max: 1, idleTimeoutMillis: 5_000}, + options: { + encrypt: connection.tlsMode !== 'disable', + trustServerCertificate: connection.tlsMode !== 'verify', + appName: 'Floci UI', + }, + } +} + +function toResultSet(recordset: IRecordSet>): SqlResultSet { + return { + columns: Object.values(recordset.columns) + .sort((left, right) => left.index - right.index) + .map((column): SqlColumn => ({ + name: column.name, + type: sqlTypeName(column.type), + })), + rows: recordset.slice(0, MAX_ROWS_PER_RESULT_SET).map(normalizeSqlRow), + truncated: recordset.length > MAX_ROWS_PER_RESULT_SET, + } +} + +function sqlTypeName(type: unknown): string { + if (type && (typeof type === 'object' || typeof type === 'function') && 'declaration' in type) { + const declaration = (type as {declaration?: unknown}).declaration + if (typeof declaration === 'string') return declaration + } + return 'unknown' +} diff --git a/packages/api/src/adapter-azure/PostgresDataClient.ts b/packages/api/src/adapter-azure/PostgresDataClient.ts new file mode 100644 index 0000000..fce7459 --- /dev/null +++ b/packages/api/src/adapter-azure/PostgresDataClient.ts @@ -0,0 +1,62 @@ +import {Client, types} from 'pg' +import type {QueryResult} from 'pg' +import type {SqlColumn, SqlQueryResult, SqlResultSet} from '../cloud-spi/types' +import type {SqlDataConnection, SqlDataClient} from './MssqlDataClient' +import {mapSqlDataError, type SqlDataPhase} from './sqlDataErrors' +import {normalizeSqlRow} from './sqlValues' + +const MAX_ROWS_PER_RESULT_SET = 500 +const TYPE_NAMES = new Map( + Object.entries(types.builtins) + .filter((entry): entry is [string, number] => typeof entry[1] === 'number') + .map(([name, oid]) => [oid, name.toLowerCase()]), +) + +export class PostgresDataClient implements SqlDataClient { + async query(connection: SqlDataConnection, query: string): Promise { + const client = new Client({ + host: connection.server, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + ssl: connection.tlsMode === 'disable' + ? false + : {rejectUnauthorized: connection.tlsMode === 'verify'}, + connectionTimeoutMillis: 10_000, + query_timeout: 30_000, + statement_timeout: 30_000, + application_name: 'Floci UI', + }) + const startedAt = performance.now() + let phase: SqlDataPhase = 'connect' + + try { + await client.connect() + phase = 'query' + const rawResult = await client.query>(query) + phase = 'result' + const results = (Array.isArray(rawResult) ? rawResult : [rawResult]) as QueryResult>[] + return { + resultSets: results.filter((result) => result.fields.length > 0).map(toResultSet), + rowsAffected: results.map((result) => result.rowCount ?? 0), + durationMs: Math.round(performance.now() - startedAt), + } + } catch (error) { + throw mapSqlDataError('PostgreSQL', phase, error) + } finally { + await client.end().catch(() => undefined) + } + } +} + +function toResultSet(result: QueryResult>): SqlResultSet { + return { + columns: result.fields.map((field): SqlColumn => ({ + name: field.name, + type: TYPE_NAMES.get(field.dataTypeID) ?? `oid:${field.dataTypeID}`, + })), + rows: result.rows.slice(0, MAX_ROWS_PER_RESULT_SET).map(normalizeSqlRow), + truncated: result.rows.length > MAX_ROWS_PER_RESULT_SET, + } +} diff --git a/packages/api/src/adapter-azure/sqlAdapterUtils.ts b/packages/api/src/adapter-azure/sqlAdapterUtils.ts new file mode 100644 index 0000000..3044f6c --- /dev/null +++ b/packages/api/src/adapter-azure/sqlAdapterUtils.ts @@ -0,0 +1,38 @@ +import type {CloudResource} from '../cloud-spi/types' + +export function withApiVersion(path: string, apiVersion: string): string { + const separator = path.includes('?') ? '&' : '?' + return `${path}${separator}api-version=${apiVersion}` +} + +export function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +export function tagList(tags?: Record): Array<{key: string; value: string}> { + return Object.entries(tags ?? {}).map(([key, value]) => ({key, value})) +} + +export function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +export function rawStringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +export function recordValue(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +export function numberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function isValidServerName(value: string): boolean { + return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) +} diff --git a/packages/api/src/adapter-azure/sqlDataErrors.test.ts b/packages/api/src/adapter-azure/sqlDataErrors.test.ts new file mode 100644 index 0000000..5c4bd41 --- /dev/null +++ b/packages/api/src/adapter-azure/sqlDataErrors.test.ts @@ -0,0 +1,35 @@ +import {describe, expect, test} from 'bun:test' +import {toHttpError} from '../cloud-spi/errors' +import {mapSqlDataError} from './sqlDataErrors' + +describe('mapSqlDataError', () => { + test('maps connection failures to runtime unavailable', () => { + expect(toHttpError(mapSqlDataError('Azure SQL', 'connect', new Error('login failed')))).toMatchObject({ + status: 503, + body: {code: 'runtime_unavailable'}, + }) + }) + + test('maps query errors to invalid requests', () => { + expect(toHttpError(mapSqlDataError('PostgreSQL', 'query', new Error('syntax error')))).toMatchObject({ + status: 400, + body: {code: 'invalid_request'}, + }) + }) + + test('keeps transport failures unavailable during queries', () => { + const error = Object.assign(new Error('connection reset'), {code: 'ECONNRESET'}) + + expect(toHttpError(mapSqlDataError('PostgreSQL', 'query', error))).toMatchObject({ + status: 503, + body: {code: 'runtime_unavailable'}, + }) + }) + + test('maps result-processing failures to runtime errors', () => { + expect(toHttpError(mapSqlDataError('Azure SQL', 'result', new Error('invalid result')))).toMatchObject({ + status: 502, + body: {code: 'runtime_error'}, + }) + }) +}) diff --git a/packages/api/src/adapter-azure/sqlDataErrors.ts b/packages/api/src/adapter-azure/sqlDataErrors.ts new file mode 100644 index 0000000..68388ab --- /dev/null +++ b/packages/api/src/adapter-azure/sqlDataErrors.ts @@ -0,0 +1,23 @@ +import { + type CloudError, + isUnreachableCause, + RuntimeError, + RuntimeUnavailableError, + ValidationError, +} from '../cloud-spi/errors' + +export type SqlDataPhase = 'connect' | 'query' | 'result' + +export function mapSqlDataError(service: string, phase: SqlDataPhase, error: unknown): CloudError { + const detail = error instanceof Error ? error.message : `Unknown ${service} error` + const message = `${service} data request failed: ${detail}` + const options = {cause: error} + + if (phase === 'connect' || isUnreachableCause(error)) { + return new RuntimeUnavailableError(message, options) + } + if (phase === 'query' && error instanceof Error) { + return new ValidationError(message, options) + } + return new RuntimeError(message, options) +} diff --git a/packages/api/src/adapter-azure/sqlTransport.test.ts b/packages/api/src/adapter-azure/sqlTransport.test.ts new file mode 100644 index 0000000..81b9a25 --- /dev/null +++ b/packages/api/src/adapter-azure/sqlTransport.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, test} from 'bun:test' +import {azureSqlTlsMode, isLoopbackSqlHost, postgresTlsMode} from './sqlTransport' + +describe('isLoopbackSqlHost', () => { + test('treats loopback addresses as local', () => { + for (const host of ['localhost', 'LOCALHOST', ' 127.0.0.1 ', '127.1.2.3', '::1', '[::1]']) { + expect(isLoopbackSqlHost(host)).toBe(true) + } + }) + + test('treats every other host as remote so TLS stays enforced', () => { + for (const host of [ + 'db.example.com', + '10.0.0.5', + '192.168.1.10', + '0.0.0.0', + 'localhost.evil.com', + '127.0.0.1.evil.com', + '2600::1', + ]) { + expect(isLoopbackSqlHost(host)).toBe(false) + } + }) +}) + +describe('azureSqlTlsMode', () => { + test('trusts the emulator certificate for container-network endpoints', () => { + expect(azureSqlTlsMode('floci-az-sql-app', 'None', true)).toBe('trust-server-certificate') + }) + + test('keeps certificate verification for remote endpoints without the emulator hint', () => { + expect(azureSqlTlsMode('db.example.com', 'None', false)).toBe('verify') + expect(azureSqlTlsMode('db.example.com', '1.2', true)).toBe('verify') + expect(azureSqlTlsMode('db.example.com', null, false)).toBe('verify') + }) +}) + +describe('postgresTlsMode', () => { + test('disables TLS for the emulator local-port extension', () => { + expect(postgresTlsMode('floci-az-pg-app', true)).toBe('disable') + }) + + test('keeps certificate verification for remote endpoints without the emulator hint', () => { + expect(postgresTlsMode('db.example.com', false)).toBe('verify') + }) +}) diff --git a/packages/api/src/adapter-azure/sqlTransport.ts b/packages/api/src/adapter-azure/sqlTransport.ts new file mode 100644 index 0000000..5770bed --- /dev/null +++ b/packages/api/src/adapter-azure/sqlTransport.ts @@ -0,0 +1,25 @@ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '::1', '[::1]']) + +export type SqlTlsMode = 'disable' | 'trust-server-certificate' | 'verify' + +export function isLoopbackSqlHost(host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, '') + if (LOOPBACK_HOSTNAMES.has(normalized) || LOOPBACK_HOSTNAMES.has(`[${normalized}]`)) return true + // The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized) +} + +export function azureSqlTlsMode( + host: string, + minimalTlsVersion: string | null, + hasLocalPort: boolean, +): SqlTlsMode { + if (isLoopbackSqlHost(host)) return 'disable' + if (hasLocalPort && minimalTlsVersion?.trim().toLowerCase() === 'none') return 'trust-server-certificate' + return 'verify' +} + +export function postgresTlsMode(host: string, hasLocalPort: boolean): SqlTlsMode { + if (isLoopbackSqlHost(host) || hasLocalPort) return 'disable' + return 'verify' +} diff --git a/packages/api/src/adapter-azure/sqlValues.ts b/packages/api/src/adapter-azure/sqlValues.ts new file mode 100644 index 0000000..dd66880 --- /dev/null +++ b/packages/api/src/adapter-azure/sqlValues.ts @@ -0,0 +1,13 @@ +export function normalizeSqlRow(row: Record): Record { + return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeSqlValue(value)])) +} + +function normalizeSqlValue(value: unknown): unknown { + if (value === null || value === undefined) return null + if (typeof value === 'bigint') return value.toString() + if (value instanceof Date) return value.toISOString() + if (Buffer.isBuffer(value)) return value.toString('base64') + if (Array.isArray(value)) return value.map(normalizeSqlValue) + if (typeof value === 'object') return normalizeSqlRow(value as Record) + return value +} diff --git a/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts new file mode 100644 index 0000000..434e27e --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts @@ -0,0 +1,153 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpCloudFunctionsAdapter} from './GcpCloudFunctionsAdapter' +import {GcpRestRuntimeClient} from '../gcp' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const FUNCTIONS_PATH = '/v2/projects/floci-local/locations/us-central1/functions' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpCloudFunctionsAdapter { + return new GcpCloudFunctionsAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function gen2Function(name: string) { + return { + name: `projects/floci-local/locations/us-central1/functions/${name}`, + buildConfig: {runtime: 'nodejs20', entryPoint: 'helloWorld'}, + serviceConfig: { + uri: `https://us-central1-floci-local.cloudfunctions.net/${name}`, + availableMemory: '256M', + service: `projects/floci-local/locations/us-central1/services/${name}`, + revision: `projects/floci-local/locations/us-central1/functions/${name}/revisions/${name}-00001`, + allTrafficOnLatestRevision: true, + }, + state: 'ACTIVE', + environment: 'GEN_2', + url: `https://us-central1-floci-local.cloudfunctions.net/${name}`, + createTime: '2026-06-22T05:29:13Z', + updateTime: '2026-06-22T05:29:13Z', + } +} + +describe('GcpCloudFunctionsAdapter', () => { + test('lists functions and maps the Gen2 resource shape', async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + functions: [gen2Function('hello')], + }), {status: 200})) as unknown as typeof fetch + + const resources = await adapter().list() + expect(resources).toEqual([ + { + id: 'hello', + name: 'hello', + cloud: 'gcp', + service: 'serverless', + type: 'gcp-function', + region: 'us-central1', + createdAt: '2026-06-22T05:29:13Z', + status: 'ACTIVE', + metadata: { + provider: 'gcp', + serverlessService: 'cloud-functions', + resourceName: 'projects/floci-local/locations/us-central1/functions/hello', + environment: 'GEN_2', + runtime: 'nodejs20', + entryPoint: 'helloWorld', + availableMemory: '256M', + timeoutSeconds: undefined, + uri: 'https://us-central1-floci-local.cloudfunctions.net/hello', + service: 'projects/floci-local/locations/us-central1/services/hello', + revision: 'projects/floci-local/locations/us-central1/functions/hello/revisions/hello-00001', + allTrafficOnLatestRevision: true, + updateTime: '2026-06-22T05:29:13Z', + // Shared key so one serverless column works across all clouds. + lastModified: '2026-06-22T05:29:13Z', + labels: undefined, + }, + }, + ]) + }) + + test('normalizes an empty list payload', async () => { + globalThis.fetch = (async () => new Response('{}', {status: 200})) as unknown as typeof fetch + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + functions: [gen2Function('alpha'), gen2Function('beta')], + }), {status: 200})) as unknown as typeof fetch + + const resources = await adapter().list({search: 'bet'}) + expect(resources.map((r) => r.name)).toEqual(['beta']) + }) + + test('get returns null when the function is missing', async () => { + globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch + await expect(adapter().get('missing')).resolves.toBeNull() + }) + + test('create posts buildConfig with functionId and unwraps the operation response', async () => { + const calls: Array<{url: string; init: RequestInit}> = [] + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => { + calls.push({url: String(url), init}) + return new Response(JSON.stringify({ + done: true, + response: gen2Function('hello'), + }), {status: 200}) + }) as unknown as typeof fetch + + const resource = await adapter().create({ + values: {functionName: 'hello', runtime: 'nodejs20', entryPoint: 'helloWorld'}, + }) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe(`${ENDPOINT}${FUNCTIONS_PATH}?functionId=hello`) + expect(calls[0].init.method).toBe('POST') + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + buildConfig: {runtime: 'nodejs20', entryPoint: 'helloWorld'}, + }) + expect(resource.id).toBe('hello') + expect(resource.status).toBe('ACTIVE') + expect(resource.metadata.runtime).toBe('nodejs20') + }) + + test('create includes inline code as buildConfig.source when provided', async () => { + let sentBody = '' + globalThis.fetch = (async (_url: RequestInfo | URL, init: RequestInit) => { + sentBody = String(init.body) + return new Response(JSON.stringify({done: true, response: gen2Function('hello')}), {status: 200}) + }) as unknown as typeof fetch + + await adapter().create({ + values: {functionName: 'hello', runtime: 'nodejs20', entryPoint: 'helloWorld', code: 'exports.helloWorld = () => {}'}, + }) + expect(JSON.parse(sentBody)).toEqual({ + buildConfig: { + runtime: 'nodejs20', + entryPoint: 'helloWorld', + source: {inlineCode: 'exports.helloWorld = () => {}'}, + }, + }) + }) + + test('create rejects when required fields are missing', async () => { + await expect(adapter().create({values: {functionName: 'hello'}})).rejects.toThrow('runtime is required') + }) + + test('delete issues a DELETE against the function path', async () => { + const calls: Array<{url: string; method?: string}> = [] + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => { + calls.push({url: String(url), method: init.method}) + return new Response(JSON.stringify({done: true, response: {}}), {status: 200}) + }) as unknown as typeof fetch + + await adapter().delete('hello') + expect(calls[0].url).toBe(`${ENDPOINT}${FUNCTIONS_PATH}/hello`) + expect(calls[0].method).toBe('DELETE') + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts new file mode 100644 index 0000000..a1e0406 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts @@ -0,0 +1,182 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpServerlessSchema} from '../cloud-spi/serverlessSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-GCP emulator's Cloud Functions service. The emulator + * mirrors the public Cloud Functions Gen2 (v2) REST API — verified against + * `floci/floci-gcp`: + * + * list GET /v2/projects/{project}/locations/{location}/functions -> {functions: [...]} + * get GET /v2/projects/{project}/locations/{location}/functions/{id} -> Function + * create POST /v2/projects/{project}/locations/{location}/functions?functionId={id} -> Operation + * delete DELETE /v2/projects/{project}/locations/{location}/functions/{id} -> Operation + * + * Notes from probing the emulator: + * - The `locations/-` wildcard is NOT supported (returns `{}`), so list is + * scoped to the configured location (FLOCI_GCP_LOCATION, default us-central1). + * - create/delete return a long-running Operation envelope with `done: true`; + * the function resource lives under `operation.response`. + * - A function's resource `name` is the fully-qualified path + * `projects/{project}/locations/{location}/functions/{shortName}`; we expose + * the short name as the resource id. + * - Gen2 nests runtime/entryPoint under `buildConfig` and the trigger/uri under + * `serviceConfig`; the deploy state is `state` (not `status`). + */ +interface GcpFunction { + name?: string + state?: string + environment?: string + url?: string + createTime?: string + updateTime?: string + labels?: Record + buildConfig?: { + runtime?: string + entryPoint?: string + source?: Record + } + serviceConfig?: { + uri?: string + availableMemory?: string + timeoutSeconds?: number + service?: string + revision?: string + allTrafficOnLatestRevision?: boolean + } +} + +interface GcpFunctionList { + functions?: GcpFunction[] + nextPageToken?: string +} + +interface GcpOperation { + done?: boolean + response?: GcpFunction +} + +export class GcpCloudFunctionsAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'serverless' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpServerlessSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.functionsPath()) + return filterBySearch((body?.functions ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const fn = await this.client.json( + `${this.functionsPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return fn ? toResource(fn) : null + } + + async create(input: CreateResourceInput): Promise { + const functionName = stringValue(input.values.functionName ?? input.values.name) + const runtime = stringValue(input.values.runtime) + const entryPoint = stringValue(input.values.entryPoint) + const code = stringValue(input.values.code) + + if (!functionName) throw new ValidationError('functionName is required') + if (!runtime) throw new ValidationError('runtime is required') + if (!entryPoint) throw new ValidationError('entryPoint is required') + + const operation = await this.client.json( + `${this.functionsPath()}?functionId=${encodeURIComponent(functionName)}`, + { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + buildConfig: { + runtime, + entryPoint, + ...(code ? {source: {inlineCode: code}} : {}), + }, + }), + }, + ) + + // create returns an Operation envelope; the function is under `response`. + const fn = operation?.response ?? (operation as unknown as GcpFunction | null) + return toResource(fn ?? {name: functionName}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.functionsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + private functionsPath(): string { + return `/v2/projects/${encodeURIComponent(this.client.project)}/locations/${encodeURIComponent(this.client.location)}/functions` + } + +} + +function toResource(fn: GcpFunction): CloudResource { + const name = shortName(fn.name ?? '') + const build = fn.buildConfig ?? {} + const serviceConfig = fn.serviceConfig ?? {} + return { + id: name, + name, + cloud: 'gcp', + service: 'serverless', + type: 'gcp-function', + region: locationOf(fn.name ?? ''), + createdAt: fn.createTime ?? fn.updateTime ?? null, + status: fn.state ?? null, + metadata: { + provider: 'gcp', + serverlessService: 'cloud-functions', + resourceName: fn.name, + environment: fn.environment, + runtime: build.runtime, + entryPoint: build.entryPoint, + availableMemory: serviceConfig.availableMemory, + timeoutSeconds: serviceConfig.timeoutSeconds, + uri: serviceConfig.uri ?? fn.url, + service: serviceConfig.service, + revision: serviceConfig.revision, + allTrafficOnLatestRevision: serviceConfig.allTrafficOnLatestRevision, + updateTime: fn.updateTime, + // Shared key so the serverless schema can surface one column for all clouds. + lastModified: fn.updateTime, + labels: fn.labels, + }, + } +} + +function shortName(resourceName: string): string { + const match = resourceName.match(/functions\/([^/]+)$/) + return match ? match[1] : resourceName +} + +function locationOf(resourceName: string): string | null { + const match = resourceName.match(/locations\/([^/]+)/) + return match ? match[1] : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/adapter-gcp/GcpCloudRunAdapter.test.ts b/packages/api/src/adapter-gcp/GcpCloudRunAdapter.test.ts new file mode 100644 index 0000000..4899443 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudRunAdapter.test.ts @@ -0,0 +1,206 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpCloudRunAdapter} from './GcpCloudRunAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const SERVICES_PATH = '/v2/projects/floci-local/locations/us-central1/services' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpCloudRunAdapter { + return new GcpCloudRunAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +/** Records every request so path, method and body can be asserted. */ +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status}) +} + +function cloudRunService(name: string, overrides: Record = {}) { + return { + name: `projects/floci-local/locations/us-central1/services/${name}`, + uid: '4cd5586e-3fca-460b-ac38-2267fc4b8b79', + generation: '1', + createTime: '2026-07-28T09:21:30.065937592Z', + updateTime: '2026-07-28T09:21:30.065937592Z', + template: {containers: [{image: 'nginx:alpine'}]}, + traffic: [{type: 'TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST', percent: 100}], + urls: [`http://${name}-1efb2c36c23f.us-central1.run.localhost.floci.io:4588`], + observedGeneration: '1', + terminalCondition: { + type: 'Ready', + state: 'CONDITION_SUCCEEDED', + message: 'Ready', + lastTransitionTime: '2026-07-28T09:21:30.065937592Z', + }, + ...overrides, + } +} + +describe('GcpCloudRunAdapter', () => { + test('identifies itself as the GCP containers adapter', () => { + const a = adapter() + + expect(a.cloud).toBe('gcp') + expect(a.service).toBe('containers') + expect(a.schema().displayName).toBe('Cloud Run') + }) + + test('lists services and maps the v2 resource shape', async () => { + const calls = stubFetch(() => json({services: [cloudRunService('web')]})) + const [resource] = await adapter().list() + + expect(calls[0]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}`) + expect(resource).toMatchObject({ + id: 'web', + name: 'web', + cloud: 'gcp', + service: 'containers', + type: 'cloud-run-service', + region: 'us-central1', + createdAt: '2026-07-28T09:21:30.065937592Z', + status: 'SUCCEEDED', + }) + expect(resource?.metadata).toMatchObject({ + image: 'nginx:alpine', + uri: 'http://web-1efb2c36c23f.us-central1.run.localhost.floci.io:4588', + generation: '1', + }) + }) + + test('reports an empty list when the runtime returns no services key', async () => { + // The runtime answers `{}` rather than `{"services": []}` when empty. + stubFetch(() => json({})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('surfaces a failed readiness condition instead of hiding it', async () => { + // A container that does not listen on $PORT never becomes ready. The state + // and the runtime's explanation both have to reach the UI. + stubFetch(() => + json({ + services: [ + cloudRunService('broken', { + terminalCondition: { + type: 'Ready', + state: 'CONDITION_FAILED', + message: 'Cloud Run runtime did not become ready before timeout: Connection refused', + }, + }), + ], + }), + ) + const [resource] = await adapter().list() + + expect(resource?.status).toBe('FAILED') + expect(resource?.metadata.terminalMessage).toContain('did not become ready') + }) + + test('follows nextPageToken until the runtime stops paging', async () => { + let call = 0 + const calls = stubFetch(() => { + call += 1 + if (call === 1) return json({services: [cloudRunService('one')], nextPageToken: 'page-2'}) + return json({services: [cloudRunService('two')]}) + }) + + const resources = await adapter().list() + + expect(resources.map((r) => r.id)).toEqual(['one', 'two']) + expect(calls[1]?.url).toContain('pageToken=page-2') + }) + + test('filters the list by search term', async () => { + stubFetch(() => json({services: [cloudRunService('web'), cloudRunService('api')]})) + const a = adapter() + + await expect(a.list({search: 'we'})).resolves.toHaveLength(1) + await expect(a.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a service by short name', async () => { + const calls = stubFetch(() => json(cloudRunService('web'))) + const resource = await adapter().get('web') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}/web`) + expect(resource?.id).toBe('web') + }) + + test('returns null when a service does not exist', async () => { + stubFetch(() => json({error: {code: 404, message: 'Cloud Run service not found', status: 'NOT_FOUND'}}, 404)) + await expect(adapter().get('missing')).resolves.toBeNull() + }) + + test('deploys a service with the image under the template', async () => { + const calls = stubFetch((url, init) => + init?.method === 'POST' + ? json({name: 'projects/floci-local/locations/us-central1/operations/op-1'}) + : json(cloudRunService('web')), + ) + const resource = await adapter().create({values: {name: 'web', image: 'nginx:alpine'}}) + + const post = calls[0] + expect(post?.url).toBe(`${ENDPOINT}${SERVICES_PATH}?serviceId=web`) + expect(post?.init?.method).toBe('POST') + expect(JSON.parse(String(post?.init?.body))).toEqual({ + template: {containers: [{image: 'nginx:alpine'}]}, + }) + // create returns an Operation, so the service is read back rather than + // parsed out of the envelope. + expect(calls[1]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}/web`) + expect(resource.id).toBe('web') + }) + + test('passes a container port through when one is given', async () => { + const calls = stubFetch((url, init) => + init?.method === 'POST' ? json({name: 'op'}) : json(cloudRunService('web')), + ) + await adapter().create({values: {name: 'web', image: 'nginx:alpine', port: '3000'}}) + + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + template: {containers: [{image: 'nginx:alpine', ports: [{containerPort: 3000}]}]}, + }) + }) + + test('requires the fields the schema marks required', async () => { + const a = adapter() + + await expect(a.create({values: {}})).rejects.toThrow(new ValidationError('name is required')) + await expect(a.create({values: {name: 'web'}})).rejects.toThrow(new ValidationError('image is required')) + }) + + test('rejects a name the runtime would refuse', async () => { + const a = adapter() + + for (const name of ['Web', '1web', 'web_service', 'web-']) { + await expect(a.create({values: {name, image: 'nginx'}})).rejects.toThrow(ValidationError) + } + }) + + test('rejects a port that is not a number', async () => { + await expect( + adapter().create({values: {name: 'web', image: 'nginx', port: 'http'}}), + ).rejects.toThrow(ValidationError) + }) + + test('deletes a service by short name', async () => { + const calls = stubFetch(() => json({name: 'op-delete'})) + await adapter().delete('web') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}/web`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpCloudRunAdapter.ts b/packages/api/src/adapter-gcp/GcpCloudRunAdapter.ts new file mode 100644 index 0000000..6970ff9 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudRunAdapter.ts @@ -0,0 +1,211 @@ +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import { + CLOUD_RUN_NAME_MAX_LENGTH, + CLOUD_RUN_NAME_PATTERN, + gcpContainersSchema, +} from '../cloud-spi/containersSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-GCP emulator's Cloud Run service. The emulator mirrors the + * public Cloud Run Admin v2 REST API — verified against `floci/floci-gcp` 0.5.0: + * + * list GET /v2/projects/{p}/locations/{l}/services -> {services: [...]} + * get GET /v2/projects/{p}/locations/{l}/services/{id} -> Service + * create POST /v2/projects/{p}/locations/{l}/services?serviceId={id} -> Operation + * delete DELETE /v2/projects/{p}/locations/{l}/services/{id} -> Operation + * + * Notes from probing the emulator: + * - An empty project returns `{}`, not `{"services": []}`. + * - create really does start a container: a service with `nginx:alpine` produced + * a `floci-cloudrun-*` container running that image. + * - create is asynchronous. It returns an Operation whose `metadata` holds the + * Service, and the service is immediately listable as `CONDITION_PENDING`, so + * this adapter reads the service back rather than unpacking the envelope. + * - Readiness is real: a container that does not listen on $PORT settles at + * `CONDITION_FAILED` with "did not become ready before timeout". That state is + * surfaced, not hidden — it is the single most common deploy mistake. + */ +interface CloudRunContainer { + image?: string + ports?: Array<{containerPort?: number; name?: string}> + resources?: {limits?: Record} +} + +interface CloudRunService { + name?: string + uid?: string + generation?: string + createTime?: string + updateTime?: string + template?: {containers?: CloudRunContainer[]; revision?: string; serviceAccount?: string} + traffic?: Array<{type?: string; percent?: number; revision?: string}> + urls?: string[] + observedGeneration?: string + terminalCondition?: {type?: string; state?: string; message?: string; lastTransitionTime?: string} + labels?: Record +} + +interface CloudRunServiceList { + services?: CloudRunService[] + nextPageToken?: string +} + +export class GcpCloudRunAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'containers' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpContainersSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const services: CloudRunService[] = [] + let pageToken: string | undefined + + do { + const path = pageToken + ? `${this.servicesPath()}?pageToken=${encodeURIComponent(pageToken)}` + : this.servicesPath() + const body = await this.client.json(path) + services.push(...(body?.services ?? [])) + pageToken = body?.nextPageToken + } while (pageToken) + + return filterBySearch(services.map(toResource), query.search) + } + + async get(id: string): Promise { + const service = await this.client.json( + `${this.servicesPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return service ? toResource(service) : null + } + + async create(input: CreateResourceInput): Promise { + const name = requiredString(input.values.name, 'name') + if (name.length > CLOUD_RUN_NAME_MAX_LENGTH || !new RegExp(CLOUD_RUN_NAME_PATTERN).test(name)) { + throw new ValidationError( + 'name must start with a letter and contain only lowercase letters, digits and hyphens', + ) + } + const image = requiredString(input.values.image, 'image') + const port = optionalPort(input.values.port) + + const container: CloudRunContainer = {image, ...(port ? {ports: [{containerPort: port}]} : {})} + + await this.client.json(this.servicesPath(`?serviceId=${encodeURIComponent(name)}`), { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({template: {containers: [container]}}), + }) + + // The Operation envelope carries the Service under `metadata`, but reading + // it back is what the runtime actually holds — including the pending + // readiness state a fresh deploy starts in. + const created = await this.get(name) + if (!created) throw new RuntimeError(`Cloud Run service ${name} was created but could not be read back`) + return created + } + + async delete(id: string): Promise { + await this.client.fetch( + `${this.servicesPath()}/${encodeURIComponent(id)}`, + {method: 'DELETE'}, + {emptyOnNotFound: true}, + ) + } + + private servicesPath(suffix = ''): string { + const project = encodeURIComponent(this.client.project) + const location = encodeURIComponent(this.client.location) + return `/v2/projects/${project}/locations/${location}/services${suffix}` + } +} + +function toResource(service: CloudRunService): CloudResource { + const name = shortName(service.name ?? '') + const container = service.template?.containers?.[0] ?? {} + const condition = service.terminalCondition ?? {} + + return { + id: name, + name, + cloud: 'gcp', + service: 'containers', + type: 'cloud-run-service', + region: locationOf(service.name ?? ''), + createdAt: service.createTime ?? service.updateTime ?? null, + // CONDITION_SUCCEEDED / CONDITION_PENDING / CONDITION_FAILED — the prefix + // is noise in a table cell, the value itself is the runtime's. + status: readinessState(condition.state), + metadata: { + provider: 'gcp', + containerService: 'cloud-run', + resourceName: service.name, + uid: service.uid, + image: container.image, + containerPort: container.ports?.[0]?.containerPort, + resourceLimits: container.resources?.limits, + urls: service.urls, + uri: service.urls?.[0], + traffic: service.traffic, + generation: service.generation, + observedGeneration: service.observedGeneration, + /** Why the service is not ready, straight from the runtime. */ + terminalMessage: condition.message, + serviceAccount: service.template?.serviceAccount, + updateTime: service.updateTime, + lastModified: service.updateTime, + labels: service.labels, + }, + } +} + +/** `CONDITION_FAILED` -> `FAILED`; anything unexpected passes through unchanged. */ +function readinessState(state: string | undefined): string | null { + if (!state) return null + return state.startsWith('CONDITION_') ? state.slice('CONDITION_'.length) : state +} + +function shortName(resourceName: string): string { + const match = resourceName.match(/services\/([^/]+)$/) + return match ? match[1] : resourceName +} + +function locationOf(resourceName: string): string | null { + const match = resourceName.match(/locations\/([^/]+)/) + return match ? match[1] : null +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function requiredString(value: unknown, field: string): string { + const raw = typeof value === 'string' ? value.trim() : '' + if (!raw) throw new ValidationError(`${field} is required`) + return raw +} + +function optionalPort(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ValidationError('port must be an integer between 1 and 65535') + } + return port +} diff --git a/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts new file mode 100644 index 0000000..f834e38 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts @@ -0,0 +1,225 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpCloudSqlAdapter} from './GcpCloudSqlAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const INSTANCES_PATH = '/sql/v1beta4/projects/floci-local/instances' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpCloudSqlAdapter { + return new GcpCloudSqlAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +/** Create answers with this receipt, not the instance. Captured from floci-gcp 0.5.0. */ +function sqlOperation(targetId: string) { + return { + kind: 'sql#operation', + name: 'b4e23845-3325-48e4-95a3-d558f44540e8', + targetId, + targetProject: 'floci-local', + status: 'DONE', + operationType: 'CREATE', + } +} + +/** Shape captured from floci-gcp 0.5.0. */ +function sqlInstance(name: string) { + return { + name, + databaseVersion: 'POSTGRES_15', + region: 'us-central1', + settings: {tier: 'db-f1-micro'}, + kind: 'sql#instance', + project: 'floci-local', + backendType: 'SECOND_GEN', + instanceType: 'CLOUD_SQL_INSTANCE', + state: 'RUNNABLE', + gceZone: 'us-central1-a', + connectionName: `floci-local:us-central1:${name}`, + ipAddresses: [{type: 'PRIMARY', ipAddress: '172.20.0.5', port: 5432}], + } +} + +describe('GcpCloudSqlAdapter', () => { + test('identifies itself as the GCP database adapter', () => { + const instance = adapter() + expect(instance.cloud).toBe('gcp') + expect(instance.service).toBe('database') + expect(instance.schema().displayName).toBe('Cloud SQL') + }) + + test('lists instances and normalizes the sqladmin shape', async () => { + const calls = stubFetch(() => new Response( + JSON.stringify({kind: 'sql#instancesList', items: [sqlInstance('orders-db')]}), + {status: 200}, + )) + + const [resource] = await adapter().list() + + expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}`) + expect(resource).toMatchObject({ + id: 'orders-db', + name: 'orders-db', + cloud: 'gcp', + service: 'database', + type: 'db-instance', + region: 'us-central1', + status: 'RUNNABLE', + engine: 'POSTGRES_15', + instanceClass: 'db-f1-micro', + }) + // The schema surfaces the connection endpoint through a metadata path. + expect(resource?.metadata.connectionName).toBe('floci-local:us-central1:orders-db') + expect(resource?.metadata.ipAddress).toBe('172.20.0.5') + expect(resource?.metadata.port).toBe(5432) + }) + + test('normalizes an empty list payload', async () => { + stubFetch(() => new Response(JSON.stringify({kind: 'sql#instancesList'}), {status: 200})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + stubFetch(() => new Response( + JSON.stringify({items: [sqlInstance('orders-db'), sqlInstance('billing-db')]}), + {status: 200}, + )) + + await expect(adapter().list({search: 'orders'})).resolves.toHaveLength(1) + await expect(adapter().list({search: 'db'})).resolves.toHaveLength(2) + await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a single instance', async () => { + const calls = stubFetch(() => new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200})) + const resource = await adapter().get('orders-db') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`) + expect(resource?.id).toBe('orders-db') + }) + + test('returns null when the instance does not exist', async () => { + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'Cloud SQL instance not found: nope', status: 'NOT_FOUND'}}), + {status: 404}, + )) + await expect(adapter().get('nope')).resolves.toBeNull() + }) + + test('creates an instance with the documented defaults', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200}) + : new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}), + ) + await adapter().create({values: {instanceName: 'orders-db'}}) + + const body = JSON.parse(String(calls[0]?.init?.body)) + expect(calls[0]?.init?.method).toBe('POST') + expect(body).toEqual({ + name: 'orders-db', + databaseVersion: 'POSTGRES_15', + region: 'us-central1', + settings: {tier: 'db-f1-micro'}, + }) + }) + + test('resolves the operation receipt into the created instance', async () => { + // Create returns a sql#operation naming the instance, not the instance — + // echoing the receipt would surface the operation UUID as the resource name. + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200}) + : new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}), + ) + + const resource = await adapter().create({values: {instanceName: 'orders-db'}}) + + expect(resource.id).toBe('orders-db') + expect(resource.status).toBe('RUNNABLE') + // POST, then a read-back of the named instance. + expect(calls).toHaveLength(2) + expect(calls[1]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`) + }) + + test('uses an embedded resource when the runtime provides one', async () => { + const calls = stubFetch(() => new Response( + JSON.stringify({done: true, response: sqlInstance('orders-db')}), + {status: 200}, + )) + + const resource = await adapter().create({values: {instanceName: 'orders-db'}}) + + expect(resource.id).toBe('orders-db') + expect(calls).toHaveLength(1) + }) + + test('passes through an explicit version, region and tier', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200}) + : new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}), + ) + await adapter().create({ + values: {instanceName: 'orders-db', databaseVersion: 'POSTGRES_16', region: 'europe-west1', tier: 'db-g1-small'}, + }) + + const body = JSON.parse(String(calls[0]?.init?.body)) + expect(body.databaseVersion).toBe('POSTGRES_16') + expect(body.region).toBe('europe-west1') + expect(body.settings.tier).toBe('db-g1-small') + }) + + test('requires an instance name', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('rejects a name the runtime would refuse', async () => { + stubFetch(() => new Response('{}', {status: 200})) + for (const name of ['1starts-with-digit', 'Has-Upper', 'has_underscore', 'a'.repeat(63)]) { + await expect(adapter().create({values: {instanceName: name}})).rejects.toBeInstanceOf(ValidationError) + } + }) + + test('deletes an instance', async () => { + const calls = stubFetch(() => new Response('{}', {status: 200})) + await adapter().delete('orders-db') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) + + test('surfaces a missing instance on delete rather than silently succeeding', async () => { + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'Cloud SQL instance not found: nope'}}), + {status: 404}, + )) + await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) + + test("surfaces the runtime's engine restriction verbatim", async () => { + // The emulator only supports PostgreSQL; the reason must reach the user. + stubFetch(() => new Response( + JSON.stringify({error: {code: 400, message: 'Only PostgreSQL Cloud SQL instances are supported'}}), + {status: 400}, + )) + + await expect(adapter().create({values: {instanceName: 'mysql-db', databaseVersion: 'MYSQL_8_0'}})) + .rejects.toThrow('Only PostgreSQL Cloud SQL instances are supported') + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts new file mode 100644 index 0000000..75cce2c --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts @@ -0,0 +1,164 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpDatabaseSchema} from '../cloud-spi/databaseSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import {type GcpOperationEnvelope, operationResponse, operationTargetId} from './operations' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Cloud SQL through the Floci-GCP emulator, which mirrors the public + * `sqladmin` v1beta4 REST API. Verified against `floci/floci-gcp` 0.5.0: + * + * GET /sql/v1beta4/projects/{project}/instances + * POST /sql/v1beta4/projects/{project}/instances + * GET /sql/v1beta4/projects/{project}/instances/{instance} + * DELETE /sql/v1beta4/projects/{project}/instances/{instance} + * + * The runtime backs each instance with a real Postgres container, so it only + * accepts PostgreSQL and rejects other engines with a 400. + */ + +interface GcpSqlIpAddress { + type?: string + ipAddress?: string + port?: number +} + +interface GcpSqlInstance { + name?: string + databaseVersion?: string + region?: string + state?: string + project?: string + gceZone?: string + backendType?: string + instanceType?: string + connectionName?: string + createTime?: string + ipAddresses?: GcpSqlIpAddress[] + settings?: {tier?: string; dataDiskSizeGb?: string; activationPolicy?: string} +} + +interface GcpSqlInstanceList { + items?: GcpSqlInstance[] +} + +export class GcpCloudSqlAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'database' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpDatabaseSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.instancesPath()) + return filterBySearch((body?.items ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const instance = await this.client.json( + `${this.instancesPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return instance ? toResource(instance) : null + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.instanceName ?? input.values.name) + const databaseVersion = stringValue(input.values.databaseVersion) || 'POSTGRES_15' + const region = stringValue(input.values.region) || 'us-central1' + const tier = stringValue(input.values.tier) || 'db-f1-micro' + + if (!name) throw new ValidationError('instanceName is required') + if (!isValidInstanceName(name)) { + throw new ValidationError( + 'Use a valid Cloud SQL instance name: 1-62 lowercase letters, numbers, or hyphens, starting with a letter.', + ) + } + + const result = await this.client.json | GcpSqlInstance>( + this.instancesPath(), + { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name, databaseVersion, region, settings: {tier}}), + }, + ) + + // sqladmin answers with a `sql#operation` that names the instance but does + // not embed it, so read it back rather than echoing the request. + const embedded = operationResponse(result) + if (embedded) return toResource(embedded) + + const created = await this.get(operationTargetId(result) ?? name) + return created ?? toResource({name, databaseVersion, region, settings: {tier}}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.instancesPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + /** Cheaper than list(): the runtime keeps instance metadata in memory. */ + async health(): Promise { + await this.client.fetch(this.instancesPath(), {method: 'GET'}) + } + + private instancesPath(): string { + return `/sql/v1beta4/projects/${encodeURIComponent(this.client.project)}/instances` + } +} + +function toResource(instance: GcpSqlInstance): CloudResource { + const name = instance.name ?? '' + const primaryIp = instance.ipAddresses?.find((address) => address.type === 'PRIMARY') + + return { + id: name, + name, + cloud: 'gcp', + service: 'database', + type: 'db-instance', + region: instance.region ?? null, + createdAt: instance.createTime ?? null, + status: instance.state ?? null, + engine: instance.databaseVersion ?? null, + version: instance.databaseVersion ?? null, + instanceClass: instance.settings?.tier ?? null, + metadata: { + provider: 'gcp', + databaseService: 'cloud-sql', + project: instance.project, + gceZone: instance.gceZone, + backendType: instance.backendType, + instanceType: instance.instanceType, + connectionName: instance.connectionName, + tier: instance.settings?.tier, + ipAddress: primaryIp?.ipAddress, + port: primaryIp?.port, + ipAddresses: instance.ipAddresses, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidInstanceName(value: string): boolean { + return /^[a-z][a-z0-9-]{0,61}$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts b/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts new file mode 100644 index 0000000..426d287 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts @@ -0,0 +1,199 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpGkeAdapter} from './GcpGkeAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const CLUSTERS_PATH = '/container/v1/projects/floci-local/locations/us-central1/clusters' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpGkeAdapter { + return new GcpGkeAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +/** Shape captured from floci-gcp 0.5.0. */ +function gkeCluster(name: string) { + return { + name, + status: 'RUNNING', + location: 'us-central1', + endpoint: 'localhost:6550', + network: 'default', + subnetwork: 'default', + createTime: '2026-07-28T04:01:57.839066676Z', + currentMasterVersion: '1.30.5-gke.1014001', + currentNodeVersion: '1.30.5-gke.1014001', + initialClusterVersion: '1.30.5-gke.1014001', + nodePools: [{name: 'default-pool', status: 'RUNNING'}], + resourceLabels: {}, + } +} + +/** GKE create answers with an Operation carrying a targetLink, not the cluster. */ +function gkeOperation(clusterName: string) { + return { + name: 'operation-28fabcf7-9df7-44d5-b84a-b59435fd9093', + operationType: 'CREATE_CLUSTER', + status: 'DONE', + zone: 'us-central1', + location: 'us-central1', + targetLink: `projects/floci-local/locations/us-central1/clusters/${clusterName}`, + } +} + +describe('GcpGkeAdapter', () => { + test('identifies itself as the GCP k8s adapter', () => { + const instance = adapter() + expect(instance.cloud).toBe('gcp') + expect(instance.service).toBe('k8s') + expect(instance.schema().displayName).toBe('Google GKE') + }) + + test('talks to the container.googleapis.com path, not the unprefixed one', async () => { + // /v1/projects/{p}/locations/{l}/clusters on this runtime is Managed Service + // for Apache Kafka — same path shape, entirely different resource. Binding + // GKE there would surface Redpanda brokers as Kubernetes clusters. + const calls = stubFetch(() => new Response(JSON.stringify({clusters: []}), {status: 200})) + await adapter().list() + + expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}`) + expect(calls[0]?.url).toContain('/container/v1/') + }) + + test('lists clusters and normalizes the GKE shape', async () => { + stubFetch(() => new Response(JSON.stringify({clusters: [gkeCluster('prod')]}), {status: 200})) + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + id: 'prod', + name: 'prod', + cloud: 'gcp', + service: 'k8s', + type: 'cluster', + region: 'us-central1', + status: 'RUNNING', + version: '1.30.5-gke.1014001', + }) + expect(resource?.metadata.endpoint).toBe('localhost:6550') + expect(resource?.metadata.nodePoolCount).toBe(1) + }) + + test('reduces a fully qualified cluster path to its name', async () => { + stubFetch(() => new Response(JSON.stringify({ + clusters: [{...gkeCluster('prod'), name: 'projects/floci-local/locations/us-central1/clusters/prod'}], + }), {status: 200})) + + const [resource] = await adapter().list() + expect(resource?.id).toBe('prod') + expect(resource?.name).toBe('prod') + }) + + test('normalizes an empty list payload', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + stubFetch(() => new Response( + JSON.stringify({clusters: [gkeCluster('prod'), gkeCluster('staging')]}), + {status: 200}, + )) + + await expect(adapter().list({search: 'prod'})).resolves.toHaveLength(1) + await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a single cluster', async () => { + const calls = stubFetch(() => new Response(JSON.stringify(gkeCluster('prod')), {status: 200})) + const resource = await adapter().get('prod') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`) + expect(resource?.id).toBe('prod') + }) + + test('returns null when the cluster does not exist', async () => { + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'cluster not found'}}), + {status: 404}, + )) + await expect(adapter().get('nope')).resolves.toBeNull() + }) + + test('resolves the operation targetLink into the created cluster', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(gkeOperation('prod')), {status: 200}) + : new Response(JSON.stringify(gkeCluster('prod')), {status: 200}), + ) + + const resource = await adapter().create({values: {clusterName: 'prod'}}) + + // The operation names the cluster only via a path, so it is read back. + expect(resource.id).toBe('prod') + expect(resource.status).toBe('RUNNING') + expect(calls).toHaveLength(2) + expect(calls[1]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`) + }) + + test('sends the cluster body the runtime expects', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(gkeOperation('prod')), {status: 200}) + : new Response(JSON.stringify(gkeCluster('prod')), {status: 200}), + ) + await adapter().create({values: {clusterName: 'prod', initialNodeCount: '3'}}) + + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + cluster: {name: 'prod', initialNodeCount: 3}, + }) + }) + + test('defaults the node count', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(gkeOperation('prod')), {status: 200}) + : new Response(JSON.stringify(gkeCluster('prod')), {status: 200}), + ) + await adapter().create({values: {clusterName: 'prod'}}) + + expect(JSON.parse(String(calls[0]?.init?.body)).cluster.initialNodeCount).toBe(1) + }) + + test('requires a cluster name', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('rejects a name the runtime would refuse', async () => { + stubFetch(() => new Response('{}', {status: 200})) + for (const name of ['1prod', 'Prod', 'has_underscore', 'a'.repeat(41)]) { + await expect(adapter().create({values: {clusterName: name}})).rejects.toBeInstanceOf(ValidationError) + } + }) + + test('deletes a cluster', async () => { + const calls = stubFetch(() => new Response('{}', {status: 200})) + await adapter().delete('prod') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) + + test('surfaces a missing cluster on delete', async () => { + stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'not found'}}), {status: 404})) + await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpGkeAdapter.ts b/packages/api/src/adapter-gcp/GcpGkeAdapter.ts new file mode 100644 index 0000000..eb99b51 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpGkeAdapter.ts @@ -0,0 +1,163 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpGkeSchema} from '../cloud-spi/eksSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import {type GcpOperationEnvelope, operationResponse, operationTargetId} from './operations' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * GKE through the Floci-GCP emulator, which mirrors the public + * `container.googleapis.com` v1 REST API and backs each cluster with a real k3s + * container. Verified against `floci/floci-gcp` 0.5.0: + * + * GET /container/v1/projects/{project}/locations/{location}/clusters + * POST /container/v1/projects/{project}/locations/{location}/clusters + * GET /container/v1/projects/{project}/locations/{location}/clusters/{cluster} + * DELETE /container/v1/projects/{project}/locations/{location}/clusters/{cluster} + * + * Note the `/container/v1` prefix. The runtime also serves + * `/v1/projects/{p}/locations/{l}/clusters`, but that is Managed Service for + * Apache Kafka — same path shape, entirely different resource — so binding GKE + * to the unprefixed path would surface Kafka brokers as Kubernetes clusters. + */ + +interface GkeNodePool { + name?: string + status?: string + initialNodeCount?: number + version?: string +} + +interface GkeCluster { + name?: string + status?: string + location?: string + endpoint?: string + network?: string + subnetwork?: string + createTime?: string + currentMasterVersion?: string + currentNodeVersion?: string + initialClusterVersion?: string + currentNodeCount?: number + nodePools?: GkeNodePool[] + resourceLabels?: Record +} + +interface GkeClusterList { + clusters?: GkeCluster[] +} + +export class GcpGkeAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'k8s' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpGkeSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.clustersPath()) + return filterBySearch((body?.clusters ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const cluster = await this.client.json( + `${this.clustersPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return cluster ? toResource(cluster) : null + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.clusterName ?? input.values.name) + const initialNodeCount = Number(input.values.initialNodeCount ?? 1) + + if (!name) throw new ValidationError('clusterName is required') + if (!isValidClusterName(name)) { + throw new ValidationError( + 'Use a valid GKE cluster name: 1-40 lowercase letters, numbers, or hyphens, starting with a letter.', + ) + } + + const result = await this.client.json | GkeCluster>(this.clustersPath(), { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + cluster: { + name, + initialNodeCount: Number.isFinite(initialNodeCount) ? initialNodeCount : 1, + }, + }), + }) + + // GKE answers with an Operation carrying a targetLink, not the cluster. + const embedded = operationResponse(result) + if (embedded) return toResource(embedded) + + const created = await this.get(operationTargetId(result) ?? name) + return created ?? toResource({name}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.clustersPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + private clustersPath(): string { + const {project, location} = this.client + return `/container/v1/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/clusters` + } +} + +function toResource(cluster: GkeCluster): CloudResource { + // The list response returns bare names while some paths return full paths. + const name = (cluster.name ?? '').split('/').pop() ?? '' + const version = cluster.currentMasterVersion ?? cluster.initialClusterVersion ?? null + + return { + id: name, + name, + cloud: 'gcp', + service: 'k8s', + type: 'cluster', + region: cluster.location ?? null, + createdAt: cluster.createTime ?? null, + status: cluster.status ?? null, + version, + metadata: { + provider: 'gcp', + k8sService: 'gke', + endpoint: cluster.endpoint, + network: cluster.network, + subnetwork: cluster.subnetwork, + currentMasterVersion: cluster.currentMasterVersion, + currentNodeVersion: cluster.currentNodeVersion, + nodePools: cluster.nodePools, + nodePoolCount: cluster.nodePools?.length ?? 0, + currentNodeCount: cluster.currentNodeCount, + labels: cluster.resourceLabels, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidClusterName(value: string): boolean { + return /^[a-z][a-z0-9-]{0,39}$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/GcpPubSubAdapter.test.ts b/packages/api/src/adapter-gcp/GcpPubSubAdapter.test.ts new file mode 100644 index 0000000..dda9bd1 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpPubSubAdapter.test.ts @@ -0,0 +1,119 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpPubSubAdapter} from './GcpPubSubAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const TOPICS_PATH = '/v1/projects/floci-local/topics' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpPubSubAdapter { + return new GcpPubSubAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function topic(name: string) { + return {name: `projects/floci-local/topics/${name}`} +} + +describe('GcpPubSubAdapter', () => { + test('identifies itself as the GCP messaging adapter', () => { + const instance = adapter() + expect(instance.cloud).toBe('gcp') + expect(instance.service).toBe('messaging') + expect(instance.schema().displayName).toBe('Cloud Pub/Sub') + }) + + test('lists topics and reduces the resource path to a short id', async () => { + const calls = stubFetch(() => new Response(JSON.stringify({topics: [topic('events')]}), {status: 200})) + const [resource] = await adapter().list() + + expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}`) + expect(resource).toMatchObject({ + id: 'events', + name: 'events', + cloud: 'gcp', + service: 'messaging', + type: 'topic', + }) + // The full path stays available for anyone who needs to address it. + expect(resource?.metadata.resourcePath).toBe('projects/floci-local/topics/events') + }) + + test('normalizes an empty list payload', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + stubFetch(() => new Response(JSON.stringify({topics: [topic('events'), topic('audit')]}), {status: 200})) + + await expect(adapter().list({search: 'even'})).resolves.toHaveLength(1) + await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a single topic', async () => { + const calls = stubFetch(() => new Response(JSON.stringify(topic('events')), {status: 200})) + const resource = await adapter().get('events') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}/events`) + expect(resource?.id).toBe('events') + }) + + test('returns null when the topic does not exist', async () => { + stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Topic not found'}}), {status: 404})) + await expect(adapter().get('nope')).resolves.toBeNull() + }) + + test('creates with PUT on the resource path, not POST to the collection', async () => { + // Pub/Sub's create verb is PUT; POSTing to /topics would 404 or 405. + const calls = stubFetch(() => new Response(JSON.stringify(topic('events')), {status: 200})) + const resource = await adapter().create({values: {topicName: 'events'}}) + + expect(calls[0]?.init?.method).toBe('PUT') + expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}/events`) + expect(resource.id).toBe('events') + }) + + test('requires a topic name', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('rejects a name Pub/Sub would refuse', async () => { + stubFetch(() => new Response('{}', {status: 200})) + for (const name of ['1starts-with-digit', 'ab', 'has space', 'has/slash']) { + await expect(adapter().create({values: {topicName: name}})).rejects.toBeInstanceOf(ValidationError) + } + }) + + test('accepts the punctuation Pub/Sub allows', async () => { + stubFetch(() => new Response(JSON.stringify(topic('a.b-c_d~e')), {status: 200})) + await expect(adapter().create({values: {topicName: 'a.b-c_d~e'}})).resolves.toBeDefined() + }) + + test('deletes a topic', async () => { + const calls = stubFetch(() => new Response('{}', {status: 200})) + await adapter().delete('events') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}/events`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) + + test('surfaces a missing topic on delete', async () => { + stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Topic not found'}}), {status: 404})) + await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpPubSubAdapter.ts b/packages/api/src/adapter-gcp/GcpPubSubAdapter.ts new file mode 100644 index 0000000..47496a9 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpPubSubAdapter.ts @@ -0,0 +1,127 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpMessagingSchema} from '../cloud-spi/messagingSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Pub/Sub topics as `messaging` resources. Verified against `floci/floci-gcp` + * 0.5.0, which mirrors the public Pub/Sub v1 REST API: + * + * GET /v1/projects/{project}/topics + * PUT /v1/projects/{project}/topics/{topic} (create — not POST) + * GET /v1/projects/{project}/topics/{topic} + * DELETE /v1/projects/{project}/topics/{topic} + * + * Topics are addressed by the fully qualified `projects/{p}/topics/{id}` path; + * the console uses the short id and rebuilds the path per call. + */ + +interface PubSubTopic { + name?: string + labels?: Record + kmsKeyName?: string + messageRetentionDuration?: string +} + +interface PubSubTopicList { + topics?: PubSubTopic[] +} + +export class GcpPubSubAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'messaging' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpMessagingSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.topicsPath()) + return filterBySearch((body?.topics ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const topic = await this.client.json( + `${this.topicsPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return topic ? toResource(topic) : null + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.topicName ?? input.values.name) + if (!name) throw new ValidationError('topicName is required') + if (!isValidTopicName(name)) { + throw new ValidationError( + 'Use a valid Pub/Sub topic name: 3-255 characters starting with a letter, using letters, numbers, or - _ . ~ + %', + ) + } + + // Pub/Sub creates with PUT on the resource path, not POST to a collection. + const topic = await this.client.json(`${this.topicsPath()}/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({}), + }) + + return toResource(topic ?? {name: `${this.projectPrefix()}/topics/${name}`}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.topicsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + private projectPrefix(): string { + return `projects/${this.client.project}` + } + + private topicsPath(): string { + return `/v1/projects/${encodeURIComponent(this.client.project)}/topics` + } +} + +function toResource(topic: PubSubTopic): CloudResource { + const path = topic.name ?? '' + const name = path.split('/').pop() ?? path + + return { + id: name, + name, + cloud: 'gcp', + service: 'messaging', + type: 'topic', + region: null, + createdAt: null, + metadata: { + provider: 'gcp', + messagingService: 'pubsub', + resourcePath: path, + labels: topic.labels, + kmsKeyName: topic.kmsKeyName, + messageRetentionDuration: topic.messageRetentionDuration, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidTopicName(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9\-_.~+%]{2,254}$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/GcpSchedulerAdapter.test.ts b/packages/api/src/adapter-gcp/GcpSchedulerAdapter.test.ts new file mode 100644 index 0000000..24f5cbd --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpSchedulerAdapter.test.ts @@ -0,0 +1,168 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpSchedulerAdapter} from './GcpSchedulerAdapter' +import {GcpRestRuntimeClient} from '../gcp' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const JOBS_PATH = '/v1/projects/floci-local/locations/us-central1/jobs' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpSchedulerAdapter { + return new GcpSchedulerAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function sampleJob(name: string) { + return { + name: `projects/floci-local/locations/us-central1/jobs/${name}`, + description: 'Daily database backup', + schedule: '0 2 * * *', + timeZone: 'UTC', + state: 'ENABLED', + httpTarget: { + uri: 'https://api.floci-local.net/backup', + httpMethod: 'POST', + }, + userUpdateTime: '2026-06-22T05:29:13Z', + } +} + +describe('GcpSchedulerAdapter', () => { + test('lists jobs and maps the resource shape', async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + jobs: [sampleJob('db-backup')], + }), {status: 200})) as unknown as typeof fetch + + const resources = await adapter().list() + expect(resources).toEqual([ + { + id: 'db-backup', + name: 'db-backup', + cloud: 'gcp', + service: 'scheduler', + type: 'scheduler-job', + region: 'us-central1', + createdAt: '2026-06-22T05:29:13Z', + status: 'ENABLED', + metadata: { + provider: 'gcp', + resourceName: 'projects/floci-local/locations/us-central1/jobs/db-backup', + schedule: '0 2 * * *', + timeZone: 'UTC', + target: 'https://api.floci-local.net/backup', + httpMethod: 'POST', + description: 'Daily database backup', + scheduleTime: undefined, + lastAttemptTime: undefined, + userUpdateTime: '2026-06-22T05:29:13Z', + }, + }, + ]) + }) + + test('normalizes an empty list payload', async () => { + globalThis.fetch = (async () => new Response('{}', {status: 200})) as unknown as typeof fetch + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('follows nextPageToken pagination and collects all jobs', async () => { + const calls: string[] = [] + globalThis.fetch = (async (url: RequestInfo | URL) => { + const urlStr = String(url) + calls.push(urlStr) + if (urlStr.includes('pageToken=token-page-2')) { + return new Response(JSON.stringify({ + jobs: [sampleJob('job-page-2')], + }), {status: 200}) + } + return new Response(JSON.stringify({ + jobs: [sampleJob('job-page-1')], + nextPageToken: 'token-page-2', + }), {status: 200}) + }) as unknown as typeof fetch + + const resources = await adapter().list() + expect(calls).toEqual([ + `${ENDPOINT}${JOBS_PATH}`, + `${ENDPOINT}${JOBS_PATH}?pageToken=token-page-2`, + ]) + expect(resources.map((r) => r.name)).toEqual(['job-page-1', 'job-page-2']) + }) + + test('filters across paginated results by search term', async () => { + globalThis.fetch = (async (url: RequestInfo | URL) => { + const urlStr = String(url) + if (urlStr.includes('pageToken=page2')) { + return new Response(JSON.stringify({ + jobs: [sampleJob('nightly-sync')], + }), {status: 200}) + } + return new Response(JSON.stringify({ + jobs: [sampleJob('hourly-cleanup')], + nextPageToken: 'page2', + }), {status: 200}) + }) as unknown as typeof fetch + + const resources = await adapter().list({search: 'sync'}) + expect(resources.map((r) => r.name)).toEqual(['nightly-sync']) + }) + + test('get returns null when the job is not found', async () => { + globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch + await expect(adapter().get('nonexistent')).resolves.toBeNull() + }) + + test('create posts job payload and returns mapped resource', async () => { + const calls: Array<{url: string; init: RequestInit}> = [] + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => { + calls.push({url: String(url), init}) + return new Response(JSON.stringify(sampleJob('db-backup')), {status: 200}) + }) as unknown as typeof fetch + + const resource = await adapter().create({ + values: { + jobName: 'db-backup', + schedule: '0 2 * * *', + timeZone: 'UTC', + targetUri: 'https://api.floci-local.net/backup', + httpMethod: 'POST', + description: 'Daily database backup', + }, + }) + + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe(`${ENDPOINT}${JOBS_PATH}`) + expect(calls[0].init.method).toBe('POST') + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + name: 'projects/floci-local/locations/us-central1/jobs/db-backup', + description: 'Daily database backup', + schedule: '0 2 * * *', + timeZone: 'UTC', + httpTarget: { + uri: 'https://api.floci-local.net/backup', + httpMethod: 'POST', + }, + }) + expect(resource.id).toBe('db-backup') + expect(resource.status).toBe('ENABLED') + expect(resource.metadata.schedule).toBe('0 2 * * *') + }) + + test('create rejects when required fields are missing', async () => { + await expect(adapter().create({values: {jobName: 'test'}})).rejects.toThrow('schedule is required') + }) + + test('delete issues a DELETE against the job path', async () => { + const calls: Array<{url: string; method?: string}> = [] + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => { + calls.push({url: String(url), method: init.method}) + return new Response(JSON.stringify({}), {status: 200}) + }) as unknown as typeof fetch + + await adapter().delete('db-backup') + expect(calls[0].url).toBe(`${ENDPOINT}${JOBS_PATH}/db-backup`) + expect(calls[0].method).toBe('DELETE') + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpSchedulerAdapter.ts b/packages/api/src/adapter-gcp/GcpSchedulerAdapter.ts new file mode 100644 index 0000000..55bb279 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpSchedulerAdapter.ts @@ -0,0 +1,167 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpSchedulerSchema} from '../cloud-spi/schedulerSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Talks to the Floci-GCP emulator's Cloud Scheduler service. The emulator + * mirrors the public Cloud Scheduler v1 REST API: + * + * list GET /v1/projects/{project}/locations/{location}/jobs -> {jobs: [...], nextPageToken?: string} + * get GET /v1/projects/{project}/locations/{location}/jobs/{id} -> Job + * create POST /v1/projects/{project}/locations/{location}/jobs -> Job + * delete DELETE /v1/projects/{project}/locations/{location}/jobs/{id} -> {} + */ +interface GcpSchedulerJob { + name?: string + description?: string + schedule?: string + timeZone?: string + state?: string + httpTarget?: { + uri?: string + httpMethod?: string + headers?: Record + body?: string + } + userUpdateTime?: string + scheduleTime?: string + lastAttemptTime?: string +} + +interface GcpSchedulerJobList { + jobs?: GcpSchedulerJob[] + nextPageToken?: string +} + +export class GcpSchedulerAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'scheduler' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpSchedulerSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const jobs: GcpSchedulerJob[] = [] + let pageToken: string | undefined + + do { + const queryParams = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : '' + const body: GcpSchedulerJobList | null = await this.client.json(`${this.jobsPath()}${queryParams}`) + if (body?.jobs) { + jobs.push(...body.jobs) + } + pageToken = body?.nextPageToken || undefined + } while (pageToken) + + return filterBySearch(jobs.map(toResource), query.search) + } + + async get(id: string): Promise { + const job = await this.client.json( + `${this.jobsPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return job ? toResource(job) : null + } + + async create(input: CreateResourceInput): Promise { + const jobName = stringValue(input.values.jobName ?? input.values.name) + const schedule = stringValue(input.values.schedule) + const timeZone = stringValue(input.values.timeZone) || 'UTC' + const targetUri = stringValue(input.values.targetUri ?? input.values.uri) + const httpMethod = stringValue(input.values.httpMethod) || 'POST' + const description = stringValue(input.values.description) + + if (!jobName) throw new ValidationError('jobName is required') + if (!schedule) throw new ValidationError('schedule is required') + if (!targetUri) throw new ValidationError('targetUri is required') + + const fullName = `projects/${this.client.project}/locations/${this.client.location}/jobs/${jobName}` + + const job = await this.client.json( + this.jobsPath(), + { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + name: fullName, + description, + schedule, + timeZone, + httpTarget: { + uri: targetUri, + httpMethod, + }, + }), + }, + ) + + return toResource(job ?? {name: fullName, schedule, timeZone, description, httpTarget: {uri: targetUri, httpMethod}}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.jobsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + private jobsPath(): string { + return `/v1/projects/${encodeURIComponent(this.client.project)}/locations/${encodeURIComponent(this.client.location)}/jobs` + } +} + +function toResource(job: GcpSchedulerJob): CloudResource { + const name = shortName(job.name ?? '') + const target = job.httpTarget?.uri ?? '' + return { + id: name, + name, + cloud: 'gcp', + service: 'scheduler', + type: 'scheduler-job', + region: locationOf(job.name ?? ''), + createdAt: job.userUpdateTime ?? null, + status: job.state ?? 'ENABLED', + metadata: { + provider: 'gcp', + resourceName: job.name, + schedule: job.schedule, + timeZone: job.timeZone, + target, + httpMethod: job.httpTarget?.httpMethod, + description: job.description, + scheduleTime: job.scheduleTime, + lastAttemptTime: job.lastAttemptTime, + userUpdateTime: job.userUpdateTime, + }, + } +} + +function shortName(resourceName: string): string { + const match = resourceName.match(/jobs\/([^/]+)$/) + return match ? match[1] : resourceName +} + +function locationOf(resourceName: string): string | null { + const match = resourceName.match(/locations\/([^/]+)/) + return match ? match[1] : null +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/adapter-gcp/GcpSecretManagerAdapter.test.ts b/packages/api/src/adapter-gcp/GcpSecretManagerAdapter.test.ts new file mode 100644 index 0000000..dfd5fed --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpSecretManagerAdapter.test.ts @@ -0,0 +1,117 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpSecretManagerAdapter} from './GcpSecretManagerAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const SECRETS_PATH = '/v1/projects/floci-local/secrets' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpSecretManagerAdapter { + return new GcpSecretManagerAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +/** Shape captured from floci-gcp 0.5.0. */ +function secret(name: string) { + return { + name: `projects/floci-local/secrets/${name}`, + createTime: '2026-07-28T04:34:51.773522423Z', + replication: {automatic: {}}, + } +} + +describe('GcpSecretManagerAdapter', () => { + test('identifies itself as the GCP secrets adapter', () => { + const instance = adapter() + expect(instance.cloud).toBe('gcp') + expect(instance.service).toBe('secrets') + expect(instance.schema().displayName).toBe('Secret Manager') + }) + + test('lists secrets and reduces the resource path to a short id', async () => { + stubFetch(() => new Response(JSON.stringify({secrets: [secret('db-password')]}), {status: 200})) + const [resource] = await adapter().list() + + expect(resource).toMatchObject({id: 'db-password', name: 'db-password', type: 'secret', service: 'secrets'}) + expect(resource?.metadata.resourcePath).toBe('projects/floci-local/secrets/db-password') + expect(resource?.metadata.replication).toBe('automatic') + }) + + test('never fetches or exposes a secret payload', async () => { + // Reading a value needs versions/latest:access; the adapter must not. + const calls = stubFetch(() => new Response(JSON.stringify({secrets: [secret('db-password')]}), {status: 200})) + const [resource] = await adapter().list() + + expect(calls.every((c) => !c.url.includes(':access'))).toBe(true) + expect(calls.every((c) => !c.url.includes('/versions'))).toBe(true) + expect(JSON.stringify(resource)).not.toContain('payload') + }) + + test('reports a user-managed replication policy distinctly', async () => { + stubFetch(() => new Response(JSON.stringify({ + secrets: [{...secret('db-password'), replication: {userManaged: {replicas: []}}}], + }), {status: 200})) + + const [resource] = await adapter().list() + expect(resource?.metadata.replication).toBe('user-managed') + }) + + test('normalizes an empty list payload', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + stubFetch(() => new Response(JSON.stringify({secrets: [secret('db-password'), secret('api-key')]}), {status: 200})) + await expect(adapter().list({search: 'api'})).resolves.toHaveLength(1) + }) + + test('returns null when the secret does not exist', async () => { + stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Secret not found'}}), {status: 404})) + await expect(adapter().get('nope')).resolves.toBeNull() + }) + + test('creates with the id as a query parameter and a replication body', async () => { + const calls = stubFetch(() => new Response(JSON.stringify(secret('api-key')), {status: 200})) + const resource = await adapter().create({values: {secretName: 'api-key'}}) + + expect(calls[0]?.init?.method).toBe('POST') + expect(calls[0]?.url).toBe(`${ENDPOINT}${SECRETS_PATH}?secretId=api-key`) + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({replication: {automatic: {}}}) + expect(resource.id).toBe('api-key') + }) + + test('requires a secret name and rejects one the runtime would refuse', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError) + for (const name of ['has space', 'has/slash', 'a'.repeat(256)]) { + await expect(adapter().create({values: {secretName: name}})).rejects.toBeInstanceOf(ValidationError) + } + }) + + test('deletes a secret', async () => { + const calls = stubFetch(() => new Response('{}', {status: 200})) + await adapter().delete('api-key') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${SECRETS_PATH}/api-key`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) + + test('surfaces a missing secret on delete', async () => { + stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Secret not found'}}), {status: 404})) + await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpSecretManagerAdapter.ts b/packages/api/src/adapter-gcp/GcpSecretManagerAdapter.ts new file mode 100644 index 0000000..d515349 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpSecretManagerAdapter.ts @@ -0,0 +1,126 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpSecretsSchema} from '../cloud-spi/secretsSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * GCP Secret Manager metadata as `secrets` resources. Verified against + * `floci/floci-gcp` 0.5.0, which mirrors the public v1 REST API: + * + * GET /v1/projects/{project}/secrets + * POST /v1/projects/{project}/secrets?secretId={id} + * GET /v1/projects/{project}/secrets/{id} + * DELETE /v1/projects/{project}/secrets/{id} + * + * Like the AWS adapter, this never reads a secret payload — that would require + * `secrets/{id}/versions/latest:access`, and the value has no business on a + * cached resource object. + */ + +interface GcpSecret { + name?: string + createTime?: string + labels?: Record + replication?: {automatic?: unknown; userManaged?: unknown} +} + +interface GcpSecretList { + secrets?: GcpSecret[] +} + +export class GcpSecretManagerAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'secrets' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpSecretsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.secretsPath()) + return filterBySearch((body?.secrets ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const secret = await this.client.json( + `${this.secretsPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return secret ? toResource(secret) : null + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.secretName ?? input.values.name) + if (!name) throw new ValidationError('secretName is required') + if (!isValidSecretName(name)) { + throw new ValidationError( + 'Use a valid Secret Manager name: 1-255 letters, numbers, hyphens, or underscores.', + ) + } + + // The id travels as a query parameter; the body carries the replication policy. + const secret = await this.client.json( + `${this.secretsPath()}?secretId=${encodeURIComponent(name)}`, + { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({replication: {automatic: {}}}), + }, + ) + + return toResource(secret ?? {name: `projects/${this.client.project}/secrets/${name}`}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.secretsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + private secretsPath(): string { + return `/v1/projects/${encodeURIComponent(this.client.project)}/secrets` + } +} + +function toResource(secret: GcpSecret): CloudResource { + const path = secret.name ?? '' + const name = path.split('/').pop() ?? path + + return { + id: name, + name, + cloud: 'gcp', + service: 'secrets', + type: 'secret', + region: null, + createdAt: secret.createTime ?? null, + metadata: { + provider: 'gcp', + secretsService: 'secretmanager', + resourcePath: path, + replication: secret.replication?.userManaged ? 'user-managed' : 'automatic', + labels: secret.labels, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidSecretName(value: string): boolean { + return /^[A-Za-z0-9_-]{1,255}$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/GcpStorageAdapter.ts b/packages/api/src/adapter-gcp/GcpStorageAdapter.ts new file mode 100644 index 0000000..f6d36ed --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpStorageAdapter.ts @@ -0,0 +1,204 @@ +import {NotFoundError, ValidationError} from '../cloud-spi/errors' +import {gcpStorageSchema} from '../cloud-spi/storageSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, + StorageObject, + StorageObjectDownload, + StorageObjectList, +} from '../cloud-spi/types' + +interface GcpBucket { + id?: string + name?: string + location?: string + timeCreated?: string + updated?: string + storageClass?: string +} + +interface GcpObject { + name?: string + bucket?: string + size?: string + updated?: string + timeCreated?: string + contentType?: string + storageClass?: string + etag?: string +} + +export class GcpStorageAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'storage' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + private get project(): string { + return this.client.project + } + + schema(): ServiceSchema { + return gcpStorageSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json<{items?: GcpBucket[]}>(`/storage/v1/b?project=${encodeURIComponent(this.project)}`) + return filterBySearch((body?.items ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const bucket = await this.client.json( + `/storage/v1/b/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return bucket ? toResource(bucket) : null + } + + async create(input: CreateResourceInput): Promise { + const bucketName = stringValue(input.values.bucketName) + if (!bucketName) throw new ValidationError('bucketName is required') + if (!isValidBucketName(bucketName)) { + throw new ValidationError('Use a valid GCS bucket name: 3-63 lowercase characters, numbers, dots, underscores, or hyphens.') + } + const body = await this.client.json(`/storage/v1/b?project=${encodeURIComponent(this.project)}`, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: bucketName}), + }) + return toResource(body ?? {name: bucketName}) + } + + async delete(id: string): Promise { + await this.client.fetch(`/storage/v1/b/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + async listObjects(resourceId: string, prefix = ''): Promise { + const qs = new URLSearchParams({delimiter: '/'}) + if (prefix) qs.set('prefix', prefix) + const body = await this.client.json<{items?: GcpObject[]; prefixes?: string[]}>(`/storage/v1/b/${encodeURIComponent(resourceId)}/o?${qs}`) + return { + prefix, + objects: [ + ...(body?.prefixes ?? []).map((key): StorageObject => ({ + key, + name: objectName(key, prefix), + type: 'folder', + size: null, + lastModified: null, + metadata: { + provider: 'gcp', + storageService: 'cloud-storage', + prefix: key, + }, + })), + ...(body?.items ?? []) + .filter((item) => item.name && item.name !== prefix) + .map((item): StorageObject => ({ + key: item.name ?? '', + name: objectName(item.name ?? '', prefix), + type: 'object', + size: numberValue(item.size), + lastModified: item.updated ?? item.timeCreated ?? null, + metadata: { + provider: 'gcp', + storageService: 'cloud-storage', + contentType: item.contentType, + storageClass: item.storageClass, + etag: item.etag, + }, + })), + ], + } + } + + async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise { + const path = `/upload/storage/v1/b/${encodeURIComponent(resourceId)}/o?uploadType=media&name=${encodeURIComponent(key)}` + await this.client.fetch(path, { + method: 'POST', + headers: {'content-type': contentType}, + body: copyBytes(body), + }) + } + + async getObject(resourceId: string, key: string): Promise { + const res = await this.client.fetch( + `/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}?alt=media`, + {method: 'GET'}, + ) + if (!res) throw new NotFoundError(`Object ${key} not found in bucket ${resourceId}`) + return { + body: await res.arrayBuffer(), + contentType: res.headers.get('content-type') ?? 'application/octet-stream', + contentLength: numberValue(res.headers.get('content-length')), + } + } + + async deleteObject(resourceId: string, key: string): Promise { + await this.client.fetch(`/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) + } + + async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise { + const destBucket = destResourceId ?? srcResourceId + await this.client.fetch( + `/storage/v1/b/${encodeURIComponent(srcResourceId)}/o/${encodeURIComponent(srcKey)}/copyTo/b/${encodeURIComponent(destBucket)}/o/${encodeURIComponent(destKey)}`, + {method: 'POST'}, + ) + } + +} + +function toResource(bucket: GcpBucket): CloudResource { + const name = bucket.name ?? bucket.id ?? '' + return { + id: name, + name, + cloud: 'gcp', + service: 'storage', + type: 'bucket', + region: bucket.location ?? null, + createdAt: bucket.timeCreated ?? null, + metadata: { + provider: 'gcp', + storageService: 'cloud-storage', + storageClass: bucket.storageClass, + updated: bucket.updated, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function objectName(key: string, prefix: string): string { + const relative = key.startsWith(prefix) ? key.slice(prefix.length) : key + return relative.replace(/\/$/, '') || key +} + +function numberValue(value: string | null | undefined): number | null { + if (!value) return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +function copyBytes(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return copy.buffer +} + +function isValidBucketName(value: string): boolean { + return /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/operations.ts b/packages/api/src/adapter-gcp/operations.ts new file mode 100644 index 0000000..207108f --- /dev/null +++ b/packages/api/src/adapter-gcp/operations.ts @@ -0,0 +1,62 @@ +/** + * Google's REST APIs answer mutations with a long-running-operation envelope + * rather than the resource, and the shape differs per service. All three are + * present on the local runtime: + * + * - Cloud Functions / Cloud Run: `{done, response: }` + * - Cloud SQL (`sql#operation`): `{status: 'DONE', targetId: }` + * - GKE: `{status: 'DONE', operationType: 'CREATE_CLUSTER', targetLink: }` + * + * Only the first embeds the resource; the others name it and expect a re-read. + * These helpers keep that discrimination in one place instead of each adapter + * guessing whether it received a resource or a receipt for one. + */ + +export interface GcpOperationEnvelope { + kind?: string + /** Cloud Functions / GKE / Cloud Run. */ + done?: boolean + response?: T + /** Cloud SQL. */ + status?: string + targetId?: string + operationType?: string + /** GKE: a resource path whose last segment is the name. */ + targetLink?: string + error?: unknown +} + +/** True when the payload is an operation receipt rather than the resource. */ +export function isOperationEnvelope(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false + const envelope = payload as GcpOperationEnvelope + return ( + envelope.kind?.endsWith('#operation') === true || + typeof envelope.done === 'boolean' || + (typeof envelope.status === 'string' && typeof envelope.operationType === 'string') + ) +} + +/** + * Unwrap an embedded resource. Returns null when the operation carries only a + * reference, in which case the caller should read the resource back by name. + */ +export function operationResponse(payload: GcpOperationEnvelope | T | null): T | null { + if (!payload) return null + if (!isOperationEnvelope(payload)) return payload as T + + const envelope = payload as GcpOperationEnvelope + return envelope.response ?? null +} + +/** + * The resource name an operation acted on, when it reports one. Accepts either a + * bare id (Cloud SQL) or a resource path (GKE), returning the final segment. + */ +export function operationTargetId(payload: GcpOperationEnvelope | T | null): string | null { + if (!payload || !isOperationEnvelope(payload)) return null + const envelope = payload as GcpOperationEnvelope + if (envelope.targetId) return envelope.targetId + if (envelope.targetLink) return envelope.targetLink.split('/').pop() ?? null + return null +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts new file mode 100644 index 0000000..97a57d4 --- /dev/null +++ b/packages/api/src/aws.ts @@ -0,0 +1,133 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { LambdaClient } from "@aws-sdk/client-lambda"; +import { EKSClient } from "@aws-sdk/client-eks"; +import { EC2Client } from "@aws-sdk/client-ec2"; +import { RDSClient } from "@aws-sdk/client-rds"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; +import { SSMClient } from "@aws-sdk/client-ssm"; +import { KMSClient } from "@aws-sdk/client-kms"; +import { SFNClient } from "@aws-sdk/client-sfn"; +import { ElasticLoadBalancingV2Client } from "@aws-sdk/client-elastic-load-balancing-v2"; +import { EventBridgeClient } from "@aws-sdk/client-eventbridge"; +import { SQSClient } from "@aws-sdk/client-sqs"; +import { IAMClient } from "@aws-sdk/client-iam"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { APIGatewayClient } from "@aws-sdk/client-api-gateway"; +import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; +import { AppConfigClient } from "@aws-sdk/client-appconfig"; + +const endpoint = process.env.FLOCI_ENDPOINT; +const region = process.env.AWS_REGION || "us-east-1"; + +// Floci derives the AWS account from AWS_ACCESS_KEY_ID: a value that is exactly +// 12 digits is used verbatim as the account id, and resources are isolated per +// account. Any other key (e.g. "test", "AKIA...") falls back to +// FLOCI_DEFAULT_ACCOUNT_ID (000000000000). We lean on that contract to scope the +// console to an account: the access key *is* the account id and the secret can +// be any non-empty string (Floci does not validate it). +const DEFAULT_ACCOUNT_ID = process.env.FLOCI_DEFAULT_ACCOUNT_ID || "000000000000"; +const SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "floci"; + +export function isAccountId(value: string | null | undefined): value is string { + return typeof value === "string" && /^\d{12}$/.test(value); +} + +/** The account used when a request does not (validly) specify one. */ +export function defaultAccountId(): string { + const envKey = process.env.AWS_ACCESS_KEY_ID; + return isAccountId(envKey) ? envKey : DEFAULT_ACCOUNT_ID; +} + +/** Normalize a caller-supplied account id, falling back to the default. */ +export function resolveAccountId(raw?: string | null): string { + return isAccountId(raw) ? raw : defaultAccountId(); +} + +export type AwsClients = { + s3: S3Client; + lambda: LambdaClient; + eks: EKSClient; + ec2: EC2Client; + rds: RDSClient; + secretsManager: SecretsManagerClient; + logs: CloudWatchLogsClient; + ssm: SSMClient; + kms: KMSClient; + sfn: SFNClient; + elbv2: ElasticLoadBalancingV2Client; + eventbridge: EventBridgeClient; + sqs: SQSClient; + iam: IAMClient; + dynamodb: DynamoDBClient; + apiGateway: APIGatewayClient; + cloudformation: CloudFormationClient; + appConfig: AppConfigClient; +}; + +export type AwsClientName = keyof AwsClients; + +const clientCache = new Map(); + +function buildClients(accountId: string): AwsClients { + const base = { + region, + credentials: { accessKeyId: accountId, secretAccessKey: SECRET_ACCESS_KEY }, + ...(endpoint ? { endpoint, forcePathStyle: true } : {}), + }; + return { + s3: new S3Client({ ...base, forcePathStyle: true }), + lambda: new LambdaClient(base), + eks: new EKSClient(base), + ec2: new EC2Client(base), + rds: new RDSClient(base), + secretsManager: new SecretsManagerClient(base), + logs: new CloudWatchLogsClient(base), + ssm: new SSMClient(base), + kms: new KMSClient(base), + sfn: new SFNClient(base), + elbv2: new ElasticLoadBalancingV2Client(base), + eventbridge: new EventBridgeClient(base), + sqs: new SQSClient(base), + iam: new IAMClient(base), + dynamodb: new DynamoDBClient(base), + apiGateway: new APIGatewayClient(base), + cloudformation: new CloudFormationClient(base), + appConfig: new AppConfigClient(base), + }; +} + +/** + * Return the cached AWS SDK client set bound to a given account. Clients are + * cheap to build but each holds connection state, so we memoize one set per + * resolved account id. + */ +export function awsClientsForAccount(accountId?: string | null): AwsClients { + const id = resolveAccountId(accountId); + let clients = clientCache.get(id); + if (!clients) { + clients = buildClients(id); + clientCache.set(id, clients); + } + return clients; +} + +// Default-account singletons, kept for surfaces that are not yet account-aware: +// the dedicated EC2, EKS, RDS and Secrets Manager routes. These ignore the +// x-floci-account-id header and always serve the default account, so they do not +// follow the console's active account. The Cloud Explorer proxy resolves clients +// per request via awsClientsForAccount() instead. +export const awsClients = awsClientsForAccount(defaultAccountId()); + +export const s3 = awsClients.s3; +export const lambda = awsClients.lambda; +export const eks = awsClients.eks; +export const ec2 = awsClients.ec2; +export const rds = awsClients.rds; +export const secretsManager = awsClients.secretsManager; +export const eventbridge = awsClients.eventbridge; +export const sqs = awsClients.sqs; +export const iam = awsClients.iam; +export const dynamodb = awsClients.dynamodb; +export const apiGateway = awsClients.apiGateway; +export const cloudformation = awsClients.cloudformation; diff --git a/packages/api/src/azure.test.ts b/packages/api/src/azure.test.ts new file mode 100644 index 0000000..35db2cb --- /dev/null +++ b/packages/api/src/azure.test.ts @@ -0,0 +1,38 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {AzureRestRuntimeClient} from './azure' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('AzureRestRuntimeClient', () => { + test('can omit the Blob Storage API version header', async () => { + let requestHeaders: HeadersInit | undefined + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + requestHeaders = init?.headers + return new Response('{}') + }) as unknown as typeof fetch + + const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1') + + await client.fetch('/devstoreaccount1-keyvault/secrets', {method: 'GET'}, { + includeStorageApiVersion: false, + }) + + expect(new Headers(requestHeaders).has('x-ms-version')).toBe(false) + }) + + test('adds endpoint context to network failures', async () => { + globalThis.fetch = (async () => { + throw new Error('connection refused') + }) as unknown as typeof fetch + + const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1') + + await expect(client.fetch('/container?restype=container', {method: 'PUT'})).rejects.toThrow( + 'Cannot reach Floci-AZ at http://localhost:4577: connection refused', + ) + }) +}) diff --git a/packages/api/src/azure.ts b/packages/api/src/azure.ts new file mode 100644 index 0000000..a141f57 --- /dev/null +++ b/packages/api/src/azure.ts @@ -0,0 +1,80 @@ +import {RuntimeUnavailableError, httpStatusToCloudError} from './cloud-spi/errors' + +export interface AzureRuntimeFetchOptions { + emptyOnNotFound?: boolean + includeStorageApiVersion?: boolean +} + +export interface AzureRuntimeClient { + readonly endpoint: string + readonly accountName: string + fetch(path: string, init: RequestInit, options?: AzureRuntimeFetchOptions): Promise +} + +export class AzureRestRuntimeClient implements AzureRuntimeClient { + constructor( + readonly endpoint: string = azureEndpoint(), + readonly accountName: string = azureAccountName(), + ) {} + + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}): Promise { + let res: Response + try { + res = await globalThis.fetch(`${this.endpoint}${path}`, { + ...init, + headers: { + ...(options.includeStorageApiVersion === false ? {} : {'x-ms-version': '2021-12-02'}), + ...(init.headers ?? {}), + }, + }) + } catch (error) { + throw new RuntimeUnavailableError( + `Cannot reach Floci-AZ at ${this.endpoint}: ${errorMessage(error)}`, + {cause: error}, + ) + } + + if (options.emptyOnNotFound && res.status === 404) return null + if (!res.ok) { + const detail = await safeResponseText(res) + // A 501 here is the runtime declaring the operation missing (e.g. floci-az + // has no /functions), which must surface as such rather than a bare 502. + throw httpStatusToCloudError( + res.status, + `Azure runtime request failed: HTTP ${res.status} ${path}${detail ? ` - ${detail}` : ''}`, + ) + } + + return res + } +} + +export function azureEndpoint(): string { + return process.env.FLOCI_AZURE_ENDPOINT ?? process.env.FLOCI_AZ_ENDPOINT ?? 'http://localhost:4577' +} + +export function azureAccountName(): string { + return process.env.FLOCI_AZURE_ACCOUNT_NAME ?? 'devstoreaccount1' +} + +export function azureSubscriptionId(): string { + return process.env.FLOCI_AZURE_SUBSCRIPTION_ID ?? '00000000-0000-0000-0000-000000000000' +} + +export function azureResourceGroup(): string { + return process.env.FLOCI_AZURE_RESOURCE_GROUP ?? 'floci-local' +} + +export const azure = new AzureRestRuntimeClient() + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function safeResponseText(res: Response): Promise { + try { + return (await res.text()).trim().slice(0, 500) + } catch { + return '' + } +} diff --git a/packages/api/src/cloud-spi/apigatewaySchema.ts b/packages/api/src/cloud-spi/apigatewaySchema.ts new file mode 100644 index 0000000..b9a133c --- /dev/null +++ b/packages/api/src/cloud-spi/apigatewaySchema.ts @@ -0,0 +1,49 @@ +import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types' + +const resourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create API', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete API', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function awsApiGatewaySchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'apigateway', + displayName: 'AWS API Gateway', + fields: [ + { + name: 'name', + label: 'API Name', + type: 'text', + required: true, + description: '1-1024 characters.', + validation: { + minLength: 1, + maxLength: 1024, + message: 'API name is required and must be 1-1024 characters.', + }, + }, + { + name: 'description', + label: 'Description', + type: 'text', + required: false, + description: 'Optional description for the REST API.', + validation: { + maxLength: 1024, + message: 'Description must be 1024 characters or fewer.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: {resourceActions}, + filters: [{name: 'search', label: 'Search', type: 'text', required: false}], + columns: [ + {name: 'name', label: 'API Name'}, + {name: 'type', label: 'Type'}, + {name: 'createdAt', label: 'Created At'}, + ], + } +} diff --git a/packages/api/src/cloud-spi/appConfigSchema.ts b/packages/api/src/cloud-spi/appConfigSchema.ts new file mode 100644 index 0000000..419e778 --- /dev/null +++ b/packages/api/src/cloud-spi/appConfigSchema.ts @@ -0,0 +1,36 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const applicationColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'}, + {name: 'id', label: 'Application Id'}, +] + +const applicationFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +export function awsAppConfigSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'configuration', + displayName: 'AppConfig', + fields: [ + { + name: 'name', + label: 'Application Name', + type: 'text', + required: true, + validation: { + pattern: '^[A-Za-z0-9._\\-]+$', + maxLength: 255, + message: 'Use letters, numbers, periods, hyphens, or underscores.', + }, + }, + {name: 'description', label: 'Description', type: 'textarea', required: false}, + ], + actions: ['list', 'create', 'delete', 'inspect'], + filters: applicationFilters, + columns: applicationColumns, + } +} diff --git a/packages/api/src/cloud-spi/childCapabilities.test.ts b/packages/api/src/cloud-spi/childCapabilities.test.ts new file mode 100644 index 0000000..62de787 --- /dev/null +++ b/packages/api/src/cloud-spi/childCapabilities.test.ts @@ -0,0 +1,115 @@ +import {describe, expect, test} from 'bun:test' +import {checkChildCapabilities} from './childCapabilities' +import type {ChildCollection, ChildItem, CollectionPage, DocumentStoreAdapter} from './childCollections' +import type {CloudServiceAdapter, ServiceSchema} from './types' + +const emptyCollections: CollectionPage = {items: [], nextCursor: null} +const emptyItems: CollectionPage = {items: [], nextCursor: null} + +function schemaWith(capabilities: ServiceSchema['capabilities']): ServiceSchema { + return { + cloud: 'aws', + service: 'storage', + displayName: 'test', + fields: [], + actions: ['list'], + capabilities, + filters: [], + columns: [], + } +} + +function adapterWith( + capabilities: ServiceSchema['capabilities'], + documents?: DocumentStoreAdapter, + items?: CloudServiceAdapter['items'], +): CloudServiceAdapter { + return { + cloud: 'aws', + service: 'storage', + schema: () => schemaWith(capabilities), + list: async () => [], + get: async () => null, + create: async () => { + throw new Error('unused') + }, + delete: async () => {}, + documents, + items, + } +} + +const listOnlyDocuments: DocumentStoreAdapter = { + listCollections: async () => emptyCollections, + listItems: async () => emptyItems, +} + +describe('checkChildCapabilities', () => { + test('an adapter with no child store and no child capabilities is consistent', () => { + expect(checkChildCapabilities(adapterWith(undefined))).toEqual([]) + }) + + // CloudWatch Logs: streams are creatable, events are strictly read-only. + test('a read-only leaf is a legitimate declared state', () => { + const adapter = adapterWith( + { + collectionActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + }, + listOnlyDocuments, + ) + expect(checkChildCapabilities(adapter)).toEqual([]) + }) + + test('claiming an unimplemented capability is a violation', () => { + const adapter = adapterWith( + { + collectionActions: [{name: 'create', label: 'Create', enabled: true, status: 'available'}], + itemActions: [], + }, + listOnlyDocuments, + ) + expect(checkChildCapabilities(adapter)).toEqual([ + 'collectionActions.create is available but documents.createCollection is missing', + 'documents.listCollections is implemented but collectionActions.list is not advertised', + 'documents.listItems is implemented but itemActions.list is not advertised', + ]) + }) + + test('an implemented write that is never advertised is a violation', () => { + const adapter = adapterWith( + { + collectionActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + }, + {...listOnlyDocuments, deleteCollection: async () => {}}, + ) + expect(checkChildCapabilities(adapter)).toEqual([ + 'documents.deleteCollection is implemented but collectionActions.delete is not advertised', + ]) + }) + + test('declaring capabilities with no child store at all is a violation', () => { + const adapter = adapterWith({ + itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + }) + expect(checkChildCapabilities(adapter)).toEqual([ + 'itemActions are declared but neither documents nor items is implemented', + ]) + }) + + // The routes cannot decide which shape `/resources/:id/items` addresses. + test('declaring both stores is a violation', () => { + const adapter = adapterWith( + { + collectionActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + itemActions: [{name: 'list', label: 'List', enabled: true, status: 'available'}], + }, + listOnlyDocuments, + {listItems: async () => emptyItems}, + ) + expect(checkChildCapabilities(adapter)).toContain( + 'declares both documents and items; a store is either nested or flat', + ) + }) +}) diff --git a/packages/api/src/cloud-spi/childCapabilities.ts b/packages/api/src/cloud-spi/childCapabilities.ts new file mode 100644 index 0000000..f84dda2 --- /dev/null +++ b/packages/api/src/cloud-spi/childCapabilities.ts @@ -0,0 +1,89 @@ +/** + * Keeps a child store and its advertised capabilities honest in both + * directions: nothing claimed that is not implemented, nothing implemented that + * is not claimed. + * + * Lives apart from cloudProxy.test.ts so the logic is unit-testable against + * fakes rather than only against whatever adapters happen to be registered. + */ + +import type {CollectionActionName, ItemActionName} from './childCollections' +import type {CloudServiceAdapter} from './types' + +const COLLECTION_METHODS: Record = { + list: 'listCollections', + create: 'createCollection', + delete: 'deleteCollection', +} + +const ITEM_METHODS: Record = { + list: 'listItems', + get: 'getItem', + put: 'putItem', + delete: 'deleteItem', + query: 'queryItems', +} + +export function checkChildCapabilities(adapter: CloudServiceAdapter): string[] { + const violations: string[] = [] + const capabilities = adapter.schema().capabilities + const collectionActions = capabilities?.collectionActions + const itemActions = capabilities?.itemActions + const store = adapter.documents ?? adapter.items + const storeName = adapter.documents ? 'documents' : 'items' + + if (adapter.documents && adapter.items) { + violations.push('declares both documents and items; a store is either nested or flat') + } + + if (!store) { + if (collectionActions) { + violations.push('collectionActions are declared but neither documents nor items is implemented') + } + if (itemActions) { + violations.push('itemActions are declared but neither documents nor items is implemented') + } + return violations + } + + // Collection-level verbs only exist on the nested shape. + if (adapter.documents) { + violations.push( + ...compare('collectionActions', 'documents', COLLECTION_METHODS, collectionActions ?? [], adapter.documents), + ) + } else if (collectionActions?.length) { + violations.push('collectionActions are declared but the store is flat') + } + + violations.push(...compare('itemActions', storeName, ITEM_METHODS, itemActions ?? [], store)) + + return violations +} + +function compare( + capabilityField: string, + storeName: string, + methods: Record, + declared: Array<{name: string; status: string}>, + store: object, +): string[] { + const violations: string[] = [] + const implemented = (method: string) => typeof (store as Record)[method] === 'function' + + for (const capability of declared) { + if (capability.status !== 'available') continue + const method = methods[capability.name] + if (!implemented(method)) { + violations.push(`${capabilityField}.${capability.name} is available but ${storeName}.${method} is missing`) + } + } + + const advertised = new Set(declared.map((capability) => capability.name)) + for (const [action, method] of Object.entries(methods)) { + if (implemented(method) && !advertised.has(action)) { + violations.push(`${storeName}.${method} is implemented but ${capabilityField}.${action} is not advertised`) + } + } + + return violations +} diff --git a/packages/api/src/cloud-spi/childCollections.test.ts b/packages/api/src/cloud-spi/childCollections.test.ts new file mode 100644 index 0000000..8be2bdd --- /dev/null +++ b/packages/api/src/cloud-spi/childCollections.test.ts @@ -0,0 +1,25 @@ +import {describe, expect, test} from 'bun:test' +import {ValidationError} from './errors' +import {DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, clampLimit} from './childCollections' + +describe('clampLimit', () => { + test('defaults when the caller sends nothing', () => { + expect(clampLimit(undefined)).toBe(DEFAULT_PAGE_LIMIT) + expect(clampLimit('')).toBe(DEFAULT_PAGE_LIMIT) + }) + + test('accepts a value inside the range', () => { + expect(clampLimit('25')).toBe(25) + expect(clampLimit(String(MAX_PAGE_LIMIT))).toBe(MAX_PAGE_LIMIT) + }) + + // Rejecting rather than silently clamping: a truncated page that looks + // complete is the failure mode this contract exists to avoid. + test('rejects out-of-range and non-numeric values', () => { + expect(() => clampLimit('0')).toThrow(ValidationError) + expect(() => clampLimit('-1')).toThrow(ValidationError) + expect(() => clampLimit(String(MAX_PAGE_LIMIT + 1))).toThrow(ValidationError) + expect(() => clampLimit('abc')).toThrow(ValidationError) + expect(() => clampLimit('1.5')).toThrow(ValidationError) + }) +}) diff --git a/packages/api/src/cloud-spi/childCollections.ts b/packages/api/src/cloud-spi/childCollections.ts new file mode 100644 index 0000000..da61d65 --- /dev/null +++ b/packages/api/src/cloud-spi/childCollections.ts @@ -0,0 +1,91 @@ +/** + * Vendor-neutral child collections. + * + * Two shapes, because the services that need this genuinely differ in depth: a + * CloudWatch log group holds streams which hold events (two levels), while a + * DynamoDB table simply is the collection (flat). Collapsing them into one + * interface would put a "which depth am I" branch in every implementer. + * + * `ChildItem.body` is where the vendor payload lands — a log event, a Cosmos + * document, a DynamoDB item — so no vendor concept reaches this contract. + */ + +import {ValidationError} from './errors' +import type {CreateResourceInput} from './types' + +export const DEFAULT_PAGE_LIMIT = 100 +export const MAX_PAGE_LIMIT = 1000 + +export interface CollectionPage { + items: T[] + /** + * Opaque continuation token; null when the page is the last one. Never + * round-trip a cursor to a different resource or collection. + */ + nextCursor: string | null +} + +export interface PageQuery { + cursor?: string + limit?: number +} + +/** The middle level: a log stream, a Cosmos container, a BigQuery table. */ +export interface ChildCollection { + id: string + name: string + parentId: string + createdAt: string | null + metadata: Record +} + +/** The leaf: a log event, a Cosmos document, a DynamoDB item. */ +export interface ChildItem { + id: string + /** null for flat stores, where the resource is the collection. */ + collectionId: string | null + timestamp: string | null + body: Record + metadata: Record +} + +export type CollectionActionName = 'list' | 'create' | 'delete' +export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' + +/** Two-level store: resource → collections → items. */ +export interface DocumentStoreAdapter { + listCollections(resourceId: string, page?: PageQuery): Promise> + createCollection?(resourceId: string, input: CreateResourceInput): Promise + deleteCollection?(resourceId: string, collectionId: string): Promise + listItems(resourceId: string, collectionId: string, page?: PageQuery): Promise> + getItem?(resourceId: string, collectionId: string, itemId: string): Promise + putItem?(resourceId: string, collectionId: string, body: Record): Promise + deleteItem?(resourceId: string, collectionId: string, itemId: string, partitionKey?: string | null): Promise + queryItems?(resourceId: string, collectionId: string, query: string): Promise> +} + +/** Flat store: the resource is the collection. */ +export interface ItemStoreAdapter { + listItems(resourceId: string, page?: PageQuery): Promise> + getItem?(resourceId: string, itemId: string): Promise + putItem?(resourceId: string, body: Record): Promise + deleteItem?(resourceId: string, itemId: string, partitionKey?: string | null): Promise + queryItems?(resourceId: string, query: string): Promise> +} + +/** + * Parse `?limit=`. Out-of-range values raise rather than clamp, so a caller + * asking for 5000 rows learns the ceiling instead of receiving 1000 that look + * like the whole set. + */ +export function clampLimit(raw: string | undefined): number { + if (raw === undefined || raw === '') return DEFAULT_PAGE_LIMIT + if (!/^\d+$/.test(raw)) { + throw new ValidationError(`limit must be a whole number between 1 and ${MAX_PAGE_LIMIT}.`) + } + const value = Number(raw) + if (value < 1 || value > MAX_PAGE_LIMIT) { + throw new ValidationError(`limit must be between 1 and ${MAX_PAGE_LIMIT}.`) + } + return value +} diff --git a/packages/api/src/cloud-spi/cloudformationSchema.ts b/packages/api/src/cloud-spi/cloudformationSchema.ts new file mode 100644 index 0000000..ee65391 --- /dev/null +++ b/packages/api/src/cloud-spi/cloudformationSchema.ts @@ -0,0 +1,59 @@ +import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' + +const cloudformationColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Stack Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + +const cloudformationFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const cloudformationResourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List stacks', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create stack', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete stack', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect stack', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function awsCloudformationSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'iac', + displayName: 'CloudFormation', + fields: [ + { + name: 'stackName', + label: 'Stack Name', + type: 'text', + required: true, + description: 'Must start with a letter. Letters, numbers, and hyphens, up to 128 characters.', + validation: { + pattern: '^[a-zA-Z][a-zA-Z0-9\\-]{0,127}$', + maxLength: 128, + message: 'Use a valid stack name: start with a letter, then letters, numbers, or hyphens.', + }, + }, + { + name: 'templateBody', + label: 'Template Body', + type: 'text', + required: true, + span: true, + description: 'Single-line CloudFormation template JSON — the console form has no multi-line editor yet.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: cloudformationResourceActions, + }, + filters: cloudformationFilters, + columns: cloudformationColumns, + } +} + +export function cloudformationSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsCloudformationSchema() + return null +} diff --git a/packages/api/src/cloud-spi/computeSchema.ts b/packages/api/src/cloud-spi/computeSchema.ts new file mode 100644 index 0000000..78941cd --- /dev/null +++ b/packages/api/src/cloud-spi/computeSchema.ts @@ -0,0 +1,248 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const computeColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'type', label: 'Type'}, + {name: 'status', label: 'State'}, + {name: 'region', label: 'AZ'}, + {name: 'createdAt', label: 'Created'}, +] + +const computeFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const computeFields: FieldSchema[] = [ + // ── Required ────────────────────────────────────────────────────── + { + name: 'name', + label: 'Instance Name', + type: 'text', + required: true, + span: true, + group: 'Required', + validation: {minLength: 1, maxLength: 255, message: 'Provide a name for the instance.'}, + }, + { + name: 'imageId', + label: 'AMI ID', + type: 'text', + required: true, + description: 'e.g. ami-0abcdef1234567890', + validation: { + pattern: '^ami-[0-9a-f]{8,17}$', + message: 'Must be a valid AMI ID (ami-xxxxxxxxx).', + }, + }, + { + name: 'instanceType', + label: 'Instance Type', + type: 'select', + required: true, + options: [ + {label: 't2.micro', value: 't2.micro'}, + {label: 't2.small', value: 't2.small'}, + {label: 't2.medium', value: 't2.medium'}, + {label: 't3.micro', value: 't3.micro'}, + {label: 't3.small', value: 't3.small'}, + {label: 't3.medium', value: 't3.medium'}, + {label: 't3.large', value: 't3.large'}, + {label: 't3.xlarge', value: 't3.xlarge'}, + {label: 'm5.large', value: 'm5.large'}, + {label: 'm5.xlarge', value: 'm5.xlarge'}, + {label: 'c5.large', value: 'c5.large'}, + {label: 'c5.xlarge', value: 'c5.xlarge'}, + ], + }, + // ── Networking (optional) ───────────────────────────────────────── + { + name: 'keyName', + label: 'Key Pair', + type: 'text', + required: false, + group: 'Networking — optional', + description: 'Name of an existing EC2 key pair.', + }, + { + name: 'subnetId', + label: 'Subnet ID', + type: 'text', + required: false, + description: 'e.g. subnet-0abcdef1234567890', + }, + { + name: 'securityGroupIds', + label: 'Security Group IDs', + type: 'text', + required: false, + span: true, + description: 'One or more SG IDs separated by commas — e.g. sg-111, sg-222', + }, +] + +/** + * Azure VM images, keyed by the label offered in the create form. + * + * The ARM API wants a four-part `imageReference`; asking a user for four fields to + * launch a VM is worse than a short list of known-good images. Shared with the + * adapter so the form and the request cannot drift. + */ +export const AZURE_VM_IMAGES = { + 'Ubuntu 20.04 LTS': {publisher: 'Canonical', offer: 'UbuntuServer', sku: '20.04-LTS', version: 'latest'}, + 'Ubuntu 22.04 LTS': { + publisher: 'Canonical', + offer: '0001-com-ubuntu-server-jammy', + sku: '22_04-lts', + version: 'latest', + }, + 'Debian 11': {publisher: 'Debian', offer: 'debian-11', sku: '11', version: 'latest'}, + 'Windows Server 2022': { + publisher: 'MicrosoftWindowsServer', + offer: 'WindowsServer', + sku: '2022-datacenter', + version: 'latest', + }, +} as const + +export const AZURE_VM_SIZES = [ + 'Standard_B1s', + 'Standard_B2s', + 'Standard_D2s_v3', + 'Standard_D4s_v3', + 'Standard_F2s_v2', +] as const + +export const AZURE_LOCATIONS = ['eastus', 'eastus2', 'westus', 'westus2', 'westeurope', 'northeurope'] as const + +/** Azure VM names: 1-64 chars, letters, digits, hyphens, underscores and periods. */ +export const AZURE_VM_NAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$' + +export function azureComputeSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'compute', + displayName: 'Azure Virtual Machines', + fields: [ + { + name: 'name', + label: 'VM Name', + type: 'text', + required: true, + span: true, + group: 'Required', + validation: { + pattern: AZURE_VM_NAME_PATTERN, + minLength: 1, + maxLength: 64, + message: 'Start with a letter or digit; letters, digits, hyphens, underscores and periods only.', + }, + }, + { + name: 'resourceGroup', + label: 'Resource Group', + type: 'text', + required: true, + group: 'Required', + description: 'Must already exist. Every Azure resource lives in a resource group.', + }, + { + name: 'vmSize', + label: 'VM Size', + type: 'select', + required: true, + options: AZURE_VM_SIZES.map((value) => ({label: value, value})), + }, + { + name: 'image', + label: 'Image', + type: 'select', + required: true, + options: Object.keys(AZURE_VM_IMAGES).map((value) => ({label: value, value})), + }, + { + name: 'location', + label: 'Location', + type: 'select', + required: false, + group: 'Optional', + description: "The VM's own location. Defaults to eastus.", + options: AZURE_LOCATIONS.map((value) => ({label: value, value})), + }, + { + name: 'adminUsername', + label: 'Admin Username', + type: 'text', + required: false, + group: 'Optional', + description: 'Defaults to azureuser.', + }, + ], + actions: ['list', 'inspect', 'create', 'delete'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create VM', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete VM', enabled: true, status: 'available', runtimeRequired: true}, + // The runtime and the adapter both do these — verified against + // floci-az, where powerOff/start/restart really change power state. + // There is no generic actions route yet (only invoke/objects), so + // the console cannot reach them; saying `coming_soon` with the + // reason beats advertising a control that cannot be called. + { + name: 'start', + label: 'Start', + enabled: false, + status: 'coming_soon', + reason: 'The adapter implements start, but the generic resource actions route is not wired yet.', + runtimeRequired: true, + }, + { + name: 'stop', + label: 'Stop', + enabled: false, + status: 'coming_soon', + reason: 'The adapter implements stop, but the generic resource actions route is not wired yet.', + runtimeRequired: true, + }, + { + name: 'reboot', + label: 'Restart', + enabled: false, + status: 'coming_soon', + reason: 'The adapter implements reboot, but the generic resource actions route is not wired yet.', + runtimeRequired: true, + }, + ], + }, + filters: computeFilters, + columns: [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Power State'}, + {name: 'instanceClass', label: 'Size'}, + {name: 'region', label: 'Location'}, + {name: 'resourceGroup', label: 'Resource Group', path: 'metadata.resourceGroup'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, + ], + } +} + +export function awsComputeSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'compute', + displayName: 'Compute', + fields: computeFields, + actions: ['list', 'inspect', 'create', 'delete'], + filters: computeFilters, + columns: computeColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Launch instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Terminate', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + } +} diff --git a/packages/api/src/cloud-spi/containersSchema.ts b/packages/api/src/cloud-spi/containersSchema.ts new file mode 100644 index 0000000..fdf071d --- /dev/null +++ b/packages/api/src/cloud-spi/containersSchema.ts @@ -0,0 +1,69 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const containerColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Service'}, + {name: 'status', label: 'State', format: 'badge'}, + {name: 'image', label: 'Image', path: 'metadata.image', format: 'code', emptyText: '—'}, + {name: 'uri', label: 'URL', path: 'metadata.uri', emptyText: '—'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, +] + +const containerFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +/** Cloud Run service names: lowercase letters, digits and hyphens, max 49 chars. */ +export const CLOUD_RUN_NAME_PATTERN = '^[a-z]([-a-z0-9]*[a-z0-9])?$' +export const CLOUD_RUN_NAME_MAX_LENGTH = 49 + +export function gcpContainersSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'containers', + displayName: 'Cloud Run', + fields: [ + { + name: 'name', + label: 'Service Name', + type: 'text', + required: true, + validation: { + pattern: CLOUD_RUN_NAME_PATTERN, + minLength: 1, + maxLength: CLOUD_RUN_NAME_MAX_LENGTH, + message: 'Start with a letter; lowercase letters, digits and hyphens only.', + }, + }, + { + name: 'image', + label: 'Container Image', + type: 'text', + required: true, + span: true, + // The most common cause of a service that never becomes ready. + description: + 'The container must listen on the port given by the PORT environment variable (8080 by default). An image that listens elsewhere will report a failed condition.', + }, + { + name: 'port', + label: 'Container Port', + type: 'text', + required: false, + description: 'Overrides the port passed to the container. Defaults to 8080.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List services', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Deploy service', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete service', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect service', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: containerFilters, + columns: containerColumns, + } +} + +export function containersSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'gcp' ? gcpContainersSchema() : null +} diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts new file mode 100644 index 0000000..c2f2cfd --- /dev/null +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -0,0 +1,328 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const databaseColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status'}, + {name: 'engine', label: 'Engine'}, + {name: 'version', label: 'Version'}, + {name: 'instanceClass', label: 'Class'}, +] + +/** Cloud SQL reports a connection endpoint, which RDS does not surface here. */ +const cloudSqlColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'engine', label: 'Version'}, + {name: 'region', label: 'Region'}, + {name: 'instanceClass', label: 'Tier'}, + {name: 'connectionName', label: 'Connection', path: 'metadata.connectionName', format: 'code'}, +] + +const databaseFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +export function awsDatabaseSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'database', + displayName: 'AWS RDS', + fields: [ + { + name: 'dbInstanceIdentifier', + label: 'DB Instance Identifier', + type: 'text', + required: true, + validation: { + pattern: '^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$', + minLength: 1, + maxLength: 63, + }, + }, + { + name: 'engine', + label: 'Engine', + type: 'select', + required: true, + defaultValue: 'postgres', + options: [ + {label: 'PostgreSQL', value: 'postgres'}, + {label: 'MySQL', value: 'mysql'}, + {label: 'MariaDB', value: 'mariadb'}, + ], + }, + { + name: 'masterUserPassword', + label: 'Master User Password', + type: 'password', + required: true, + span: true, + validation: {minLength: 8, maxLength: 128}, + }, + { + name: 'dbInstanceClass', + label: 'DB Instance Class', + type: 'text', + required: false, + defaultValue: 'db.t3.micro', + }, + { + name: 'allocatedStorage', + label: 'Allocated Storage', + type: 'text', + required: false, + defaultValue: '20', + validation: {pattern: '^[1-9][0-9]*$'}, + }, + { + name: 'masterUsername', + label: 'Master Username', + type: 'text', + required: false, + defaultValue: 'root', + validation: {maxLength: 63}, + }, + { + name: 'dbName', + label: 'Database Name', + type: 'text', + required: false, + defaultValue: '', + validation: {maxLength: 64}, + }, + { + name: 'engineVersion', + label: 'Engine Version', + type: 'text', + required: false, + defaultValue: '', + }, + { + name: 'securityGroupIds', + label: 'Security Group IDs', + type: 'text', + required: false, + span: true, + description: 'One or more SG IDs separated by commas — e.g. sg-111, sg-222', + }, + ], + actions: ['list', 'create', 'update', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List DB instances', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create DB instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'update', label: 'Update DB instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete DB instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect DB instance', enabled: true, status: 'available', runtimeRequired: true}, + ], + databaseActions: [ + { + name: 'listSnapshots', + label: 'List DB snapshots', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + { + name: 'createSnapshot', + label: 'Create DB snapshot', + enabled: true, + status: 'partial', + reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.', + runtimeRequired: true, + }, + ], + }, + filters: databaseFilters, + columns: databaseColumns, + updateFields: [ + { + name: 'masterUserPassword', + label: 'Master Password', + type: 'password', + required: false, + span: true, + description: 'Leave blank to keep the current password. Class, storage, engine, and version are omitted because the current emulator does not support modifying them.', + validation: {minLength: 8, maxLength: 128}, + }, + { + name: 'enableIamDatabaseAuthentication', + label: 'IAM Database Authentication', + type: 'select', + required: false, + valuePath: 'metadata.iamDatabaseAuthenticationEnabled', + description: 'Leave unchanged to keep current setting.', + options: [ + {label: 'True', value: 'true'}, + {label: 'False', value: 'false'}, + ], + }, + { + name: 'dbSubnetGroupName', + label: 'DB Subnet Group', + type: 'text', + required: false, + valuePath: 'metadata.subnetGroup.name', + description: 'Leave unchanged or blank to keep current DB subnet group.', + }, + { + name: 'vpcSecurityGroupIds', + label: 'VPC Security Group IDs', + type: 'text', + required: false, + valuePath: 'metadata.vpcSecurityGroupIds', + description: 'Comma-separated security group IDs. Leave unchanged or blank to keep current security groups.', + }, + { + name: 'optionGroupName', + label: 'Option Group', + type: 'text', + required: false, + valuePath: 'metadata.optionGroupName', + description: 'Leave unchanged or blank to keep current option group.', + }, + { + name: 'autoMinorVersionUpgrade', + label: 'Auto Minor Version Upgrade', + type: 'select', + required: false, + valuePath: 'metadata.autoMinorVersionUpgrade', + description: 'Leave unchanged to keep current setting.', + options: [ + {label: 'True', value: 'true'}, + {label: 'False', value: 'false'}, + ], + }, + ], + } +} + +export function azureDatabaseSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'database', + displayName: 'Azure Databases', + fields: [ + { + name: 'engine', + label: 'Database Engine', + type: 'select', + required: true, + defaultValue: 'azure-sql', + options: [ + {label: 'Azure SQL Database', value: 'azure-sql'}, + {label: 'Azure Database for PostgreSQL', value: 'postgresql'}, + ], + }, + { + name: 'serverName', + label: 'Server Name', + type: 'text', + required: true, + validation: { + minLength: 1, + maxLength: 63, + pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', + message: 'Use 1-63 lowercase letters, numbers, or hyphens.', + }, + }, + { + name: 'location', + label: 'Location', + type: 'text', + required: true, + defaultValue: 'eastus', + }, + { + name: 'administratorLogin', + label: 'Administrator Login', + type: 'text', + required: true, + defaultValue: 'sa', + }, + { + name: 'administratorLoginPassword', + label: 'Administrator Password', + type: 'password', + required: true, + span: true, + description: 'Required by the local database runtime. The value is not returned by the API.', + validation: { + minLength: 8, + message: 'Use at least 8 characters.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List database servers', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create database server', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete database server', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: databaseFilters, + columns: [ + {name: 'name', label: 'Name'}, + {name: 'engine', label: 'Engine'}, + {name: 'status', label: 'Status'}, + {name: 'region', label: 'Region'}, + ], + } +} + +export function gcpDatabaseSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'database', + displayName: 'Cloud SQL', + fields: [ + { + name: 'instanceName', + label: 'Instance Name', + type: 'text', + required: true, + description: 'Lowercase letters, numbers, and hyphens; must start with a letter.', + }, + { + name: 'databaseVersion', + label: 'Database Version', + type: 'select', + required: false, + // The runtime backs instances with real Postgres containers and + // rejects every other engine, so this is not the full GCP list. + description: 'The local runtime supports PostgreSQL only.', + options: [ + {label: 'PostgreSQL 15', value: 'POSTGRES_15'}, + {label: 'PostgreSQL 16', value: 'POSTGRES_16'}, + ], + }, + { + name: 'region', + label: 'Region', + type: 'text', + required: false, + description: 'Defaults to us-central1.', + }, + { + name: 'tier', + label: 'Machine Tier', + type: 'text', + required: false, + description: 'Defaults to db-f1-micro.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: databaseFilters, + columns: cloudSqlColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List instances', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect instance', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, + } +} diff --git a/packages/api/src/cloud-spi/dynamodbSchema.ts b/packages/api/src/cloud-spi/dynamodbSchema.ts new file mode 100644 index 0000000..6297e1e --- /dev/null +++ b/packages/api/src/cloud-spi/dynamodbSchema.ts @@ -0,0 +1,90 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const dynamodbColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Table'}, + {name: 'status', label: 'Status'}, + {name: 'region', label: 'Region'}, + {name: 'createdAt', label: 'Created At'}, +] + +const dynamodbFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const attributeTypeOptions = [ + {label: 'String', value: 'S'}, + {label: 'Number', value: 'N'}, + {label: 'Binary', value: 'B'}, +] + +export function awsDynamoDbSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'nosql', + displayName: 'DynamoDB', + fields: [ + { + name: 'tableName', + label: 'Table Name', + type: 'text', + required: true, + description: '3-255 characters. Letters, numbers, underscores, dots, and hyphens.', + validation: { + pattern: '^(?:[A-Za-z0-9_.]|-){3,255}$', + minLength: 3, + maxLength: 255, + message: 'Use a valid DynamoDB table name: 3-255 letters, numbers, underscores, dots, or hyphens.', + }, + }, + { + name: 'partitionKey', + label: 'Partition Key', + type: 'text', + required: true, + group: 'Primary key', + validation: { + minLength: 1, + maxLength: 255, + message: 'Partition key must contain 1-255 characters.', + }, + }, + { + name: 'partitionKeyType', + label: 'Partition Key Type', + type: 'select', + required: true, + options: attributeTypeOptions, + }, + { + name: 'sortKey', + label: 'Sort Key', + type: 'text', + required: false, + description: 'Optional range key.', + validation: { + minLength: 1, + maxLength: 255, + message: 'Sort key must contain 1-255 characters.', + }, + }, + { + name: 'sortKeyType', + label: 'Sort Key Type', + type: 'select', + required: false, + options: attributeTypeOptions, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List tables', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create table', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect table', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: dynamodbFilters, + columns: dynamodbColumns, + } +} diff --git a/packages/api/src/cloud-spi/eksSchema.ts b/packages/api/src/cloud-spi/eksSchema.ts new file mode 100644 index 0000000..e488c99 --- /dev/null +++ b/packages/api/src/cloud-spi/eksSchema.ts @@ -0,0 +1,102 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const eksColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status'}, + {name: 'version', label: 'Version'}, + {name: 'createdAt', label: 'Created At'}, +] + +/** GKE reports an API endpoint and node pools, which the EKS list does not. */ +const gkeColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'version', label: 'Version'}, + {name: 'region', label: 'Location'}, + {name: 'endpoint', label: 'Endpoint', path: 'metadata.endpoint', format: 'code'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + +const eksFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +export function awsEksSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'k8s', + displayName: 'AWS EKS', + fields: [], + actions: ['list', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List clusters', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect cluster', enabled: true, status: 'available', runtimeRequired: false}, + ], + kubernetesActions: [ + {name: 'listNodegroups', label: 'List nodegroups', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createNodegroup', label: 'Create nodegroup', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deleteNodegroup', label: 'Delete nodegroup', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'listFargateProfiles', label: 'List Fargate profiles', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createFargateProfile', label: 'Create Fargate profile', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deleteFargateProfile', label: 'Delete Fargate profile', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: eksFilters, + columns: eksColumns, + } +} + +export function azureAksSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'k8s', + displayName: 'Azure AKS', + fields: [], + actions: ['list', 'inspect'], + filters: eksFilters, + columns: eksColumns, + } +} + +export function gcpGkeSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'k8s', + displayName: 'Google GKE', + fields: [ + { + name: 'clusterName', + label: 'Cluster Name', + type: 'text', + required: true, + description: 'Lowercase letters, numbers, and hyphens; must start with a letter.', + }, + { + name: 'initialNodeCount', + label: 'Initial Node Count', + type: 'text', + required: false, + description: 'Defaults to 1. The local runtime backs the cluster with a single k3s container.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: eksFilters, + columns: gkeColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List clusters', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create cluster', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete cluster', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect cluster', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, + } +} + +export function k8sSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsEksSchema() + if (cloud === 'azure') return azureAksSchema() + if (cloud === 'gcp') return gcpGkeSchema() + return null +} diff --git a/packages/api/src/cloud-spi/emailSchema.ts b/packages/api/src/cloud-spi/emailSchema.ts new file mode 100644 index 0000000..70d295b --- /dev/null +++ b/packages/api/src/cloud-spi/emailSchema.ts @@ -0,0 +1,36 @@ +import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' + +const emailColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Subject'}, + {name: 'source', label: 'From', path: 'metadata.source', format: 'code'}, + {name: 'to', label: 'To', path: 'metadata.toAddresses', format: 'list'}, + {name: 'messageType', label: 'Type', path: 'metadata.messageType', format: 'badge'}, + {name: 'createdAt', label: 'Captured At', format: 'datetime'}, +] + +const emailFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const emailResourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List captured emails', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect email content', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function awsSesEmailSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'email', + displayName: 'SES Mailbox', + fields: [], + actions: ['list', 'inspect'], + capabilities: {resourceActions: emailResourceActions}, + filters: emailFilters, + columns: emailColumns, + } +} + +export function emailSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsSesEmailSchema() + return null +} diff --git a/packages/api/src/cloud-spi/errors.test.ts b/packages/api/src/cloud-spi/errors.test.ts new file mode 100644 index 0000000..1995be9 --- /dev/null +++ b/packages/api/src/cloud-spi/errors.test.ts @@ -0,0 +1,134 @@ +import {describe, expect, test} from 'bun:test' +import { + AccessDeniedError, + type CloudError, + type CloudErrorCode, + type CloudErrorStatus, + ConflictError, + NotFoundError, + NotImplementedByRuntimeError, + NotSupportedError, + RateLimitedError, + RuntimeError, + RuntimeUnavailableError, + ValidationError, + httpStatusToCloudError, + isUnreachableCause, + toHttpError, +} from './errors' + +describe('CloudError wire shape', () => { + test('keeps the generic label in message and the raw text in detail', () => { + const body = new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577').toBody() + + expect(body.code).toBe('runtime_unavailable') + expect(body.message).toBe('Runtime unavailable') + expect(body.error).toBe(body.message) + expect(body.detail).toBe('Cannot reach Floci-AZ at http://localhost:4577') + }) + + test('omits detail when it would duplicate the message', () => { + // ValidationError intentionally uses its own message as the label, so a + // field-level complaint reaches the UI verbatim instead of being generified. + const body = new ValidationError('bucketName is required').toBody() + + expect(body.message).toBe('bucketName is required') + expect(body.detail).toBeUndefined() + }) + + test('preserves the cause chain for debugging', () => { + const cause = new Error('socket hang up') + expect(new RuntimeError('wrapped', {cause}).cause).toBe(cause) + }) + + const statuses: Array<[CloudError, CloudErrorStatus, CloudErrorCode]> = [ + [new ValidationError('x'), 400, 'invalid_request'], + [new AccessDeniedError('x'), 403, 'access_denied'], + [new NotFoundError('x'), 404, 'resource_not_found'], + [new ConflictError('x'), 409, 'resource_conflict'], + [new RateLimitedError('x'), 429, 'rate_limited'], + [new NotSupportedError('x'), 501, 'operation_not_supported'], + [new NotImplementedByRuntimeError('x'), 501, 'operation_not_implemented'], + [new RuntimeError('x'), 502, 'runtime_error'], + [new RuntimeUnavailableError('x'), 503, 'runtime_unavailable'], + ] + + for (const [error, status, code] of statuses) { + test(`${error.name} carries ${status}/${code}`, () => { + expect(error.status).toBe(status) + expect(error.code).toBe(code) + expect(toHttpError(error).status).toBe(status) + }) + } +}) + +describe('httpStatusToCloudError', () => { + const cases: Array<[number, CloudErrorStatus, CloudErrorCode]> = [ + [400, 400, 'invalid_request'], + [401, 403, 'access_denied'], + [403, 403, 'access_denied'], + [404, 404, 'resource_not_found'], + [409, 409, 'resource_conflict'], + [429, 429, 'rate_limited'], + [501, 501, 'operation_not_implemented'], + [502, 503, 'runtime_unavailable'], + [503, 503, 'runtime_unavailable'], + [504, 503, 'runtime_unavailable'], + [418, 502, 'runtime_error'], + ] + + for (const [runtimeStatus, expectedStatus, expectedCode] of cases) { + test(`HTTP ${runtimeStatus} becomes ${expectedStatus}`, () => { + const error = httpStatusToCloudError(runtimeStatus, `HTTP ${runtimeStatus}`) + expect(error.status).toBe(expectedStatus) + expect(error.code).toBe(expectedCode) + }) + } +}) + +describe('isUnreachableCause', () => { + test('detects a nested transport failure code', () => { + const inner = Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}) + expect(isUnreachableCause(new Error('fetch failed', {cause: inner}))).toBe(true) + }) + + test('detects an aborted or timed-out request by name', () => { + const timeout = new Error('timed out') + timeout.name = 'TimeoutError' + expect(isUnreachableCause(timeout)).toBe(true) + }) + + test('does not treat an ordinary error as unreachable', () => { + expect(isUnreachableCause(new Error('bucket already exists'))).toBe(false) + expect(isUnreachableCause('not an error')).toBe(false) + }) +}) + +describe('toHttpError', () => { + test('defaults an unknown failure to 502 rather than guessing', () => { + const {status, body} = toHttpError(new Error('something odd happened')) + + expect(status).toBe(502) + expect(body.code).toBe('runtime_error') + expect(body.detail).toBe('something odd happened') + }) + + test('promotes a transport failure to 503 without any message matching', () => { + const inner = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:4566'), {code: 'ECONNREFUSED'}) + expect(toHttpError(new Error('fetch failed', {cause: inner})).status).toBe(503) + }) + + test('lets a vendor mapper win over the generic fallback', () => { + const {status} = toHttpError(new Error('vendor specific'), () => new ConflictError('already exists')) + expect(status).toBe(409) + }) + + test('a CloudError outranks the vendor mapper', () => { + const {status} = toHttpError(new NotFoundError('missing'), () => new ConflictError('nope')) + expect(status).toBe(404) + }) + + test('handles a non-Error throw', () => { + expect(toHttpError('boom').status).toBe(502) + }) +}) diff --git a/packages/api/src/cloud-spi/errors.ts b/packages/api/src/cloud-spi/errors.ts new file mode 100644 index 0000000..f7a871f --- /dev/null +++ b/packages/api/src/cloud-spi/errors.ts @@ -0,0 +1,183 @@ +/** + * Typed errors for the cloud SPI. + * + * Adapters throw these instead of bare `Error`s so `routes/clouds.ts` can map a + * failure to an HTTP status without pattern-matching on message text. The wire + * shape is deliberately unchanged from the hand-rolled mapper it replaces: + * `message` carries a generic label and `detail` carries the raw runtime text. + */ + +export type CloudErrorCode = + | 'invalid_request' + | 'access_denied' + | 'resource_not_found' + | 'resource_conflict' + | 'rate_limited' + | 'operation_not_supported' + | 'operation_not_implemented' + | 'runtime_unavailable' + | 'runtime_error' + | 'cosmos_nosql_unavailable' + +export type CloudErrorStatus = 400 | 403 | 404 | 409 | 429 | 501 | 502 | 503 + +export interface CloudErrorBody { + /** Mirrors `message`; kept for clients that read `error`. */ + error: string + code: string + message: string + detail?: string +} + +export abstract class CloudError extends Error { + abstract readonly status: CloudErrorStatus + abstract readonly code: CloudErrorCode + + /** + * Generic, user-facing label. `message` holds the raw runtime text and is + * surfaced as `detail`, so a label change never leaks adapter internals. + */ + protected abstract readonly label: string + + constructor(message: string, options?: {cause?: unknown}) { + super(message, options?.cause === undefined ? undefined : {cause: options.cause}) + this.name = new.target.name + } + + toBody(): CloudErrorBody { + const message = this.label + const detail = this.message + return { + error: message, + code: this.code, + message, + ...(detail && detail !== message ? {detail} : {}), + } + } +} + +/** 400 — the caller sent something unusable. The raw message is the label. */ +export class ValidationError extends CloudError { + readonly status = 400 as const + readonly code = 'invalid_request' as const + protected get label(): string { + return this.message + } +} + +export class AccessDeniedError extends CloudError { + readonly status = 403 as const + readonly code = 'access_denied' as const + protected readonly label = 'Access denied by the runtime' +} + +export class NotFoundError extends CloudError { + readonly status = 404 as const + readonly code = 'resource_not_found' as const + protected readonly label = 'Resource not found' +} + +export class ConflictError extends CloudError { + readonly status = 409 as const + readonly code = 'resource_conflict' as const + protected readonly label = 'Resource already exists or is in use' +} + +export class RateLimitedError extends CloudError { + readonly status = 429 as const + readonly code = 'rate_limited' as const + protected readonly label = 'Runtime is throttling requests' +} + +/** 501 — this adapter does not implement the operation at all. */ +export class NotSupportedError extends CloudError { + readonly status = 501 as const + readonly code = 'operation_not_supported' as const + protected readonly label = 'Operation is not supported by this adapter' +} + +/** 501 — the adapter supports it but the local runtime answered NotImplemented. */ +export class NotImplementedByRuntimeError extends CloudError { + readonly status = 501 as const + readonly code = 'operation_not_implemented' as const + protected readonly label = 'Operation is not implemented by the selected runtime' +} + +export class RuntimeUnavailableError extends CloudError { + readonly status = 503 as const + readonly code = 'runtime_unavailable' as const + protected readonly label = 'Runtime unavailable' +} + +export class RuntimeError extends CloudError { + readonly status = 502 as const + readonly code = 'runtime_error' as const + protected readonly label = 'Runtime request failed' +} + +/** Transport-level failure codes that mean "nothing is listening over there". */ +const UNREACHABLE_CAUSE_CODES = new Set([ + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', +]) + +/** + * Map a runtime HTTP status onto the matching `CloudError`. Shared by the Azure + * and GCP REST clients so both report a 404 or a 501 the same way. + */ +export function httpStatusToCloudError(status: number, message: string, options?: {cause?: unknown}): CloudError { + if (status === 400) return new ValidationError(message, options) + if (status === 401 || status === 403) return new AccessDeniedError(message, options) + if (status === 404) return new NotFoundError(message, options) + if (status === 409) return new ConflictError(message, options) + if (status === 429) return new RateLimitedError(message, options) + if (status === 501) return new NotImplementedByRuntimeError(message, options) + if (status === 502 || status === 503 || status === 504) return new RuntimeUnavailableError(message, options) + return new RuntimeError(message, options) +} + +/** True when the failure is a transport error rather than an HTTP response. */ +export function isUnreachableCause(err: unknown): boolean { + if (!(err instanceof Error)) return false + if (err.name === 'TimeoutError' || err.name === 'AbortError') return true + + let cause: unknown = err + for (let depth = 0; depth < 5 && cause instanceof Error; depth += 1) { + const code = (cause as {code?: unknown}).code + if (typeof code === 'string' && UNREACHABLE_CAUSE_CODES.has(code)) return true + cause = (cause as {cause?: unknown}).cause + } + return false +} + +/** + * The single place adapter failures become HTTP responses. + * + * `mapVendorError` lets a provider contribute SDK-specific knowledge (see + * `adapter-aws/awsErrors.ts`) without `cloud-spi` importing any vendor package. + */ +export function toHttpError( + err: unknown, + mapVendorError?: (err: unknown) => CloudError | null, +): {status: CloudErrorStatus; body: CloudErrorBody} { + const mapped = resolveCloudError(err, mapVendorError) + return {status: mapped.status, body: mapped.toBody()} +} + +function resolveCloudError(err: unknown, mapVendorError?: (err: unknown) => CloudError | null): CloudError { + if (err instanceof CloudError) return err + + const vendor = mapVendorError?.(err) + if (vendor) return vendor + + const message = err instanceof Error ? err.message : 'Runtime request failed' + if (isUnreachableCause(err)) return new RuntimeUnavailableError(message, {cause: err}) + + return new RuntimeError(message, {cause: err}) +} diff --git a/packages/api/src/cloud-spi/eventbridgeSchema.ts b/packages/api/src/cloud-spi/eventbridgeSchema.ts new file mode 100644 index 0000000..16fcbf0 --- /dev/null +++ b/packages/api/src/cloud-spi/eventbridgeSchema.ts @@ -0,0 +1,38 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const eventBusColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'type', label: 'Resource Type'}, + {name: 'region', label: 'Region'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + +const eventBusFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +export function awsEventBridgeSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'events', + displayName: 'EventBridge', + fields: [ + { + name: 'name', + label: 'Event Bus Name', + type: 'text', + required: true, + description: 'Letters, numbers, periods, hyphens, and underscores.', + validation: { + pattern: '^[A-Za-z0-9._\\-]+$', + maxLength: 256, + message: 'Use letters, numbers, periods, hyphens, or underscores.', + }, + }, + {name: 'description', label: 'Description', type: 'text', required: false}, + ], + actions: ['list', 'create', 'delete', 'inspect'], + filters: eventBusFilters, + columns: eventBusColumns, + } +} diff --git a/packages/api/src/cloud-spi/iamSchema.ts b/packages/api/src/cloud-spi/iamSchema.ts new file mode 100644 index 0000000..0847013 --- /dev/null +++ b/packages/api/src/cloud-spi/iamSchema.ts @@ -0,0 +1,57 @@ +import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types' + +export const IAM_USER_NAME_PATTERN = '^[A-Za-z0-9_+=,.@-]{1,64}$' +export const IAM_USER_NAME_MESSAGE = 'Use a valid IAM user name: 1-64 letters, numbers, and +=,.@_- characters.' +export const IAM_PATH_PATTERN = '^/(?:[!-~]+/)?$' +export const IAM_PATH_MAX_LENGTH = 512 +export const IAM_PATH_MESSAGE = 'Use a valid IAM path: begin and end with / and use only printable ASCII characters.' + +const resourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect user', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function awsIamSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'identity', + displayName: 'AWS IAM users', + fields: [ + { + name: 'userName', + label: 'User Name', + type: 'text', + required: true, + description: '1-64 letters, numbers, and +=,.@_- characters.', + validation: { + pattern: IAM_USER_NAME_PATTERN, + minLength: 1, + maxLength: 64, + message: IAM_USER_NAME_MESSAGE, + }, + }, + { + name: 'path', + label: 'Path', + type: 'text', + required: false, + description: 'Optional IAM path, beginning and ending with /. Defaults to /.', + validation: { + pattern: IAM_PATH_PATTERN, + maxLength: IAM_PATH_MAX_LENGTH, + message: IAM_PATH_MESSAGE, + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: {resourceActions}, + filters: [{name: 'search', label: 'Search', type: 'text', required: false}], + columns: [ + {name: 'name', label: 'User Name'}, + {name: 'type', label: 'Type'}, + {name: 'createdAt', label: 'Created At'}, + ], + } +} diff --git a/packages/api/src/cloud-spi/kmsSchema.ts b/packages/api/src/cloud-spi/kmsSchema.ts new file mode 100644 index 0000000..7a445ea --- /dev/null +++ b/packages/api/src/cloud-spi/kmsSchema.ts @@ -0,0 +1,105 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +/** + * A KMS key has no name — only an opaque uuid and an optional description — so + * the description carries the identity in the table and in search. + */ +const kmsColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Key ID', format: 'code'}, + {name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'}, + {name: 'status', label: 'State', format: 'badge'}, + {name: 'keyUsage', label: 'Usage', path: 'metadata.keyUsage'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, +] + +const kmsFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +/** Offered on create. Kept in sync with the adapter's validation. */ +export const KMS_KEY_USAGES = ['ENCRYPT_DECRYPT', 'SIGN_VERIFY', 'GENERATE_VERIFY_MAC'] as const +export const KMS_KEY_SPECS = [ + 'SYMMETRIC_DEFAULT', + 'RSA_2048', + 'RSA_4096', + 'ECC_NIST_P256', + 'HMAC_256', +] as const + +/** + * Which specs each usage can actually be created with, and the spec to use when + * the caller picks a usage but no spec. + * + * KMS rejects a mismatched pair — a SIGN_VERIFY key cannot be SYMMETRIC_DEFAULT, + * and only HMAC specs can generate MACs. The local runtime is more permissive and + * returns 200 for pairs real KMS refuses, so this table, not the runtime, is the + * authority. The first entry of each list is the default. + */ +export const KMS_SPECS_BY_USAGE = { + ENCRYPT_DECRYPT: ['SYMMETRIC_DEFAULT', 'RSA_2048', 'RSA_4096'], + SIGN_VERIFY: ['RSA_2048', 'RSA_4096', 'ECC_NIST_P256'], + GENERATE_VERIFY_MAC: ['HMAC_256'], +} as const satisfies Record<(typeof KMS_KEY_USAGES)[number], readonly (typeof KMS_KEY_SPECS)[number][]> + +/** KMS caps a key description at 8192 characters. */ +export const KMS_DESCRIPTION_MAX_LENGTH = 8192 + +export function awsKmsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'kms', + displayName: 'AWS KMS', + fields: [ + { + name: 'description', + label: 'Description', + type: 'text', + required: false, + description: 'How this key is used. A key has no name, so this is how you will recognise it.', + span: true, + validation: { + maxLength: KMS_DESCRIPTION_MAX_LENGTH, + message: `Keep the description under ${KMS_DESCRIPTION_MAX_LENGTH} characters.`, + }, + }, + { + name: 'keyUsage', + label: 'Key Usage', + type: 'select', + required: false, + options: KMS_KEY_USAGES.map((value) => ({label: value, value})), + }, + { + name: 'keySpec', + label: 'Key Spec', + type: 'select', + required: false, + description: + 'Must match the key usage: symmetric or RSA to encrypt, RSA or ECC to sign, HMAC to generate MACs. Left unset, a valid spec is chosen for the usage.', + options: KMS_KEY_SPECS.map((value) => ({label: value, value})), + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List keys', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create key', enabled: true, status: 'available', runtimeRequired: true}, + { + name: 'delete', + label: 'Schedule key deletion', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + {name: 'inspect', label: 'Inspect key', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'encrypt', label: 'Encrypt plaintext', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'decrypt', label: 'Decrypt ciphertext', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'updateTags', label: 'Edit tags', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: kmsFilters, + columns: kmsColumns, + } +} + +export function kmsSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'aws' ? awsKmsSchema() : null +} diff --git a/packages/api/src/cloud-spi/lambdaStarterCode.test.ts b/packages/api/src/cloud-spi/lambdaStarterCode.test.ts new file mode 100644 index 0000000..02ae378 --- /dev/null +++ b/packages/api/src/cloud-spi/lambdaStarterCode.test.ts @@ -0,0 +1,12 @@ +import {describe, expect, test} from 'bun:test' +import {lambdaStarterCode} from './lambdaStarterCode' + +describe('lambdaStarterCode', () => { + test('returns a JavaScript handler for Node.js runtimes', () => { + expect(lambdaStarterCode('nodejs22.x')).toContain('exports.handler') + }) + + test('returns a Python handler for Python runtimes', () => { + expect(lambdaStarterCode('python3.12')).toContain('def handler(event, context)') + }) +}) diff --git a/packages/api/src/cloud-spi/lambdaStarterCode.ts b/packages/api/src/cloud-spi/lambdaStarterCode.ts new file mode 100644 index 0000000..590d9e1 --- /dev/null +++ b/packages/api/src/cloud-spi/lambdaStarterCode.ts @@ -0,0 +1,33 @@ +const NODEJS_STARTER_CODE = `exports.handler = async (event) => { + return { + statusCode: 200, + body: JSON.stringify({ + message: 'Hello from Lambda!', + event, + }), + }; +};` + +const PYTHON_STARTER_CODE = `import json + + +def handler(event, context): + return { + 'statusCode': 200, + 'body': json.dumps({ + 'message': 'Hello from Lambda!', + 'event': event, + }), + }` + +export const lambdaStarterCodeByRuntime: Record = { + 'nodejs22.x': NODEJS_STARTER_CODE, + 'nodejs20.x': NODEJS_STARTER_CODE, + 'nodejs18.x': NODEJS_STARTER_CODE, + 'python3.12': PYTHON_STARTER_CODE, + 'python3.11': PYTHON_STARTER_CODE, +} + +export function lambdaStarterCode(runtime: string): string { + return lambdaStarterCodeByRuntime[runtime] ?? NODEJS_STARTER_CODE +} diff --git a/packages/api/src/cloud-spi/loadBalancingSchema.ts b/packages/api/src/cloud-spi/loadBalancingSchema.ts new file mode 100644 index 0000000..8ccc81c --- /dev/null +++ b/packages/api/src/cloud-spi/loadBalancingSchema.ts @@ -0,0 +1,86 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const loadBalancerColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'State', format: 'badge'}, + {name: 'lbType', label: 'Type', path: 'metadata.lbType'}, + {name: 'scheme', label: 'Scheme', path: 'metadata.scheme'}, + {name: 'dnsName', label: 'DNS Name', path: 'metadata.dnsName', format: 'code', emptyText: '—'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, +] + +const loadBalancerFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +export const ELB_TYPES = ['application', 'network'] as const +export const ELB_SCHEMES = ['internet-facing', 'internal'] as const + +/** ELBv2 names: up to 32 chars, alphanumerics and hyphens, no leading/trailing hyphen. */ +export const ELB_NAME_PATTERN = '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$' + +export function awsLoadBalancingSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'loadbalancing', + displayName: 'AWS Elastic Load Balancing', + fields: [ + { + name: 'name', + label: 'Load Balancer Name', + type: 'text', + required: true, + group: 'Required', + validation: { + pattern: ELB_NAME_PATTERN, + minLength: 1, + maxLength: 32, + message: 'Up to 32 characters: letters, digits and hyphens, not starting or ending with a hyphen.', + }, + }, + { + name: 'subnets', + label: 'Subnet IDs', + type: 'text', + required: true, + span: true, + group: 'Required', + // Follows the compute schema, which takes security group ids the + // same way rather than pushing create into a bespoke panel. + description: + 'Two or more subnet ids, comma separated — e.g. subnet-aaa, subnet-bbb. ELB requires them to be in different availability zones.', + }, + { + name: 'type', + label: 'Type', + type: 'select', + required: false, + description: 'Defaults to application.', + options: ELB_TYPES.map((value) => ({label: value, value})), + }, + { + name: 'scheme', + label: 'Scheme', + type: 'select', + required: false, + description: 'Defaults to internet-facing.', + options: ELB_SCHEMES.map((value) => ({label: value, value})), + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List load balancers', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + { + name: 'create', + label: 'Create load balancer', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + {name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: loadBalancerFilters, + columns: loadBalancerColumns, + } +} diff --git a/packages/api/src/cloud-spi/logsSchema.ts b/packages/api/src/cloud-spi/logsSchema.ts new file mode 100644 index 0000000..69ca6ab --- /dev/null +++ b/packages/api/src/cloud-spi/logsSchema.ts @@ -0,0 +1,62 @@ +import type {ServiceSchema} from './types' + +export function awsLogsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'logs', + displayName: 'CloudWatch Logs', + fields: [ + { + name: 'name', + label: 'Log group name', + type: 'text', + required: true, + validation: { + minLength: 1, + maxLength: 512, + pattern: '^[A-Za-z0-9_./#-]+$', + message: 'Use letters, numbers, underscore, dot, slash, hash, or dash.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List log groups', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create log group', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete log group', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + collectionActions: [ + {name: 'list', label: 'List log streams', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create log stream', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete log stream', enabled: true, status: 'available', runtimeRequired: true}, + ], + // Read-only leaf, and that is the provider's shape rather than a gap: + // AWS has no DeleteLogEvent, and writing events from a resource + // browser is not something a console should offer. + itemActions: [ + {name: 'list', label: 'Read log events', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: [ + { + name: 'search', + label: 'Name starts with', + type: 'text', + required: false, + description: 'CloudWatch filters log groups by prefix, not by substring.', + }, + ], + columns: [ + {name: 'name', label: 'Log group'}, + // No 'Stored' column here, deliberately: the runtime's DescribeLogGroups + // always reports storedBytes as 0 at the group level (only the stream + // level is accurate), so a table cell showing "0 B" would tell the user + // a populated group is empty. The field still travels in + // metadata.storedBytes for anyone reading the resource directly. + {name: 'retentionInDays', label: 'Retention', path: 'metadata.retentionInDays', emptyText: 'Never expires'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, + ], + } +} diff --git a/packages/api/src/cloud-spi/messagingSchema.ts b/packages/api/src/cloud-spi/messagingSchema.ts new file mode 100644 index 0000000..c6effc9 --- /dev/null +++ b/packages/api/src/cloud-spi/messagingSchema.ts @@ -0,0 +1,91 @@ +import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' + +/** + * One category for queue- and topic-shaped services. + * + * SQS queues and Pub/Sub topics differ in delivery semantics but present the + * same way in a console — a named endpoint with a depth and a few settings — so + * splitting them into separate categories would double the nav for no gain. + */ + +const messagingColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'type', label: 'Type'}, + {name: 'messages', label: 'Messages', path: 'metadata.approximateMessages'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + +const pubSubColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'type', label: 'Type'}, + {name: 'path', label: 'Resource Path', path: 'metadata.resourcePath', format: 'code'}, +] + +const messagingFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +function crudCapabilities(noun: string): CapabilitySchema[] { + return [ + {name: 'list', label: `List ${noun}s`, enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: `Create ${noun}`, enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: `Delete ${noun}`, enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: `Inspect ${noun}`, enabled: true, status: 'available', runtimeRequired: false}, + ] +} + +export function awsMessagingSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'messaging', + displayName: 'AWS SQS', + fields: [ + { + name: 'queueName', + label: 'Queue Name', + type: 'text', + required: true, + description: 'Up to 80 alphanumeric characters, hyphens, or underscores.', + }, + { + name: 'visibilityTimeout', + label: 'Visibility Timeout', + type: 'text', + required: false, + description: 'Seconds a received message stays hidden. Defaults to 30.', + }, + { + name: 'messageRetentionPeriod', + label: 'Retention Period', + type: 'text', + required: false, + description: 'Seconds a message is kept. Defaults to 345600 (4 days).', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: messagingFilters, + columns: messagingColumns, + capabilities: {resourceActions: crudCapabilities('queue')}, + } +} + +export function gcpMessagingSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'messaging', + displayName: 'Cloud Pub/Sub', + fields: [ + { + name: 'topicName', + label: 'Topic Name', + type: 'text', + required: true, + description: 'Letters, numbers, hyphens, underscores, periods, tildes, plus, or percent signs.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: messagingFilters, + columns: pubSubColumns, + capabilities: {resourceActions: crudCapabilities('topic')}, + } +} diff --git a/packages/api/src/cloud-spi/networkingSchema.ts b/packages/api/src/cloud-spi/networkingSchema.ts new file mode 100644 index 0000000..82a0a03 --- /dev/null +++ b/packages/api/src/cloud-spi/networkingSchema.ts @@ -0,0 +1,49 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const networkingColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'version', label: 'CIDR', path: 'metadata.cidrBlock', format: 'code'}, + {name: 'status', label: 'State', format: 'badge'}, + {name: 'type', label: 'Type'}, +] + +const networkingFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +export function awsNetworkingSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'networking', + displayName: 'Networking', + fields: [], + actions: ['list'], + filters: networkingFilters, + columns: networkingColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'VPCs', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + // The generic form cannot express the dependent selectors these + // need, so both live in the Networking panel. Advertising them as + // available here produced a 502 telling the user to go elsewhere. + { + name: 'create', + label: 'Create resources', + enabled: false, + status: 'partial', + reason: 'Use the Networking panel — VPC and subnet creation need dependent selectors.', + runtimeRequired: true, + }, + { + name: 'delete', + label: 'Delete resources', + enabled: false, + status: 'partial', + reason: 'Use the Networking panel — deletion must resolve dependent networking resources first.', + runtimeRequired: true, + }, + ], + }, + } +} diff --git a/packages/api/src/cloud-spi/noSqlSchema.ts b/packages/api/src/cloud-spi/noSqlSchema.ts new file mode 100644 index 0000000..e1244df --- /dev/null +++ b/packages/api/src/cloud-spi/noSqlSchema.ts @@ -0,0 +1,45 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const noSqlFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const noSqlColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'engine', label: 'Engine'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, +] + +export function azureNoSqlSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'nosql', + displayName: 'Azure Cosmos DB NoSQL', + fields: [ + { + name: 'databaseName', + label: 'Database Name', + type: 'text', + required: true, + validation: { + minLength: 1, + maxLength: 255, + pattern: '^[A-Za-z0-9._-]+$', + message: 'Use letters, numbers, dot, underscore, or dash.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List Cosmos databases', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create Cosmos database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete Cosmos database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: noSqlFilters, + columns: noSqlColumns, + } +} diff --git a/packages/api/src/cloud-spi/parametersSchema.ts b/packages/api/src/cloud-spi/parametersSchema.ts new file mode 100644 index 0000000..cf7c1c3 --- /dev/null +++ b/packages/api/src/cloud-spi/parametersSchema.ts @@ -0,0 +1,86 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +/** + * No value column, by design. A parameter value never travels with the resource + * (see AwsParameterStoreAdapter), so offering the column would render an empty + * cell for every row. + */ +const parameterColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name', format: 'code'}, + {name: 'parameterType', label: 'Type', path: 'metadata.parameterType', format: 'badge'}, + {name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'}, + {name: 'version', label: 'Version', path: 'metadata.version'}, + {name: 'createdAt', label: 'Last Modified', format: 'datetime'}, +] + +const parameterFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +export const PARAMETER_TYPES = ['String', 'StringList', 'SecureString'] as const + +/** SSM caps a Standard-tier parameter name at 1011 characters. */ +export const PARAMETER_NAME_MAX_LENGTH = 1011 + +export function awsParametersSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'parameters', + displayName: 'SSM Parameter Store', + fields: [ + { + name: 'name', + label: 'Name', + type: 'text', + required: true, + description: 'Hierarchical names are conventional, e.g. /app/prod/region.', + validation: { + minLength: 1, + maxLength: PARAMETER_NAME_MAX_LENGTH, + message: 'Use a name of up to 1011 characters, optionally with / separators.', + }, + }, + { + name: 'type', + label: 'Type', + type: 'select', + required: false, + description: 'SecureString is stored encrypted. Its value is never returned to the console.', + options: PARAMETER_TYPES.map((value) => ({label: value, value})), + }, + { + name: 'value', + label: 'Value', + type: 'text', + required: true, + span: true, + description: 'Accepted here on create, but never read back.', + }, + {name: 'description', label: 'Description', type: 'text', required: false, span: true}, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List parameters', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create parameter', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete parameter', enabled: true, status: 'available', runtimeRequired: true}, + { + name: 'inspect', + label: 'Inspect parameter', + enabled: true, + // Metadata only. The value is deliberately withheld, so this is + // not a full inspect and the UI should say so rather than + // render a blank field and look broken. + status: 'partial', + reason: 'Parameter values are never returned to the console, so the inspector shows metadata only.', + runtimeRequired: true, + }, + {name: 'updateTags', label: 'Edit tags', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: parameterFilters, + columns: parameterColumns, + } +} + +export function parametersSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'aws' ? awsParametersSchema() : null +} diff --git a/packages/api/src/cloud-spi/schedulerSchema.ts b/packages/api/src/cloud-spi/schedulerSchema.ts new file mode 100644 index 0000000..2405988 --- /dev/null +++ b/packages/api/src/cloud-spi/schedulerSchema.ts @@ -0,0 +1,96 @@ +import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' + +const schedulerColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'schedule', label: 'Schedule', path: 'metadata.schedule'}, + {name: 'timeZone', label: 'Time Zone', path: 'metadata.timeZone'}, + {name: 'target', label: 'Target URI', path: 'metadata.target'}, + {name: 'status', label: 'State'}, + {name: 'region', label: 'Region'}, + {name: 'createdAt', label: 'Created / Updated At'}, +] + +const schedulerFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const schedulerResourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List jobs', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create job', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete job', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect job', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function gcpSchedulerSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'scheduler', + displayName: 'Cloud Scheduler', + fields: [ + { + name: 'jobName', + label: 'Job Name', + type: 'text', + required: true, + description: 'Unique identifier for the job.', + validation: { + pattern: '^[a-zA-Z0-9_-]{1,500}$', + minLength: 1, + maxLength: 500, + message: 'Use a valid job name: 1-500 alphanumeric characters, underscores, or hyphens.', + }, + }, + { + name: 'schedule', + label: 'Schedule (Cron)', + type: 'text', + required: true, + description: 'Cron schedule syntax (e.g. * * * * * or 0 9 * * 1).', + }, + { + name: 'timeZone', + label: 'Time Zone', + type: 'text', + required: false, + description: 'Timezone (e.g. UTC, America/New_York). Defaults to UTC.', + }, + { + name: 'targetUri', + label: 'Target HTTP URI', + type: 'text', + required: true, + description: 'Target URL invoked when the job fires.', + }, + { + name: 'httpMethod', + label: 'HTTP Method', + type: 'select', + required: false, + options: [ + {label: 'POST', value: 'POST'}, + {label: 'GET', value: 'GET'}, + {label: 'PUT', value: 'PUT'}, + {label: 'DELETE', value: 'DELETE'}, + ], + }, + { + name: 'description', + label: 'Description', + type: 'text', + required: false, + description: 'Optional description of the job.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: schedulerResourceActions, + }, + filters: schedulerFilters, + columns: schedulerColumns, + } +} + +export function schedulerSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'gcp') return gcpSchedulerSchema() + return null +} diff --git a/packages/api/src/cloud-spi/secretsSchema.ts b/packages/api/src/cloud-spi/secretsSchema.ts new file mode 100644 index 0000000..b1fd0eb --- /dev/null +++ b/packages/api/src/cloud-spi/secretsSchema.ts @@ -0,0 +1,138 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +// The hyphen is escaped so the pattern also compiles under the `v` flag used for +// HTML pattern validation in the browser. +export const SECRET_NAME_PATTERN = '^[0-9A-Za-z\\-]{1,127}$' +export const SECRET_NAME_MESSAGE = 'Use a valid Key Vault secret name: 1-127 letters, numbers, or hyphens.' + +const secretsFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +// The list endpoint returns base secret identifiers without a version, so a Version +// column would be blank for every row. Versions are surfaced on inspect instead. +const secretsColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Secret Name'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, +] + +export function azureSecretsSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'secrets', + displayName: 'Key Vault', + fields: [ + { + name: 'secretName', + label: 'Secret Name', + type: 'text', + required: true, + description: 'Unique Key Vault secret name.', + validation: { + minLength: 1, + maxLength: 127, + pattern: SECRET_NAME_PATTERN, + message: SECRET_NAME_MESSAGE, + }, + }, + { + name: 'secretValue', + label: 'Secret Value', + type: 'password', + required: true, + description: 'Value stored in the secret.', + span: true, + }, + { + name: 'contentType', + label: 'Content Type', + type: 'text', + required: false, + description: 'Optional content type, for example application/json.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: secretsFilters, + columns: secretsColumns, + } +} + +const awsSecretsColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Secret Name'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + +const gcpSecretsColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, + {name: 'replication', label: 'Replication', path: 'metadata.replication'}, +] + +export function awsSecretsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'secrets', + displayName: 'AWS Secrets Manager', + fields: [ + {name: 'secretName', label: 'Secret Name', type: 'text', required: true}, + {name: 'description', label: 'Description', type: 'text', required: false}, + { + name: 'secretValue', + label: 'Secret Value', + type: 'password', + required: false, + description: 'Optional initial value. Stored by the runtime and never read back into the console.', + span: true, + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: secretsFilters, + columns: awsSecretsColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + } +} + +export function gcpSecretsSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'secrets', + displayName: 'Secret Manager', + fields: [ + { + name: 'secretName', + label: 'Secret Name', + type: 'text', + required: true, + description: 'Letters, numbers, hyphens, and underscores.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: secretsFilters, + columns: gcpSecretsColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + } +} diff --git a/packages/api/src/cloud-spi/serverlessSchema.ts b/packages/api/src/cloud-spi/serverlessSchema.ts new file mode 100644 index 0000000..fe4feaf --- /dev/null +++ b/packages/api/src/cloud-spi/serverlessSchema.ts @@ -0,0 +1,265 @@ +import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' +import {lambdaStarterCodeByRuntime} from './lambdaStarterCode' + +const serverlessColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Function Name'}, + {name: 'type', label: 'Type'}, + {name: 'cloud', label: 'Cloud'}, + {name: 'region', label: 'Region'}, + {name: 'runtime', label: 'Runtime', path: 'metadata.runtime'}, + {name: 'status', label: 'Status'}, + {name: 'updatedAt', label: 'Last Updated', path: 'metadata.lastModified', format: 'datetime'}, +] + +/** Invoke is the verb that distinguishes serverless from every other category. */ +function serverlessResourceActions( + invoke: CapabilitySchema, +): CapabilitySchema[] { + return [ + {name: 'list', label: 'List functions', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create function', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete function', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect function', enabled: true, status: 'available', runtimeRequired: false}, + invoke, + ] +} + +const serverlessFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, + {name: 'runtime', label: 'Runtime', type: 'text', required: false}, +] + +export function awsServerlessSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'serverless', + displayName: 'AWS Lambda', + fields: [ + { + name: 'functionName', + label: 'Function Name', + type: 'text', + required: true, + description: 'Unique Lambda function name.', + }, + { + name: 'runtime', + label: 'Runtime', + type: 'select', + required: true, + options: [ + {label: 'Node.js 22.x', value: 'nodejs22.x'}, + {label: 'Node.js 20.x', value: 'nodejs20.x'}, + {label: 'Node.js 18.x', value: 'nodejs18.x'}, + {label: 'Python 3.12', value: 'python3.12'}, + {label: 'Python 3.11', value: 'python3.11'}, + ], + defaultValue: 'nodejs22.x', + }, + { + name: 'architecture', + label: 'Architecture', + type: 'select', + required: true, + options: [ + {label: 'x86_64', value: 'x86_64'}, + {label: 'arm64', value: 'arm64'}, + ], + defaultValue: 'x86_64', + description: 'Select the architecture used to build native dependencies.', + }, + { + name: 'handler', + label: 'Handler', + type: 'text', + required: true, + description: 'Example: index.handler', + defaultValue: 'index.handler', + }, + { + name: 'role', + label: 'Execution Role ARN', + type: 'text', + required: true, + description: 'IAM role ARN used by the Lambda function.', + }, + { + name: 'memorySize', + label: 'Memory Size', + type: 'text', + required: false, + description: 'Memory in MB. Default: 128.', + }, + { + name: 'timeout', + label: 'Timeout', + type: 'text', + required: false, + description: 'Timeout in seconds. Default: 3.', + }, + { + name: 'description', + label: 'Description', + type: 'text', + required: false, + }, + { + name: 'code', + label: 'Hello World Source', + type: 'textarea', + required: false, + description: 'Edit the default handler, or upload an existing ZIP package below.', + span: true, + defaultValueWhen: {field: 'runtime', values: lambdaStarterCodeByRuntime}, + }, + { + name: 'zipFile', + label: 'Upload ZIP Deployment Package', + type: 'file', + required: false, + accept: '.zip,application/zip', + description: 'Uses this ZIP instead of the Hello World source.', + span: true, + }, +], + actions: ['list', 'create', 'inspect', 'delete'], + filters: serverlessFilters, + columns: serverlessColumns, + capabilities: { + resourceActions: serverlessResourceActions({ + name: 'invoke', label: 'Invoke function', enabled: true, status: 'available', runtimeRequired: true, + }), + }, + } +} + +export function azureServerlessSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'serverless', + displayName: 'Azure Functions', + fields: [ + { + name: 'functionName', + label: 'Function Name', + type: 'text', + required: true, + description: 'Unique Azure Function name.', + }, + { + name: 'runtime', + label: 'Runtime', + type: 'select', + required: false, + options: [ + {label: 'Node.js', value: 'node'}, + {label: 'Python', value: 'python'}, + {label: '.NET', value: 'dotnet'}, + {label: 'Java', value: 'java'}, + ], + }, + { + name: 'handler', + label: 'Handler', + type: 'text', + required: false, + description: 'Optional function entry point or handler.', + }, + { + name: 'functionAppName', + label: 'Function App Name', + type: 'text', + required: false, + description: 'Optional parent Function App name.', + }, + { + name: 'location', + label: 'Location', + type: 'text', + required: false, + description: 'Azure region, for example eastus.', + }, + { + name: 'code', + label: 'Inline Code', + type: 'text', + required: false, + description: 'Optional starter code. Package deployment can follow in a later PR.', + span: true, + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: serverlessFilters, + columns: serverlessColumns, + capabilities: { + resourceActions: serverlessResourceActions({ + name: 'invoke', label: 'Invoke function', enabled: true, status: 'available', runtimeRequired: true, + }), + }, + } +} + +export function gcpServerlessSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'serverless', + displayName: 'Cloud Functions', + fields: [ + { + name: 'functionName', + label: 'Function Name', + type: 'text', + required: true, + description: 'Unique Cloud Function name within the project and region.', + }, + { + name: 'runtime', + label: 'Runtime', + type: 'select', + required: true, + options: [ + {label: 'Node.js 20', value: 'nodejs20'}, + {label: 'Node.js 18', value: 'nodejs18'}, + {label: 'Python 3.12', value: 'python312'}, + {label: 'Python 3.11', value: 'python311'}, + {label: 'Go 1.22', value: 'go122'}, + ], + }, + { + name: 'entryPoint', + label: 'Entry Point', + type: 'text', + required: true, + description: 'Name of the exported function to execute. Example: helloWorld', + }, + { + name: 'code', + label: 'Inline Code', + type: 'text', + required: false, + description: 'Optional inline starter code. Source archive upload will come in a later PR.', + span: true, + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], + filters: serverlessFilters, + columns: serverlessColumns, + capabilities: { + resourceActions: serverlessResourceActions({ + name: 'invoke', + label: 'Invoke function', + enabled: false, + status: 'coming_soon', + reason: 'The Cloud Functions :call endpoint is not wired through the adapter yet.', + runtimeRequired: true, + }), + }, + } +} + +export function serverlessSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsServerlessSchema() + if (cloud === 'azure') return azureServerlessSchema() + if (cloud === 'gcp') return gcpServerlessSchema() + return null +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.test.ts b/packages/api/src/cloud-spi/serviceCatalog.test.ts new file mode 100644 index 0000000..a64157b --- /dev/null +++ b/packages/api/src/cloud-spi/serviceCatalog.test.ts @@ -0,0 +1,97 @@ +import {describe, expect, test} from 'bun:test' +import { + SERVICE_CATALOG, + SERVICE_CATALOG_ENTRIES, + SERVICE_GROUP_ORDER, + SERVICE_TYPES, + catalogEntry, + displayNameFor, + isServiceType, + routeFor, +} from './serviceCatalog' + +describe('SERVICE_CATALOG', () => { + test('every entry carries the metadata the nav needs', () => { + for (const entry of SERVICE_CATALOG_ENTRIES) { + expect(entry.displayName.length).toBeGreaterThan(0) + expect(entry.iconKey.length).toBeGreaterThan(0) + expect(SERVICE_GROUP_ORDER).toContain(entry.group) + expect(Number.isFinite(entry.order)).toBe(true) + expect(routeFor(entry, 'aws').length).toBeGreaterThan(0) + } + }) + + test('exposes one entry per catalog key', () => { + expect(SERVICE_CATALOG_ENTRIES).toHaveLength(Object.keys(SERVICE_CATALOG).length) + expect(new Set(SERVICE_TYPES).size).toBe(SERVICE_TYPES.length) + }) + + test('registers the iac category under the Provisioning group', () => { + expect(SERVICE_GROUP_ORDER).toContain('Provisioning') + const iac = catalogEntry('iac')! + expect(iac.group).toBe('Provisioning') + expect(isServiceType('iac')).toBe(true) + expect(displayNameFor(iac, 'aws')).toBe('CloudFormation') + }) + + test('orders entries by group then in-group order', () => { + const positions = SERVICE_CATALOG_ENTRIES.map((entry) => [ + SERVICE_GROUP_ORDER.indexOf(entry.group), + entry.order, + ]) + + for (let i = 1; i < positions.length; i += 1) { + const [prevGroup, prevOrder] = positions[i - 1]! + const [group, order] = positions[i]! + expect(prevGroup < group || (prevGroup === group && prevOrder <= order)).toBe(true) + } + }) + + test('routes default to the slug', () => { + expect(routeFor(catalogEntry('storage')!, 'aws')).toBe('storage') + expect(routeFor(catalogEntry('storage')!, 'gcp')).toBe('storage') + }) + + test('a per-cloud override routes one provider to its bespoke page', () => { + // AWS Secrets Manager keeps its dedicated page until value reveal exists + // as a row action; GCP goes through the generic explorer today. + const secrets = catalogEntry('secrets')! + expect(routeFor(secrets, 'aws')).toBe('/secretsmanager') + expect(routeFor(secrets, 'gcp')).toBe('secrets') + }) + + test('resolves per-cloud routes so one category can span a legacy page and the explorer', () => { + const secrets = catalogEntry('secrets')! + expect(routeFor(secrets, 'aws')).toBe('/secretsmanager') + expect(routeFor(secrets, 'azure')).toBe('secrets') + // A category with no per-cloud override is unaffected by the cloud argument. + expect(routeFor(catalogEntry('storage')!, 'azure')).toBe('storage') + }) + + test('resolves per-cloud display names', () => { + const k8s = catalogEntry('k8s')! + expect(displayNameFor(k8s, 'aws')).toBe('EKS') + expect(displayNameFor(k8s, 'azure')).toBe('AKS') + expect(displayNameFor(k8s, 'gcp')).toBe('GKE') + // No override -> the shared display name. + expect(displayNameFor(catalogEntry('storage')!, 'gcp')).toBe('Storage') + }) +}) + +describe('isServiceType', () => { + test('accepts every catalog key', () => { + for (const service of SERVICE_TYPES) { + expect(isServiceType(service)).toBe(true) + } + }) + + test('rejects unknown slugs so routes 404 instead of failing later', () => { + for (const slug of ['queue', 'stroage', '', 'constructor', '__proto__', 'toString']) { + expect(isServiceType(slug)).toBe(false) + } + }) + + test('catalogEntry returns undefined for an unknown slug', () => { + expect(catalogEntry('queue')).toBeUndefined() + }) +}) diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts new file mode 100644 index 0000000..bed5d96 --- /dev/null +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -0,0 +1,221 @@ +import type {CloudAvailability, CloudProvider} from './types' + +/** + * The single source of truth for which services the console knows about. + * + * Availability is NOT declared here — it is derived per cloud from whether an + * adapter is registered (see `CloudProxyService.services`). This file only + * carries the presentation metadata the nav needs, which is why adding a + * service is one row here plus one adapter, with no frontend edit. + * + * `CloudServiceType` is derived from these keys, so a new row is also a new + * route-addressable service type. Keeping the union closed means an unknown + * slug is a 404 instead of a service that silently 501s later. + */ + +/** Sidebar grouping. A flat list stops being usable past ~12 services. */ +export type ServiceGroup = + | 'Compute' + | 'Storage' + | 'Databases' + | 'Networking' + | 'Integration' + | 'Provisioning' + | 'Security' + | 'Observability' + +export const SERVICE_GROUP_ORDER: ServiceGroup[] = [ + 'Compute', + 'Storage', + 'Databases', + 'Networking', + 'Integration', + 'Provisioning', + 'Security', + 'Observability', +] + +export interface ServiceCatalogMetadata { + displayName: string + /** Per-cloud display override, e.g. 'EKS' vs 'AKS' vs 'GKE'. */ + displayNameByCloud?: Partial> + /** Resolved to a component client-side; an unknown key degrades to a default. */ + iconKey: string + group: ServiceGroup + /** Sort order within the group. */ + order: number + /** Defaults to the catalog key. A leading '/' marks a page outside Cloud Explorer. */ + route?: string + /** + * Per-cloud route override. Needed when one category is served by a legacy + * standalone page on one cloud and by Cloud Explorer on another — AWS secrets + * still live at /secretsmanager while Azure Key Vault is a normal explorer route. + */ + routeByCloud?: Partial> + /** + * Escape hatch for a service whose UI predates the SPI, so availability + * cannot come from the registry. Every entry here is migration debt — delete + * the field once a real adapter exists. + */ + legacyAvailability?: Partial> +} + +export const SERVICE_CATALOG = { + compute: {displayName: 'Compute', iconKey: 'compute', group: 'Compute', order: 10}, + k8s: { + displayName: 'k8s Engine', + displayNameByCloud: {aws: 'EKS', azure: 'AKS', gcp: 'GKE'}, + iconKey: 'k8s', + group: 'Compute', + order: 20, + }, + serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30}, + containers: { + displayName: 'Containers', + displayNameByCloud: {gcp: 'Cloud Run'}, + iconKey: 'containers', + group: 'Compute', + order: 40, + }, + storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10}, + database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10}, + nosql: { + displayName: 'NoSQL', + // Both labels are declared even though each arrives with its own adapter, + // so this row reads the same whichever of the two lands first. A label for + // a cloud with no adapter is inert: availability comes from the registry. + displayNameByCloud: {aws: 'DynamoDB', azure: 'Cosmos DB NoSQL'}, + iconKey: 'nosql', + group: 'Databases', + order: 20, + }, + networking: {displayName: 'Networking', iconKey: 'networking', group: 'Networking', order: 10}, + workflows: { + displayName: 'Workflows', + displayNameByCloud: {aws: 'Step Functions'}, + iconKey: 'workflows', + group: 'Integration', + order: 30, + }, + loadbalancing: { + displayName: 'Load Balancing', + displayNameByCloud: {aws: 'ELB'}, + iconKey: 'loadbalancing', + group: 'Networking', + order: 20, + }, + messaging: { + displayName: 'Messaging', + displayNameByCloud: {aws: 'SQS', gcp: 'Pub/Sub'}, + iconKey: 'messaging', + group: 'Integration', + order: 10, + }, + events: { + displayName: 'Events', + displayNameByCloud: {aws: 'EventBridge'}, + iconKey: 'events', + group: 'Integration', + order: 15, + }, + identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5}, + apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10}, + email: { + displayName: 'Email', + displayNameByCloud: {aws: 'SES Mailbox'}, + iconKey: 'email', + group: 'Integration', + order: 20, + }, + kms: { + displayName: 'Key Management', + displayNameByCloud: {aws: 'KMS'}, + iconKey: 'kms', + group: 'Security', + order: 20, + }, + parameters: { + displayName: 'Parameter Store', + iconKey: 'parameters', + group: 'Security', + order: 30, + }, + secrets: { + displayName: 'Secrets Manager', + displayNameByCloud: {azure: 'Key Vault', gcp: 'Secret Manager'}, + iconKey: 'secrets', + group: 'Security', + order: 10, + // AWS retains its dedicated page while Azure and GCP use Cloud Explorer. + routeByCloud: {aws: '/secretsmanager'}, + }, + iac: { + displayName: 'Infrastructure as Code', + displayNameByCloud: {aws: 'CloudFormation'}, + iconKey: 'iac', + group: 'Provisioning', + order: 10, + }, + configuration: { + displayName: 'Configuration', + displayNameByCloud: {aws: 'AppConfig'}, + iconKey: 'configuration', + group: 'Provisioning', + order: 20, + }, + scheduler: { + displayName: 'Cloud Scheduler', + displayNameByCloud: {gcp: 'Cloud Scheduler'}, + iconKey: 'scheduler', + group: 'Integration', + order: 20, + }, + logs: { + displayName: 'Logs', + displayNameByCloud: {aws: 'CloudWatch Logs'}, + iconKey: 'logs', + group: 'Observability', + order: 10, + }, +} as const satisfies Record + +export type CloudServiceType = keyof typeof SERVICE_CATALOG + +export interface ServiceCatalogEntry extends ServiceCatalogMetadata { + service: CloudServiceType +} + +/** Every known service, sorted by group then in-group order. */ +export const SERVICE_CATALOG_ENTRIES: ServiceCatalogEntry[] = ( + Object.keys(SERVICE_CATALOG) as CloudServiceType[] +) + .map((service) => ({service, ...SERVICE_CATALOG[service]})) + .sort(compareCatalogEntries) + +export const SERVICE_TYPES: CloudServiceType[] = SERVICE_CATALOG_ENTRIES.map((entry) => entry.service) + +export function catalogEntry(service: string): ServiceCatalogEntry | undefined { + return isServiceType(service) ? {service, ...SERVICE_CATALOG[service]} : undefined +} + +/** + * Route guard. A slug missing from the catalog is a 404 rather than a service + * that renders and then fails on every call. + */ +export function isServiceType(value: string): value is CloudServiceType { + return Object.hasOwn(SERVICE_CATALOG, value) +} + +export function displayNameFor(entry: ServiceCatalogEntry, cloud: CloudProvider): string { + return entry.displayNameByCloud?.[cloud] ?? entry.displayName +} + +export function routeFor(entry: ServiceCatalogEntry, cloud?: CloudProvider): string { + const perCloud = cloud ? entry.routeByCloud?.[cloud] : undefined + return perCloud ?? entry.route ?? entry.service +} + +export function compareCatalogEntries(a: ServiceCatalogEntry, b: ServiceCatalogEntry): number { + const groupDelta = SERVICE_GROUP_ORDER.indexOf(a.group) - SERVICE_GROUP_ORDER.indexOf(b.group) + return groupDelta !== 0 ? groupDelta : a.order - b.order +} diff --git a/packages/api/src/cloud-spi/servicebusSchema.ts b/packages/api/src/cloud-spi/servicebusSchema.ts new file mode 100644 index 0000000..8e8b991 --- /dev/null +++ b/packages/api/src/cloud-spi/servicebusSchema.ts @@ -0,0 +1,39 @@ +import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types' + +const resourceActions: CapabilitySchema[] = [ + {name: 'list', label: 'List namespaces', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create namespace', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete namespace', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect namespace', enabled: true, status: 'available', runtimeRequired: false}, +] + +export function azureServiceBusSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'messaging', + displayName: 'Service Bus', + fields: [ + { + name: 'namespaceName', + label: 'Namespace Name', + type: 'text', + required: true, + description: '6-50 characters. Letters, numbers, and hyphens; starts with a letter, ends with a letter or number. Cannot end with -sb or -mgmt.', + validation: { + pattern: '^(?!.*-(?:[sS][bB]|[mM][gG][mM][tT])$)[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$', + minLength: 6, + maxLength: 50, + message: 'Use a valid Service Bus namespace: 6-50 letters, numbers, or hyphens; start with a letter, end with a letter or number, and do not end with -sb or -mgmt.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: {resourceActions}, + filters: [{name: 'search', label: 'Search', type: 'text', required: false}], + columns: [ + {name: 'name', label: 'Namespace'}, + {name: 'status', label: 'Status'}, + {name: 'type', label: 'Type'}, + ], + } +} diff --git a/packages/api/src/cloud-spi/storageSchema.ts b/packages/api/src/cloud-spi/storageSchema.ts new file mode 100644 index 0000000..8aabbbc --- /dev/null +++ b/packages/api/src/cloud-spi/storageSchema.ts @@ -0,0 +1,145 @@ +import type {CapabilitySchema, CloudProvider, FieldSchema, ObjectActionName, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' + +const storageColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'type', label: 'Type'}, + {name: 'cloud', label: 'Cloud'}, + {name: 'region', label: 'Region'}, + {name: 'createdAt', label: 'Created At'}, +] + +const storageFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +function storageResourceActions(createLabel: string): CapabilitySchema[] { + return [ + {name: 'list', label: 'List resources', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: createLabel, enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete resource', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect resource', enabled: true, status: 'available', runtimeRequired: false}, + ] +} + +const storageObjectActions: CapabilitySchema[] = [ + {name: 'list', label: 'List objects', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'upload', label: 'Upload object', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'download', label: 'Download object', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete object', enabled: true, status: 'available', runtimeRequired: true}, + { + name: 'createFolder', + label: 'Create folder', + enabled: true, + status: 'partial', + reason: 'Folders are represented as object/blob prefixes, not filesystem directories.', + runtimeRequired: true, + }, + {name: 'copy', label: 'Copy object', enabled: true, status: 'available', runtimeRequired: true}, +] + +export function awsStorageSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'storage', + displayName: 'S3 Storage', + fields: [ + { + name: 'bucketName', + label: 'Bucket Name', + type: 'text', + required: true, + description: '3-63 characters. Lowercase letters, numbers, dots, and hyphens.', + validation: { + pattern: '^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?!.*\\.\\.)(?!.*\\.-)(?!.*-\\.)(?!.*--x-s3$)(?!.*-s3alias$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$', + minLength: 3, + maxLength: 63, + message: 'Use a valid S3 bucket name: 3-63 lowercase characters, numbers, dots, or hyphens.', + }, + }, + { + name: 'region', + label: 'Region', + type: 'select', + required: false, + options: [ + {label: 'US East (N. Virginia)', value: 'us-east-1'}, + {label: 'US West (Oregon)', value: 'us-west-2'}, + ], + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: storageResourceActions('Create bucket'), + objectActions: storageObjectActions, + }, + filters: storageFilters, + columns: storageColumns, + } +} + +export function azureStorageSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'storage', + displayName: 'Azure Blob Storage', + fields: [ + { + name: 'containerName', + label: 'Container Name', + type: 'text', + required: true, + description: '3-63 characters. Lowercase letters, numbers, and hyphens.', + validation: { + pattern: '^[a-z0-9](?:[a-z0-9]|-(?!-)){1,61}[a-z0-9]$', + minLength: 3, + maxLength: 63, + message: 'Use a valid Azure container name: 3-63 lowercase letters, numbers, or single hyphens.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: storageResourceActions('Create container'), + objectActions: storageObjectActions, + }, + filters: storageFilters, + columns: storageColumns, + } +} + +export function gcpStorageSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'storage', + displayName: 'Google Cloud Storage', + fields: [ + { + name: 'bucketName', + label: 'Bucket Name', + type: 'text', + required: true, + description: '3-63 characters. Lowercase letters, numbers, dots, underscores, and hyphens.', + validation: { + pattern: '^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$', + minLength: 3, + maxLength: 63, + message: 'Use a valid GCS bucket name: 3-63 lowercase characters, numbers, dots, underscores, or hyphens.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: storageResourceActions('Create bucket'), + objectActions: storageObjectActions, + }, + filters: storageFilters, + columns: storageColumns, + } +} + +export function storageSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsStorageSchema() + if (cloud === 'azure') return azureStorageSchema() + if (cloud === 'gcp') return gcpStorageSchema() + return null +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts new file mode 100644 index 0000000..5d9c076 --- /dev/null +++ b/packages/api/src/cloud-spi/types.ts @@ -0,0 +1,590 @@ +// Derived from SERVICE_CATALOG so a new catalog row is a new service type. +// This type-only cycle with serviceCatalog.ts is erased at compile time. +import type {CloudServiceType, ServiceGroup} from './serviceCatalog' +import type { + CollectionActionName, + DocumentStoreAdapter, + ItemActionName, + ItemStoreAdapter, +} from './childCollections' + +export type {CloudServiceType, ServiceGroup} + +export type CloudProvider = 'aws' | 'azure' | 'gcp' + +export type CloudAvailability = 'available' | 'coming_soon' + +export interface CloudDescriptor { + id: CloudProvider + displayName: string + availability: CloudAvailability +} + +/** + * What the frontend needs to render a nav entry. Everything except + * `availability` and `reason` comes from the service catalog; those two are + * resolved per cloud at request time. + */ +export interface CloudServiceDescriptor { + cloud: CloudProvider + service: CloudServiceType + displayName: string + availability: CloudAvailability + /** Why the service is unavailable. Always set when availability is coming_soon. */ + reason?: string + /** Route slug, or an absolute path for a page outside Cloud Explorer. */ + route: string + /** Icon hint; the frontend maps it to a component and falls back if unknown. */ + iconKey: string + group: ServiceGroup + order: number +} + +export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon' + +export interface CloudStatus { + cloud: CloudProvider + adapterRegistered: boolean + runtime: RuntimeReachability + endpoint: string | null + checkedAt: string + error: string | null + /** Present only when the caller asks for per-service detail. */ + services?: CloudServiceStatus[] +} + +/** + * Health of one service rather than the whole cloud. Needed because a cloud can + * be up while an individual service is not — the Azure runtime serves blob + * storage but answers 501 for functions. + */ +export interface CloudServiceStatus { + cloud: CloudProvider + service: CloudServiceType + adapterRegistered: boolean + runtime: RuntimeReachability + endpoint: string | null + checkedAt: string + latencyMs: number | null + error: string | null + /** The mapped error code, so the UI can tell "not implemented" from "down". */ + errorCode: string | null +} + +export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' + +export interface FieldSchema { + name: string + label: string + type: FieldType + required: boolean + description?: string + group?: string + span?: boolean + valuePath?: string + defaultValue?: string + defaultValueWhen?: {field: string; values: Record} + validation?: { + pattern?: string + minLength?: number + maxLength?: number + message?: string + } + options?: Array<{label: string; value: string}> + accept?: string +} + +/** + * Verbs a service can advertise. `ActionSchema` drives which controls the + * generic view renders; `ResourceActionName` additionally covers lifecycle verbs + * that a capability block can describe but that are not table-level controls. + */ +export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect' +export type ResourceActionName = + | 'list' + | 'create' + | 'update' + | 'delete' + | 'inspect' + | 'encrypt' + | 'decrypt' + | 'invoke' + | 'start' + | 'stop' + | 'reboot' + | 'updateTags' +export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' +export type DatabaseActionName = 'listSnapshots' | 'createSnapshot' +export type KubernetesActionName = + | 'listNodegroups' + | 'createNodegroup' + | 'deleteNodegroup' + | 'listFargateProfiles' + | 'createFargateProfile' + | 'deleteFargateProfile' +export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' + +export interface CapabilitySchema { + name: TAction + label: string + enabled: boolean + status: CapabilityStatus + reason?: string + runtimeRequired?: boolean +} + +/** + * How a column is rendered. Everything past `label` is optional and every + * default reproduces the previous behaviour, so existing schemas are unaffected. + */ +export type ColumnFormat = 'text' | 'datetime' | 'relative' | 'bytes' | 'boolean' | 'badge' | 'code' | 'list' + +export interface TableColumnSchema { + /** Column identity and sort key. */ + name: string + label: string + /** + * Dotted accessor into the resource, defaulting to `name`. Needed because + * most provider detail lives under `metadata`, which a top-level lookup + * cannot reach — those columns previously rendered blank for every row. + */ + path?: string + format?: ColumnFormat + emptyText?: string + width?: string +} + +export interface ServiceSchema { + cloud: CloudProvider + service: CloudServiceType + displayName: string + fields: FieldSchema[] + actions: ActionSchema[] + capabilities?: { + resourceActions?: CapabilitySchema[] + objectActions?: CapabilitySchema[] + databaseActions?: CapabilitySchema[] + kubernetesActions?: CapabilitySchema[] + // Child levels advertise separately from the resource level because a + // store can be readable at the leaf and writable at the collection — + // CloudWatch Logs creates streams but cannot delete an event. + collectionActions?: CapabilitySchema[] + itemActions?: CapabilitySchema[] + } + filters: FieldSchema[] + columns: TableColumnSchema[] + updateFields?: FieldSchema[] +} + +export type KnownResourceType = + | 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'dynamodb-table' + | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' + | 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue' + | 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server' + | 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' + +export interface CloudResource { + id: string + name: string + cloud: CloudProvider + service: CloudServiceType + /** Provider resource kind. Open so adapters can evolve independently. */ + type: KnownResourceType | (string & {}) + region: string | null + createdAt: string | null + status?: string | null + version?: string | null + engine?: string | null + instanceClass?: string | null + metadata: Record +} + +export interface DatabaseSnapshot { + id: string + name: string + instanceIdentifier: string | null + status: string | null + engine: string | null + version: string | null + createdAt: string | null + metadata: Record +} + +export interface CreateDatabaseSnapshotInput { + instanceIdentifier: string + snapshotIdentifier: string +} + +export interface StorageObject { + key: string + name: string + type: 'folder' | 'object' + size: number | null + lastModified: string | null + metadata: Record +} + +export interface StorageObjectList { + prefix: string + objects: StorageObject[] +} + +export interface StorageObjectDownload { + body: BodyInit + contentType: string + contentLength: number | null +} + +export interface CosmosContainer { + id: string + name: string + databaseId: string + partitionKeyPath: string + createdAt: string | null + metadata: Record +} + +export interface CosmosItem { + id: string + databaseId: string + containerId: string + partitionKey: string | null + etag: string | null + timestamp: string | null + document: Record +} + +export interface CosmosQueryResult { + items: Array | string | number | boolean | null> + count: number +} + +export interface SqlConnectionInput { + username: string + password: string + database?: string + engine?: 'azure-sql' | 'postgresql' +} + +export interface SqlDatabase { + name: string + state: string + createdAt: string | null + isSystem: boolean +} + +export interface SqlTable { + schema: string + name: string + type: 'table' | 'view' + rowCount: number | null +} + +export interface SqlColumn { + name: string + type: string +} + +export interface SqlResultSet { + columns: SqlColumn[] + rows: Array> + truncated: boolean +} + +export interface SqlQueryResult { + // `resultSets` holds only the statements that returned columns, while `rowsAffected` + // has one entry per executed statement. On a multi-statement script the two arrays + // therefore have different lengths — treat `rowsAffected` as a total, never as an + // index-for-index companion to `resultSets`. + resultSets: SqlResultSet[] + rowsAffected: number[] + durationMs: number +} + +/** Mirrors the real StartQuery contract: epoch seconds, inclusive range. */ +export interface LogsInsightsQueryInput { + queryString: string + startTime: number + endTime: number + limit?: number +} + +export interface LogsInsightsQueryResult { + /** + * Always present, including when `status` is still Running/Scheduled after + * the poll deadline — dropping it in that case would leave no way to ever + * check back on that query again, even though it is still running server-side. + */ + queryId: string + /** CloudWatch Logs' own QueryStatus: Complete, Running, Failed, Timeout, ... */ + status: string + rows: Array> +} + +export interface NoSqlItem { + id: string + key: Record + document: Record +} + +/** Provider-neutral representation of a managed Kubernetes node pool. */ +export interface KubernetesNodegroup { + id: string + name: string + clusterId: string + arn: string | null + status: string | null + version: string | null + releaseVersion: string | null + createdAt: string | null + modifiedAt: string | null + capacityType: string | null + instanceTypes: string[] + subnets: string[] + nodeRole: string | null + scalingConfig: {minSize?: number; maxSize?: number; desiredSize?: number} | null + labels: Record + tags: Record +} + +export interface CreateKubernetesNodegroupInput { + name: string + nodeRole: string + subnets: string[] + instanceTypes?: string[] + scalingConfig?: {minSize?: number; maxSize?: number; desiredSize?: number} + labels?: Record + tags?: Record +} + +export interface KubernetesFargateProfile { + id: string + name: string + clusterId: string + arn: string | null + status: string | null + createdAt: string | null + podExecutionRoleArn: string | null + subnets: string[] + selectors: Array<{namespace: string | null; labels: Record}> + tags: Record +} + +export interface CreateKubernetesFargateProfileInput { + name: string + podExecutionRoleArn: string + subnets?: string[] + selectors: Array<{namespace: string; labels?: Record}> + tags?: Record +} + +export interface AppConfigEnvironment { + id: string + applicationId: string + name: string + description: string | null + state: string | null +} + +export interface AppConfigConfigurationProfile { + id: string + applicationId: string + name: string + description: string | null + locationUri: string | null + type: string | null +} + +export interface AppConfigHostedConfigurationVersion { + id: string + applicationId: string + configurationProfileId: string + versionNumber: number + description: string | null + contentType: string | null + /** Decoded configuration payload; only GetHostedConfigurationVersion returns it. */ + content: string | null +} + +export interface AppConfigDeploymentStrategy { + id: string + name: string + description: string | null + deploymentDurationInMinutes: number | null + growthType: string | null + growthFactor: number | null + finalBakeTimeInMinutes: number | null + replicateTo: string | null +} + +export interface AppConfigDeployment { + applicationId: string + environmentId: string + deploymentNumber: number + configurationProfileId: string | null + configurationVersion: string | null + deploymentStrategyId: string | null + state: string | null + percentageComplete: number | null + startedAt: string | null + completedAt: string | null + description: string | null +} + +export interface ResourceQuery { + search?: string +} + +export interface CreateResourceInput { + values: Record +} + +export interface UpdateResourceInput { + values: Record +} +export interface ServerlessInvokeResult { + statusCode: number + payload: string + functionError?: string + logResult?: string + executionDuration?: number +} + +export interface ApiGatewayLambdaRouteInput { + path: string + method: string + stage: string + functionName: string +} + +export interface ApiGatewayLambdaRouteResult { + resourceId: string + deploymentId: string + invokeUrl: string +} + +export type KmsEncryptionAlgorithm = + | 'SYMMETRIC_DEFAULT' + | 'RSAES_OAEP_SHA_1' + | 'RSAES_OAEP_SHA_256' + +export interface KmsEncryptInput { + plaintext: Uint8Array + encryptionAlgorithm: KmsEncryptionAlgorithm + encryptionContext?: Record +} + +export interface KmsEncryptResult { + ciphertextBlob: Uint8Array + keyId: string + encryptionAlgorithm: KmsEncryptionAlgorithm +} + +export interface KmsDecryptInput { + ciphertextBlob: Uint8Array + encryptionAlgorithm: KmsEncryptionAlgorithm + encryptionContext?: Record +} + +export interface KmsDecryptResult { + plaintext: Uint8Array + keyId: string + encryptionAlgorithm: KmsEncryptionAlgorithm +} +/** + * Lets a registered adapter correct its own advertised availability. + * + * Needed because "an adapter exists" and "the local runtime implements it" are + * different facts: floci-az ships no /functions endpoint, so the Azure + * serverless adapter is registered but cannot serve a request. Declaring that + * here keeps the sidebar, the console card and the explorer surface consistent + * from one string. + */ +export interface CloudServiceDescriptorOverride { + availability?: CloudAvailability + reason?: string + displayName?: string +} + +export interface CloudServiceAdapter { + readonly cloud: CloudProvider + readonly service: CloudServiceType + schema(): ServiceSchema + descriptorOverride?(): CloudServiceDescriptorOverride + list(query?: ResourceQuery): Promise + get(id: string): Promise + create(input: CreateResourceInput): Promise + update?(id: string, input: UpdateResourceInput): Promise + delete(id: string): Promise + listObjects?(resourceId: string, prefix?: string): Promise + putObject?(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise + getObject?(resourceId: string, key: string): Promise + deleteObject?(resourceId: string, key: string): Promise + listDatabaseSnapshots?(instanceIdentifier?: string): Promise + createDatabaseSnapshot?(input: CreateDatabaseSnapshotInput): Promise + listDatabaseOrderableInstanceClasses?(engine?: string): Promise + invoke?(id: string, payload: string): Promise + createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise + encrypt?(id: string, input: KmsEncryptInput): Promise + decrypt?(id: string, input: KmsDecryptInput): Promise + // Lifecycle verbs. Optional because most categories have no notion of them; + // an adapter that advertises one in `capabilities` must implement it, which + // cloudProxy.test.ts enforces. + start?(id: string): Promise + stop?(id: string, force?: boolean): Promise + reboot?(id: string): Promise + updateTags?(id: string, tags: Record): Promise + /** + * Cheap liveness check. Defaults to `list()`, which is a valid probe for + * every current adapter; override where listing is expensive. + */ + health?(): Promise + copyObject?(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise + /** + * Child collections. An adapter implements at most one: `documents` when the + * resource holds collections that hold items, `items` when the resource is + * itself the collection. Both is a contract violation — the flat item routes + * would be ambiguous — and cloudProxy.test.ts enforces that. + */ + documents?: DocumentStoreAdapter + items?: ItemStoreAdapter + listCosmosContainers?(databaseId: string): Promise + createCosmosContainer?(databaseId: string, input: CreateResourceInput): Promise + deleteCosmosContainer?(databaseId: string, containerId: string): Promise + listCosmosItems?(databaseId: string, containerId: string): Promise + upsertCosmosItem?(databaseId: string, containerId: string, document: Record): Promise + deleteCosmosItem?(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise + queryCosmosItems?(databaseId: string, containerId: string, query: string): Promise + listSqlDatabases?(serverId: string, connection: SqlConnectionInput): Promise + listSqlTables?(serverId: string, connection: SqlConnectionInput): Promise + querySql?(serverId: string, connection: SqlConnectionInput, query: string): Promise + queryLogs?(logGroupName: string, input: LogsInsightsQueryInput): Promise + listNoSqlItems?(resourceId: string): Promise + putNoSqlItem?(resourceId: string, document: Record): Promise + listKubernetesNodegroups?(clusterId: string): Promise + createKubernetesNodegroup?(clusterId: string, input: CreateKubernetesNodegroupInput): Promise + deleteKubernetesNodegroup?(clusterId: string, nodegroupId: string): Promise + listKubernetesFargateProfiles?(clusterId: string): Promise + createKubernetesFargateProfile?(clusterId: string, input: CreateKubernetesFargateProfileInput): Promise + deleteKubernetesFargateProfile?(clusterId: string, profileId: string): Promise + /** Clears the provider's locally captured email inbox, if it exposes one. */ + clearEmailInbox?(): Promise + // AppConfig nested resources, keyed by the ids the provider needs. Inspect + // reuses the full list payloads; only hosted versions (whose content is not + // in List*) and deployments (which are polled) get a getter. + listAppConfigEnvironments?(applicationId: string): Promise + createAppConfigEnvironment?(applicationId: string, input: CreateResourceInput): Promise + deleteAppConfigEnvironment?(applicationId: string, environmentId: string): Promise + listAppConfigConfigurationProfiles?(applicationId: string): Promise + createAppConfigConfigurationProfile?(applicationId: string, input: CreateResourceInput): Promise + deleteAppConfigConfigurationProfile?(applicationId: string, profileId: string): Promise + listAppConfigHostedConfigurationVersions?(applicationId: string, profileId: string): Promise + getAppConfigHostedConfigurationVersion?(applicationId: string, profileId: string, versionNumber: number): Promise + createAppConfigHostedConfigurationVersion?(applicationId: string, profileId: string, input: CreateResourceInput): Promise + deleteAppConfigHostedConfigurationVersion?(applicationId: string, profileId: string, versionNumber: number): Promise + listAppConfigDeploymentStrategies?(): Promise + createAppConfigDeploymentStrategy?(input: CreateResourceInput): Promise + deleteAppConfigDeploymentStrategy?(strategyId: string): Promise + startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise + getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise +} diff --git a/packages/api/src/cloud-spi/workflowsSchema.ts b/packages/api/src/cloud-spi/workflowsSchema.ts new file mode 100644 index 0000000..b73eec7 --- /dev/null +++ b/packages/api/src/cloud-spi/workflowsSchema.ts @@ -0,0 +1,83 @@ +import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +/** + * No `status` column, deliberately. + * + * ListStateMachines returns only the ARN, name, type and creation date — real Step + * Functions does the same. Status lives on DescribeStateMachine, so putting it in + * the table would mean one extra call per row. It is surfaced on inspect instead. + */ +const workflowColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'machineType', label: 'Type', path: 'metadata.machineType', format: 'badge'}, + {name: 'createdAt', label: 'Created', format: 'datetime'}, +] + +const workflowFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +/** + * Step Functions accepts a wider character set, but this is the subset AWS + * recommends and the one that is unambiguous in an ARN. + */ +export const STATE_MACHINE_NAME_PATTERN = '^[a-zA-Z0-9_-]{1,80}$' + +export function awsWorkflowsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'workflows', + displayName: 'AWS Step Functions', + fields: [ + { + name: 'name', + label: 'State Machine Name', + type: 'text', + required: true, + group: 'Required', + validation: { + pattern: STATE_MACHINE_NAME_PATTERN, + minLength: 1, + maxLength: 80, + message: 'Up to 80 characters: letters, digits, hyphens and underscores.', + }, + }, + { + name: 'roleArn', + label: 'Execution Role ARN', + type: 'text', + required: true, + group: 'Required', + description: 'The IAM role the state machine assumes when it runs.', + validation: { + pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$', + message: 'Must be an IAM role ARN, e.g. arn:aws:iam::000000000000:role/my-role.', + }, + }, + { + name: 'definition', + label: 'Definition (Amazon States Language)', + type: 'textarea', + required: true, + span: true, + group: 'Required', + description: 'JSON with StartAt and States, e.g. {"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List state machines', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + { + name: 'create', + label: 'Create state machine', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + {name: 'delete', label: 'Delete', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: workflowFilters, + columns: workflowColumns, + } +} diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts new file mode 100644 index 0000000..688ee59 --- /dev/null +++ b/packages/api/src/cloudProxy.test.ts @@ -0,0 +1,228 @@ +import {describe, expect, test} from 'bun:test' +import {createCloudAdapterRegistry} from './cloudProxy' +import {checkChildCapabilities} from './cloud-spi/childCapabilities' +import {isServiceType} from './cloud-spi/serviceCatalog' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName} from './cloud-spi/types' + +/** + * Guards the schema-to-implementation contract for every registered adapter. + * + * A schema is a promise to the frontend: it decides which controls render and + * which are greyed out. Three separate bugs came from breaking that promise — + * networking advertised create/delete as available while the adapter threw, + * serverless shipped an invoke panel against an adapter with no invoke, and the + * Azure serverless adapter was advertised as available against a runtime that + * answers 501. These tests make the contract mechanical instead of a convention. + */ + +const CLOUDS: CloudProvider[] = ['aws', 'azure', 'gcp'] + +/** Adapter method that must exist for a capability to claim `available`. */ +const METHOD_FOR_ACTION: Record = { + list: 'list', + create: 'create', + update: 'update', + delete: 'delete', + inspect: 'get', + encrypt: 'encrypt', + decrypt: 'decrypt', + invoke: 'invoke', + start: 'start', + stop: 'stop', + reboot: 'reboot', + updateTags: 'updateTags', +} + +const METHOD_FOR_DATABASE_ACTION = { + listSnapshots: 'listDatabaseSnapshots', + createSnapshot: 'createDatabaseSnapshot', +} satisfies Record + +const METHOD_FOR_KUBERNETES_ACTION: Record = { + listNodegroups: 'listKubernetesNodegroups', + createNodegroup: 'createKubernetesNodegroup', + deleteNodegroup: 'deleteKubernetesNodegroup', + listFargateProfiles: 'listKubernetesFargateProfiles', + createFargateProfile: 'createKubernetesFargateProfile', + deleteFargateProfile: 'deleteKubernetesFargateProfile', +} + +const registry = createCloudAdapterRegistry() + +function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> { + return CLOUDS.flatMap((cloud) => + registry.servicesFor(cloud).map((service) => ({cloud, adapter: adapterFor(cloud, service)})), + ) +} + +function adapterFor(cloud: CloudProvider, service: CloudServiceType): CloudServiceAdapter { + const adapter = registry.get(cloud, service) + if (!adapter) throw new Error(`expected an adapter for ${cloud}/${service}`) + return adapter +} + +describe('registered adapters honour their schema', () => { + const adapters = registeredAdapters() + + test('at least one adapter is registered for every cloud', () => { + for (const cloud of CLOUDS) { + expect(adapters.filter((entry) => entry.cloud === cloud).length).toBeGreaterThan(0) + } + }) + + for (const {cloud, adapter} of adapters) { + const label = `${cloud}/${adapter.service}` + + test(`${label} schema identifies itself consistently`, () => { + const schema = adapter.schema() + expect(schema.cloud).toBe(adapter.cloud) + expect(schema.service).toBe(adapter.service) + expect(isServiceType(schema.service)).toBe(true) + expect(schema.displayName.length).toBeGreaterThan(0) + }) + + test(`${label} implements every action it advertises`, () => { + for (const action of adapter.schema().actions) { + const method = METHOD_FOR_ACTION[action] + expect(typeof adapter[method]).toBe('function') + } + }) + + test(`${label} implements every capability it marks available`, () => { + const capabilities = adapter.schema().capabilities?.resourceActions ?? [] + const claimed = capabilities.filter((capability) => capability.status === 'available') + + for (const capability of claimed) { + const method = METHOD_FOR_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } + + const databaseCapabilities = adapter.schema().capabilities?.databaseActions ?? [] + const databaseClaims = databaseCapabilities.filter( + (capability) => capability.status === 'available' || (capability.status === 'partial' && capability.enabled), + ) + for (const capability of databaseClaims) { + const method = METHOD_FOR_DATABASE_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as ${capability.status} but has no ${String(method)}()`, + ).toBe('function') + } + + const kubernetesCapabilities = adapter.schema().capabilities?.kubernetesActions ?? [] + const kubernetesClaims = kubernetesCapabilities.filter((capability) => capability.status === 'available') + for (const capability of kubernetesClaims) { + const method = METHOD_FOR_KUBERNETES_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } + }) + + test(`${label} child collections match their advertised capabilities`, () => { + expect(checkChildCapabilities(adapter), `${label} child capability mismatch`).toEqual([]) + }) + + test(`${label} explains every capability it does not fully support`, () => { + const capabilities = [ + ...(adapter.schema().capabilities?.resourceActions ?? []), + ...(adapter.schema().capabilities?.objectActions ?? []), + ...(adapter.schema().capabilities?.databaseActions ?? []), + ...(adapter.schema().capabilities?.kubernetesActions ?? []), + ...(adapter.schema().capabilities?.collectionActions ?? []), + ...(adapter.schema().capabilities?.itemActions ?? []), + ] + + for (const capability of capabilities) { + if (capability.status === 'available') continue + expect( + capability.reason?.length ?? 0, + `${label} capability ${capability.name} is ${capability.status} with no reason`, + ).toBeGreaterThan(0) + } + }) + + test(`${label} declares object capabilities only when it can serve them`, () => { + const objectActions = adapter.schema().capabilities?.objectActions ?? [] + if (objectActions.length === 0) return + + // An object-action block means the resource is a container of objects. + expect(typeof adapter.listObjects).toBe('function') + }) + + test(`${label} columns resolve to a path`, () => { + for (const column of adapter.schema().columns) { + expect((column.path ?? column.name).length).toBeGreaterThan(0) + } + }) + } +}) + +describe('adapter capability advertisements match the runtime reality', () => { + test('AWS database advertises instance and snapshot capabilities', () => { + const schema = adapterFor('aws', 'database').schema() + + expect(schema.actions).toEqual(['list', 'create', 'update', 'delete', 'inspect']) + expect(schema.capabilities?.resourceActions?.map(({name, enabled, status, runtimeRequired}) => ({ + name, + enabled, + status, + runtimeRequired, + }))).toEqual([ + {name: 'list', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'update', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', enabled: true, status: 'available', runtimeRequired: true}, + ]) + expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'create')?.label).toBe('Create DB instance') + expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'update')?.label).toBe('Update DB instance') + expect(schema.capabilities?.databaseActions).toEqual([ + { + name: 'listSnapshots', + label: 'List DB snapshots', + enabled: true, + status: 'available', + runtimeRequired: true, + }, + { + name: 'createSnapshot', + label: 'Create DB snapshot', + enabled: true, + status: 'partial', + reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.', + runtimeRequired: true, + }, + ]) + }) + + test('AWS serverless advertises invoke and implements it', () => { + const adapter = adapterFor('aws', 'serverless') + const invoke = adapter.schema().capabilities?.resourceActions?.find((c) => c.name === 'invoke') + + expect(invoke?.status).toBe('available') + expect(typeof adapter.invoke).toBe('function') + }) + + test('AWS networking does not advertise create or delete as available', () => { + const adapter = adapterFor('aws', 'networking') + const actions = adapter.schema().capabilities?.resourceActions ?? [] + + for (const name of ['create', 'delete'] as const) { + const capability = actions.find((c) => c.name === name) + expect(capability?.status).not.toBe('available') + expect(capability?.reason).toBeTruthy() + } + }) + + test('Azure serverless reports its runtime gap through descriptorOverride', () => { + const override = adapterFor('azure', 'serverless').descriptorOverride?.() + + expect(override?.availability).toBe('coming_soon') + expect(override?.reason).toContain('501') + }) +}) diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts new file mode 100644 index 0000000..ab74908 --- /dev/null +++ b/packages/api/src/cloudProxy.ts @@ -0,0 +1,112 @@ +import {CloudAdapterRegistry} from './registry/CloudAdapterRegistry' +import {AwsComputeAdapter} from './adapter-aws/AwsComputeAdapter' +import {AwsNetworkingAdapter} from './adapter-aws/AwsNetworkingAdapter' +import {AwsDatabaseAdapter} from './adapter-aws/AwsDatabaseAdapter' +import {AwsEksAdapter} from './adapter-aws/AwsEksAdapter' +import {AwsStorageAdapter} from './adapter-aws/AwsStorageAdapter' +import {AwsLogsAdapter} from './adapter-aws/AwsLogsAdapter' +import {AzureNoSqlAdapter} from './adapter-azure/AzureNoSqlAdapter' +import {AwsDynamoDbAdapter} from './adapter-aws/AwsDynamoDbAdapter' +import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' +import {AzureServiceBusAdapter} from './adapter-azure/AzureServiceBusAdapter' +import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' +import {AzureAksAdapter} from './adapter-azure/AzureAksAdapter' +import {AzureComputeAdapter} from './adapter-azure/AzureComputeAdapter' +import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' +import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' +import {GcpCloudSqlAdapter} from './adapter-gcp/GcpCloudSqlAdapter' +import {GcpGkeAdapter} from './adapter-gcp/GcpGkeAdapter' +import {GcpCloudRunAdapter} from './adapter-gcp/GcpCloudRunAdapter' +import {GcpPubSubAdapter} from './adapter-gcp/GcpPubSubAdapter' +import {GcpSecretManagerAdapter} from './adapter-gcp/GcpSecretManagerAdapter' +import {GcpSchedulerAdapter} from './adapter-gcp/GcpSchedulerAdapter' +import {AwsSqsAdapter} from './adapter-aws/AwsSqsAdapter' +import {CloudProxyService} from './service/CloudProxyService' +import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter' +import {AzureKeyVaultAdapter} from './adapter-azure/AzureKeyVaultAdapter' +import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter' +import {AwsParameterStoreAdapter} from './adapter-aws/AwsParameterStoreAdapter' +import {AwsKmsAdapter} from './adapter-aws/AwsKmsAdapter' +import {AwsStepFunctionsAdapter} from './adapter-aws/AwsStepFunctionsAdapter' +import {AwsLoadBalancingAdapter} from './adapter-aws/AwsLoadBalancingAdapter' +import {AwsEventBridgeAdapter} from './adapter-aws/AwsEventBridgeAdapter' +import {AwsIamAdapter} from './adapter-aws/AwsIamAdapter' +import {AwsApiGatewayAdapter} from './adapter-aws/AwsApiGatewayAdapter' +import {AwsCloudFormationAdapter} from './adapter-aws/AwsCloudFormationAdapter' +import {AwsSecretsAdapter} from './adapter-aws/AwsSecretsAdapter' +import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter' +import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter' +import {awsClientsForAccount, resolveAccountId} from './aws' +import {createEc2Service} from './services/ec2' +import {createEksService} from './services/eks' +import {createRdsService} from './services/rds' + +/** + * Build the adapter registry for an account. The account id drives the AWS SDK + * credentials (see aws.ts), so every AWS call is isolated to that account; Azure + * and GCP adapters use their own runtime auth model and are account-neutral. + * + * Exported separately from the service so tests can assert registry contents — + * notably that every adapter implements what its schema advertises — without + * reaching into private state. + */ +export function createCloudAdapterRegistry(accountId?: string | null): CloudAdapterRegistry { + const clients = awsClientsForAccount(accountId) + const ec2Service = createEc2Service(clients.ec2) + + return new CloudAdapterRegistry([ + new AwsStorageAdapter(clients.s3), + new AwsDynamoDbAdapter(clients.dynamodb), + new AwsEksAdapter(createEksService(clients.eks)), + new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds), + new AwsComputeAdapter(ec2Service), + new AwsNetworkingAdapter(ec2Service), + new AwsServerlessAdapter(clients.lambda), + new AwsLogsAdapter(clients.logs), + new AwsParameterStoreAdapter(clients.ssm), + new AwsKmsAdapter(clients.kms), + new AwsStepFunctionsAdapter(clients.sfn), + new AwsLoadBalancingAdapter(clients.elbv2), + new AwsEventBridgeAdapter(clients.eventbridge), + new AwsIamAdapter(clients.iam), + new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)), + new AwsCloudFormationAdapter(clients.cloudformation), + new AwsSecretsAdapter(clients.secretsManager), + new AwsSesAdapter(), + new AwsAppConfigAdapter(clients.appConfig), + new AzureStorageAdapter(), + new AzureServiceBusAdapter(), + new AzureDatabaseAdapter(), + new AzureAksAdapter(), + new AzureNoSqlAdapter(), + new AzureComputeAdapter(), + new GcpStorageAdapter(), + new GcpCloudFunctionsAdapter(), + new GcpCloudSqlAdapter(), + new GcpGkeAdapter(), + new GcpCloudRunAdapter(), + new GcpPubSubAdapter(), + new GcpSecretManagerAdapter(), + new GcpSchedulerAdapter(), + new AwsSqsAdapter(clients.sqs), + new AzureServerlessAdapter(), + new AzureKeyVaultAdapter(), + ]) +} + +export function createCloudProxyService(accountId?: string | null): CloudProxyService { + return new CloudProxyService(createCloudAdapterRegistry(accountId)) +} + +const serviceCache = new Map() + +/** Return a cached account-scoped CloudProxyService, building it on first use. */ +export function serviceForAccount(accountId?: string | null): CloudProxyService { + const id = resolveAccountId(accountId) + let service = serviceCache.get(id) + if (!service) { + service = createCloudProxyService(id) + serviceCache.set(id, service) + } + return service +} diff --git a/packages/api/src/gcp.test.ts b/packages/api/src/gcp.test.ts new file mode 100644 index 0000000..e3c6d09 --- /dev/null +++ b/packages/api/src/gcp.test.ts @@ -0,0 +1,127 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpRestRuntimeClient, gcpEndpoint, gcpLocation, gcpProject} from './gcp' +import {NotFoundError, NotImplementedByRuntimeError, RuntimeUnavailableError, ValidationError} from './cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + const calls: string[] = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push(String(url)) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function client(): GcpRestRuntimeClient { + return new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1') +} + +describe('environment defaults', () => { + test('falls back to the documented local endpoints', () => { + expect(gcpEndpoint()).toBe(ENDPOINT) + expect(gcpProject()).toBe('floci-local') + expect(gcpLocation()).toBe('us-central1') + }) +}) + +describe('GcpRestRuntimeClient.fetch', () => { + test('prefixes the endpoint and returns the response', async () => { + const calls = stubFetch(() => new Response('{"ok":true}', {status: 200})) + const res = await client().fetch('/storage/v1/b') + + expect(calls[0]).toBe(`${ENDPOINT}/storage/v1/b`) + expect(res?.status).toBe(200) + }) + + test('turns a transport failure into a runtime-unavailable error', async () => { + stubFetch(() => { + throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}) + }) + await expect(client().fetch('/storage/v1/b')).rejects.toBeInstanceOf(RuntimeUnavailableError) + }) + + test('returns null for a 404 when asked to', async () => { + stubFetch(() => new Response('not found', {status: 404})) + await expect(client().fetch('/x', {}, {emptyOnNotFound: true})).resolves.toBeNull() + }) + + test('throws a typed error for a 404 otherwise', async () => { + stubFetch(() => new Response('not found', {status: 404})) + await expect(client().fetch('/x')).rejects.toBeInstanceOf(NotFoundError) + }) + + test('maps a runtime 501 to not-implemented', async () => { + stubFetch(() => new Response('nope', {status: 501})) + await expect(client().fetch('/x')).rejects.toBeInstanceOf(NotImplementedByRuntimeError) + }) + + test('maps a runtime 400 to a validation error', async () => { + stubFetch(() => new Response('bad', {status: 400})) + await expect(client().fetch('/x')).rejects.toBeInstanceOf(ValidationError) + }) + + test("surfaces Google's structured error message as detail", async () => { + // The previous per-adapter fetch discarded the body entirely, leaving + // callers with a bare "HTTP 500". + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'Bucket nope does not exist', status: 'NOT_FOUND'}}), + {status: 404, headers: {'content-type': 'application/json'}}, + )) + + await expect(client().fetch('/storage/v1/b/nope')).rejects.toThrow('Bucket nope does not exist') + }) + + test('falls back to the raw body when it is not a Google error envelope', async () => { + stubFetch(() => new Response('Resource not found', {status: 404})) + await expect(client().fetch('/x')).rejects.toThrow('Resource not found') + }) +}) + +describe('GcpRestRuntimeClient.json', () => { + test('parses the body', async () => { + stubFetch(() => new Response('{"kind":"storage#buckets"}', {status: 200})) + await expect(client().json('/storage/v1/b')).resolves.toEqual({kind: 'storage#buckets'}) + }) + + test('returns null when the resource is absent', async () => { + stubFetch(() => new Response('', {status: 404})) + await expect(client().json('/x', {}, {emptyOnNotFound: true})).resolves.toBeNull() + }) +}) + +describe('GcpRestRuntimeClient.health', () => { + test("probes the runtime's own health endpoint", async () => { + const calls = stubFetch(() => new Response('{"services":{"gcs":"running"}}', {status: 200})) + await client().health() + + // Deliberately not /_floci/health, which this runtime 404s. + expect(calls[0]).toBe(`${ENDPOINT}/_floci-gcp/health`) + }) + + test('treats any non-5xx as reachable', async () => { + // "Responded at all" is the signal — an older runtime without this exact + // path still proves the process is up and routing. + for (const status of [200, 401, 403, 404]) { + stubFetch(() => new Response('', {status})) + await expect(client().health()).resolves.toBeUndefined() + } + }) + + test('reports 5xx as unavailable', async () => { + stubFetch(() => new Response('', {status: 503})) + await expect(client().health()).rejects.toBeInstanceOf(RuntimeUnavailableError) + }) + + test('reports a refused connection as unavailable', async () => { + stubFetch(() => { + throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}) + }) + await expect(client().health()).rejects.toThrow(`Cannot reach Floci-GCP at ${ENDPOINT}`) + }) +}) diff --git a/packages/api/src/gcp.ts b/packages/api/src/gcp.ts new file mode 100644 index 0000000..fb42ab6 --- /dev/null +++ b/packages/api/src/gcp.ts @@ -0,0 +1,129 @@ +import {RuntimeUnavailableError, httpStatusToCloudError} from './cloud-spi/errors' + +/** Floci-GCP's health endpoint; deliberately not the `/_floci/health` core uses. */ +const GCP_HEALTH_PATH = '/_floci-gcp/health' + +export interface GcpRuntimeFetchOptions { + emptyOnNotFound?: boolean +} + +/** + * Transport seam for the Floci-GCP runtime, mirroring `AzureRuntimeClient`. + * + * Both GCP adapters previously carried their own copy of this and threw away the + * response body, so a failure surfaced as a bare "HTTP 500" with no explanation. + */ +export interface GcpRuntimeClient { + readonly endpoint: string + readonly project: string + readonly location: string + fetch(path: string, init?: RequestInit, options?: GcpRuntimeFetchOptions): Promise + json(path: string, init?: RequestInit, options?: GcpRuntimeFetchOptions): Promise + health(): Promise +} + +/** Google's REST error envelope. */ +interface GcpErrorEnvelope { + error?: {code?: number; message?: string; status?: string} +} + +export class GcpRestRuntimeClient implements GcpRuntimeClient { + constructor( + readonly endpoint: string = gcpEndpoint(), + readonly project: string = gcpProject(), + readonly location: string = gcpLocation(), + ) {} + + async fetch(path: string, init: RequestInit = {}, options: GcpRuntimeFetchOptions = {}): Promise { + let res: Response + try { + res = await globalThis.fetch(`${this.endpoint}${path}`, init) + } catch (error) { + throw new RuntimeUnavailableError( + `Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`, + {cause: error}, + ) + } + + if (options.emptyOnNotFound && res.status === 404) return null + if (!res.ok) { + const detail = await readErrorDetail(res) + throw httpStatusToCloudError( + res.status, + `GCP runtime request failed: HTTP ${res.status} ${path}${detail ? ` - ${detail}` : ''}`, + ) + } + + return res + } + + async json(path: string, init: RequestInit = {}, options: GcpRuntimeFetchOptions = {}): Promise { + const res = await this.fetch(path, init, options) + if (!res) return null + return res.json() as Promise + } + + /** + * Liveness probe against the runtime's own health endpoint. + * + * Note the path is `/_floci-gcp/health`, not the `/_floci/health` that Floci + * core uses — `/` and `/_floci/health` both 404 on this runtime. + */ + async health(): Promise { + const url = `${this.endpoint}${GCP_HEALTH_PATH}` + let res: Response + try { + res = await globalThis.fetch(url, {method: 'GET'}) + } catch (error) { + throw new RuntimeUnavailableError( + `Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`, + {cause: error}, + ) + } + if (res.status >= 500) { + throw new RuntimeUnavailableError(`Floci-GCP at ${this.endpoint} returned HTTP ${res.status}`) + } + } +} + +export function gcpEndpoint(): string { + return process.env.FLOCI_GCP_ENDPOINT ?? process.env.FLOCI_GP_ENDPOINT ?? 'http://localhost:4588' +} + +export function gcpProject(): string { + return process.env.FLOCI_GCP_PROJECT ?? 'floci-local' +} + +export function gcpLocation(): string { + return process.env.FLOCI_GCP_LOCATION ?? 'us-central1' +} + +export const gcp = new GcpRestRuntimeClient() + +export async function checkGcpRuntime(endpoint: string = gcpEndpoint()): Promise { + await new GcpRestRuntimeClient(endpoint).health() +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** Prefer Google's structured error message over the raw body. */ +async function readErrorDetail(res: Response): Promise { + const text = await safeResponseText(res) + if (!text) return '' + try { + const parsed = JSON.parse(text) as GcpErrorEnvelope + return parsed.error?.message ?? text + } catch { + return text + } +} + +async function safeResponseText(res: Response): Promise { + try { + return (await res.text()).trim().slice(0, 500) + } catch { + return '' + } +} diff --git a/packages/api/src/index.cors.test.ts b/packages/api/src/index.cors.test.ts new file mode 100644 index 0000000..006b169 --- /dev/null +++ b/packages/api/src/index.cors.test.ts @@ -0,0 +1,80 @@ +import {describe, expect, test} from 'bun:test' +import server from './index' + +const KMS_ENCRYPT_URL = 'http://localhost/api/clouds/aws/services/kms/resources/key-1/encrypt' +const AZURE_KMS_DECRYPT_URL = 'http://localhost/api/clouds/azure/services/kms/resources/key-1/decrypt' +const KMS_SCHEMA_URL = 'http://localhost/api/clouds/aws/services/kms/schema' +const TRUSTED_ORIGIN = 'http://localhost:3000' +const UNTRUSTED_ORIGIN = 'https://example.invalid' + +describe('credentialed route CORS', () => { + test('allows a trusted KMS crypto preflight', async () => { + const res = await server.fetch(new Request(KMS_ENCRYPT_URL, { + method: 'OPTIONS', + headers: { + origin: TRUSTED_ORIGIN, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type', + }, + })) + + expect(res.headers.get('access-control-allow-origin')).toBe(TRUSTED_ORIGIN) + expect(res.headers.get('access-control-allow-methods')).toContain('POST') + }) + + test('does not allow an untrusted or null KMS crypto origin', async () => { + for (const url of [KMS_ENCRYPT_URL, AZURE_KMS_DECRYPT_URL]) { + for (const origin of [UNTRUSTED_ORIGIN, 'null']) { + const res = await server.fetch(new Request(url, { + method: 'OPTIONS', + headers: {origin, 'access-control-request-method': 'POST'}, + })) + expect(res.headers.get('access-control-allow-origin')).toBeNull() + } + } + }) + + test('applies the allow-list to a real KMS crypto request', async () => { + const body = JSON.stringify({}) + const trusted = await server.fetch(new Request(KMS_ENCRYPT_URL, { + method: 'POST', + headers: {origin: TRUSTED_ORIGIN, 'content-type': 'application/json'}, + body, + })) + const untrusted = await server.fetch(new Request(KMS_ENCRYPT_URL, { + method: 'POST', + headers: {origin: UNTRUSTED_ORIGIN, 'content-type': 'application/json'}, + body, + })) + + expect(trusted.status).toBe(400) + expect(trusted.headers.get('access-control-allow-origin')).toBe(TRUSTED_ORIGIN) + expect(untrusted.status).toBe(400) + expect(untrusted.headers.get('access-control-allow-origin')).toBeNull() + }) + + test('keeps same-origin requests usable and unrelated routes permissive', async () => { + const sameOrigin = await server.fetch(new Request(KMS_ENCRYPT_URL, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({}), + })) + const unrelated = await server.fetch(new Request('http://localhost/api/clouds', { + headers: {origin: UNTRUSTED_ORIGIN}, + })) + + expect(sameOrigin.status).toBe(400) + expect(sameOrigin.headers.get('access-control-allow-origin')).toBeNull() + expect(unrelated.status).toBe(200) + expect(unrelated.headers.get('access-control-allow-origin')).toBe('*') + }) + + test('keeps existing non-crypto KMS routes permissive', async () => { + const schema = await server.fetch(new Request(KMS_SCHEMA_URL, { + headers: {origin: UNTRUSTED_ORIGIN}, + })) + + expect(schema.status).toBe(200) + expect(schema.headers.get('access-control-allow-origin')).toBe('*') + }) +}) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..d9200e3 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,59 @@ +import "dotenv/config"; +import { Hono } from "hono"; +import { serveStatic } from "hono/bun"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; +import eks from "./routes/eks"; +import ec2 from "./routes/ec2"; +import secretsmanager from "./routes/secretsmanager"; +import clouds from "./routes/clouds"; +const app = new Hono(); + +// Secrets Manager and KMS crypto routes handle sensitive values with server-side +// AWS credentials, so an unrestricted `cors()` would let any web page in the +// browser drive them cross-origin. Restrict CORS to trusted origins for those +// routes and keep the permissive default elsewhere. In production the frontend +// is served from the same origin (see serveStatic below), so same-origin requests +// are unaffected; cross-origin callers must be explicitly allow-listed. +// +// A single CORS middleware handles both cases: stacking two `cors()` calls +// would either short-circuit the OPTIONS preflight in the wrong handler or let +// the later one overwrite `Access-Control-Allow-Origin` on real requests. +const trustedOrigins = ( + process.env.CORS_ALLOWED_ORIGINS ?? "http://localhost:3000" +) + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + +app.use( + "*", + cors({ + origin: (origin, c) => + isSensitiveCredentialRoute(c.req.path) + ? trustedOrigins.includes(origin) + ? origin + : null + : "*", + }), +); +app.use("*", logger()); + +app.route("/api/eks", eks); +app.route("/api/ec2", ec2); +app.route("/api/secretsmanager", secretsmanager); +app.route("/api/clouds", clouds); + +// Serve static frontend files when public/ directory is present (production) +app.use("*", serveStatic({ root: "./public" })); +app.get("*", serveStatic({ path: "./public/index.html" })); + +const port = Number(process.env.PORT ?? 4501); +export default { port, fetch: app.fetch }; + +function isSensitiveCredentialRoute(path: string): boolean { + return ( + path.startsWith("/api/secretsmanager") || + /^\/api\/clouds\/[^/]+\/services\/kms\/resources\/[^/]+\/(?:encrypt|decrypt)$/.test(path) + ); +} diff --git a/packages/api/src/registry/CloudAdapterRegistry.test.ts b/packages/api/src/registry/CloudAdapterRegistry.test.ts new file mode 100644 index 0000000..1bfc20e --- /dev/null +++ b/packages/api/src/registry/CloudAdapterRegistry.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, test} from 'bun:test' +import {awsStorageSchema} from '../cloud-spi/storageSchema' +import type {CloudResource, CloudServiceAdapter, CreateResourceInput} from '../cloud-spi/types' +import {CloudAdapterRegistry} from './CloudAdapterRegistry' + +const adapter: CloudServiceAdapter = { + cloud: 'aws', + service: 'storage', + schema: awsStorageSchema, + list: async () => [], + get: async () => null, + create: async (_input: CreateResourceInput): Promise => ({ + id: 'demo', + name: 'demo', + cloud: 'aws', + service: 'storage', + type: 'bucket', + region: null, + createdAt: null, + metadata: {}, + }), + delete: async () => {}, +} + +describe('CloudAdapterRegistry', () => { + test('returns registered adapters by cloud and service', () => { + const registry = new CloudAdapterRegistry([adapter]) + + expect(registry.get('aws', 'storage')).toBe(adapter) + expect(registry.get('azure', 'storage')).toBeUndefined() + }) + + test('lists services registered for a cloud', () => { + const registry = new CloudAdapterRegistry([adapter]) + + expect(registry.servicesFor('aws')).toEqual(['storage']) + expect(registry.servicesFor('azure')).toEqual([]) + }) +}) diff --git a/packages/api/src/registry/CloudAdapterRegistry.ts b/packages/api/src/registry/CloudAdapterRegistry.ts new file mode 100644 index 0000000..a68b17e --- /dev/null +++ b/packages/api/src/registry/CloudAdapterRegistry.ts @@ -0,0 +1,29 @@ +import type {CloudProvider, CloudServiceAdapter, CloudServiceType} from '../cloud-spi/types' + +export class CloudAdapterRegistry { + private readonly adapters = new Map() + + constructor(adapters: CloudServiceAdapter[] = []) { + for (const adapter of adapters) { + this.register(adapter) + } + } + + register(adapter: CloudServiceAdapter): void { + this.adapters.set(this.key(adapter.cloud, adapter.service), adapter) + } + + get(cloud: CloudProvider, service: CloudServiceType): CloudServiceAdapter | undefined { + return this.adapters.get(this.key(cloud, service)) + } + + servicesFor(cloud: CloudProvider): CloudServiceType[] { + return [...this.adapters.values()] + .filter((adapter) => adapter.cloud === cloud) + .map((adapter) => adapter.service) + } + + private key(cloud: CloudProvider, service: CloudServiceType): string { + return `${cloud}:${service}` + } +} diff --git a/packages/api/src/routes/clouds.search.test.ts b/packages/api/src/routes/clouds.search.test.ts new file mode 100644 index 0000000..424c58c --- /dev/null +++ b/packages/api/src/routes/clouds.search.test.ts @@ -0,0 +1,105 @@ +/** + * Regression tests for issue #205: + * "Search bar in top nav doesn't update the `search` query param" + * + * These tests verify that the `GET /clouds/:cloud/services/:service/resources` + * route correctly reads the `search` URL query parameter and forwards it to + * `CloudProxyService.listResources()` as `query.search`. + */ +import {describe, expect, test} from 'bun:test' +import {createCloudRoutes} from './clouds' +import {CloudProxyService} from '../service/CloudProxyService' +import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' +import type {CloudProvider, CloudResource, CloudServiceType, ResourceQuery} from '../cloud-spi/types' + +const STUB_RESOURCE: CloudResource = { + id: 'stub-bucket-1', + name: 'stub-bucket-1', + cloud: 'aws', + service: 'storage', + type: 'bucket', + region: 'us-east-1', + createdAt: '2026-09-01T00:00:00Z', + status: 'active', + metadata: {}, +} + +class StubStorageAdapter { + readonly cloud: CloudProvider = 'aws' + readonly service: CloudServiceType = 'storage' + capturedQuery: ResourceQuery | undefined + + schema() { + return { + cloud: 'aws' as CloudProvider, + service: 'storage' as CloudServiceType, + displayName: 'Storage', + actions: ['list'] as const, + columns: [{name: 'id', label: 'ID', path: 'id'}], + availability: 'available' as const, + } + } + + async list(query?: ResourceQuery): Promise { + this.capturedQuery = query + const term = query?.search?.toLowerCase() + if (!term) return [STUB_RESOURCE] + return STUB_RESOURCE.name.toLowerCase().includes(term) ? [STUB_RESOURCE] : [] + } +} + +function buildApp(adapter: StubStorageAdapter) { + const registry = { + get: (cloud: CloudProvider, service: CloudServiceType) => + cloud === 'aws' && service === 'storage' ? adapter : undefined, + servicesFor: () => [], + clouds: () => [], + } as unknown as CloudAdapterRegistry + + const service = new CloudProxyService(registry) + return createCloudRoutes(service) +} + +describe('GET /clouds/:cloud/services/:service/resources - search query param (#205)', () => { + test('forwards the search param to listResources when provided', async () => { + const adapter = new StubStorageAdapter() + const app = buildApp(adapter) + + const response = await app.request('/aws/services/storage/resources?search=stub') + + expect(response.status).toBe(200) + expect(adapter.capturedQuery?.search).toBe('stub') + }) + + test('passes undefined search when no param supplied', async () => { + const adapter = new StubStorageAdapter() + const app = buildApp(adapter) + + const response = await app.request('/aws/services/storage/resources') + + expect(response.status).toBe(200) + expect(adapter.capturedQuery?.search).toBeUndefined() + }) + + test('filters resources by search term via adapter', async () => { + const adapter = new StubStorageAdapter() + const app = buildApp(adapter) + + const hitResponse = await app.request('/aws/services/storage/resources?search=stub') + const hitBody = await hitResponse.json() as CloudResource[] + expect(hitBody).toHaveLength(1) + + const missResponse = await app.request('/aws/services/storage/resources?search=dynamo') + const missBody = await missResponse.json() as CloudResource[] + expect(missBody).toHaveLength(0) + }) + + test('returns 404 for unknown cloud', async () => { + const adapter = new StubStorageAdapter() + const app = buildApp(adapter) + + const response = await app.request('/unknown-cloud/services/storage/resources?search=test') + + expect(response.status).toBe(404) + }) +}) diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts new file mode 100644 index 0000000..70e73ae --- /dev/null +++ b/packages/api/src/routes/clouds.test.ts @@ -0,0 +1,1531 @@ +import {describe, expect, test} from 'bun:test' +import {Hono} from 'hono' +import {NotImplementedByRuntimeError, RuntimeError, RuntimeUnavailableError, ValidationError} from '../cloud-spi/errors' +import type { + ChildCollection, + ChildItem, + CollectionPage, + DocumentStoreAdapter, + ItemStoreAdapter, +} from '../cloud-spi/childCollections' +import {awsDatabaseSchema, azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {awsLogsSchema} from '../cloud-spi/logsSchema' +import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' +import {awsDynamoDbSchema} from '../cloud-spi/dynamodbSchema' +import {awsEksSchema} from '../cloud-spi/eksSchema' +import {awsKmsSchema} from '../cloud-spi/kmsSchema' +import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' +import {awsSesEmailSchema} from '../cloud-spi/emailSchema' +import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' +import type { + AppConfigConfigurationProfile, + AppConfigDeployment, + AppConfigDeploymentStrategy, + AppConfigEnvironment, + AppConfigHostedConfigurationVersion, + CloudProvider, + CloudResource, + CloudServiceAdapter, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateDatabaseSnapshotInput, + CreateResourceInput, + DatabaseSnapshot, + LogsInsightsQueryResult, + NoSqlItem, +} from '../cloud-spi/types' +import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' +import {CloudProxyService} from '../service/CloudProxyService' +import type {RuntimeProbe} from '../service/runtimeProbe' +import {createCloudRoutes} from './clouds' + +function mockAdapter(cloud: CloudProvider, overrides: Partial = {}): CloudServiceAdapter { + return { + cloud, + service: 'storage', + schema: cloud === 'aws' ? awsStorageSchema : cloud === 'gcp' ? gcpStorageSchema : azureStorageSchema, + list: async () => [], + get: async () => null, + create: async (_input: CreateResourceInput): Promise => ({ + id: 'created', + name: 'created', + cloud, + service: 'storage', + type: cloud === 'azure' ? 'container' : 'bucket', + region: null, + createdAt: null, + metadata: {}, + }), + delete: async () => {}, + listObjects: async (resourceId: string, prefix = '') => ({ + prefix, + objects: [{ + key: `${resourceId}/object.txt`, + name: 'object.txt', + type: 'object', + size: 12, + lastModified: null, + metadata: {}, + }], + }), + ...overrides, + } +} + +/** + * Runtime probes make real HTTP calls to the emulator endpoints, so tests inject + * stubs — otherwise status assertions depend on whether a container happens to + * be listening, which passes locally and fails in CI. + */ +function stubProbes(overrides: Partial> = {}): Record { + const reachable: RuntimeProbe = async () => {} + return {aws: reachable, azure: reachable, gcp: reachable, ...overrides} +} + +function unreachable(message: string): RuntimeProbe { + return async () => { + throw new RuntimeUnavailableError(message) + } +} + +function appWithRoutes( + adapters: CloudServiceAdapter[] = [mockAdapter('aws'), mockAdapter('azure')], + probes: Record = stubProbes(), +) { + const app = new Hono() + const registry = new CloudAdapterRegistry(adapters) + app.route('/api/clouds', createCloudRoutes(new CloudProxyService(registry, probes))) + return app +} + +const databaseSnapshot: DatabaseSnapshot = { + id: 'orders-db-snapshot-1', + name: 'orders-db-snapshot-1', + instanceIdentifier: 'orders-db', + status: 'available', + engine: 'postgres', + version: '16.4', + createdAt: '2026-08-14T09:30:00.000Z', + metadata: {snapshotType: 'manual'}, +} + +describe('database snapshot routes', () => { + test('lists snapshots with the exact optional instance filter', async () => { + let delegatedIdentifier: string | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + listDatabaseSnapshots: async (instanceIdentifier) => { + delegatedIdentifier = instanceIdentifier + return [databaseSnapshot] + }, + })]) + + const res = await app.request( + '/api/clouds/aws/services/database/snapshots?instanceIdentifier=orders-db', + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual([databaseSnapshot]) + expect(delegatedIdentifier).toBe('orders-db') + }) + + test('creates a snapshot with the raw two-field input', async () => { + let delegatedInput: CreateDatabaseSnapshotInput | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + createDatabaseSnapshot: async (input) => { + delegatedInput = input + return databaseSnapshot + }, + })]) + const input = { + instanceIdentifier: 'orders-db', + snapshotIdentifier: 'orders-db-snapshot-1', + } + + const res = await app.request('/api/clouds/aws/services/database/snapshots', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify(input), + }) + + expect(res.status).toBe(201) + expect(await res.json()).toEqual(databaseSnapshot) + expect(delegatedInput).toEqual(input) + }) + + test('returns 501 when the database adapter lacks either snapshot method', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + })]) + + const listRes = await app.request('/api/clouds/aws/services/database/snapshots') + const createRes = await app.request('/api/clouds/aws/services/database/snapshots', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + instanceIdentifier: 'orders-db', + snapshotIdentifier: 'orders-db-snapshot-1', + }), + }) + + expect(listRes.status).toBe(501) + expect((await listRes.json()).code).toBe('operation_not_supported') + expect(createRes.status).toBe(501) + expect((await createRes.json()).code).toBe('operation_not_supported') + }) + + test('maps adapter snapshot validation errors to 400', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + createDatabaseSnapshot: async () => { + throw new ValidationError('snapshotIdentifier is required') + }, + })]) + + const res = await app.request('/api/clouds/aws/services/database/snapshots', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({instanceIdentifier: 'orders-db', snapshotIdentifier: ''}), + }) + expect(res.status).toBe(400) + const body = await res.json() + + expect(body).toMatchObject({ + code: 'invalid_request', + message: 'snapshotIdentifier is required', + }) + }) + + test('maps AWS UnsupportedOperation snapshot errors through the shared mapper', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + listDatabaseSnapshots: async () => { + throw Object.assign(new Error('RDS snapshots are unavailable'), { + name: 'UnsupportedOperation', + $fault: 'client', + $metadata: {httpStatusCode: 500}, + }) + }, + })]) + + const res = await app.request('/api/clouds/aws/services/database/snapshots') + expect(res.status).toBe(501) + const body = await res.json() + + expect(body.code).toBe('operation_not_implemented') + expect(body.detail).toBe('RDS snapshots are unavailable') + }) + + test('lists orderable instance classes with engine filter', async () => { + let delegatedEngine: string | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + listDatabaseOrderableInstanceClasses: async (engine) => { + delegatedEngine = engine + return ['db.t3.micro', 'db.m8g.large'] + }, + })]) + + const res = await app.request( + '/api/clouds/aws/services/database/orderable-classes?engine=postgres', + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(['db.t3.micro', 'db.m8g.large']) + expect(delegatedEngine).toBe('postgres') + }) +}) + +describe('cloud schema routes', () => { + test('returns AWS storage schema', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('aws') + expect(body.service).toBe('storage') + expect(body.fields[0].name).toBe('bucketName') + }) + + test('returns Azure storage schema', async () => { + const res = await appWithRoutes().request('/api/clouds/azure/services/storage/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('azure') + expect(body.service).toBe('storage') + expect(body.fields[0].name).toBe('containerName') + }) + + test('returns Azure database schema when the adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'database', + schema: azureDatabaseSchema, + })]) + const res = await app.request('/api/clouds/azure/services/database/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('azure') + expect(body.service).toBe('database') + // Cosmos moved to the nosql category; database now covers Azure SQL and PostgreSQL. + expect(body.displayName).toBe('Azure Databases') + }) + + test('returns Azure Cosmos NoSQL schema when the adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + })]) + const res = await app.request('/api/clouds/azure/services/nosql/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.service).toBe('nosql') + expect(body.displayName).toBe('Azure Cosmos DB NoSQL') + }) + + test('returns AWS DynamoDB schema for the nosql service', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'nosql', + schema: awsDynamoDbSchema, + })]) + const res = await app.request('/api/clouds/aws/services/nosql/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('aws') + expect(body.service).toBe('nosql') + expect(body.displayName).toBe('DynamoDB') + }) + + test('returns GCP storage schema when the adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('gcp', {service: 'storage', schema: gcpStorageSchema})]) + const res = await app.request('/api/clouds/gcp/services/storage/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('gcp') + expect(body.service).toBe('storage') + expect(body.fields[0].name).toBe('bucketName') + }) + + // Previously a static schema was served for any known service even with no + // adapter behind it, so the UI rendered a table that then 501'd on every call. + test('does not serve a schema for a service with no registered adapter', async () => { + const app = appWithRoutes([mockAdapter('aws')]) + + for (const path of [ + '/api/clouds/azure/services/k8s/schema', + '/api/clouds/gcp/services/k8s/schema', + '/api/clouds/gcp/services/database/schema', + '/api/clouds/azure/services/compute/schema', + ]) { + const res = await app.request(path) + expect(res.status).toBe(404) + expect((await res.json()).error).toBe('Schema not available') + } + }) + + test('rejects a service slug that is not in the catalog', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/queue/schema') + + expect(res.status).toBe(404) + expect((await res.json()).error).toBe('Unknown cloud or service') + }) + + test('returns AWS cloud status', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/status') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('aws') + expect(body.adapterRegistered).toBe(true) + expect(body.runtime).toBe('reachable') + }) + + test('returns GCP runtime status without a registered adapter', async () => { + const app = appWithRoutes( + [mockAdapter('aws'), mockAdapter('azure')], + stubProbes({gcp: unreachable('Cannot reach Floci-GCP at http://localhost:4588')}), + ) + const res = await app.request('/api/clouds/gcp/status') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.cloud).toBe('gcp') + expect(body.adapterRegistered).toBe(false) + expect(body.runtime).toBe('unavailable') + expect(body.endpoint).toBe('http://localhost:4588') + expect(body.error).toContain('Cannot reach Floci-GCP') + }) + + test('cloud status reflects the runtime probe, not one adapter listing', async () => { + // Previously a cloud whose storage adapter could list reported "reachable" + // no matter what state the runtime was actually in. + const app = appWithRoutes( + [mockAdapter('aws')], + stubProbes({aws: unreachable('Cannot reach Floci core at http://localhost:4566')}), + ) + const body = await (await app.request('/api/clouds/aws/status')).json() + + expect(body.runtime).toBe('unavailable') + expect(body.adapterRegistered).toBe(true) + }) + + test('lists storage objects through the cloud adapter', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/storage/resources/demo/objects') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.objects).toHaveLength(1) + expect(body.objects[0].name).toBe('object.txt') + }) + + test('lists Cosmos containers through the cloud NoSQL adapter', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + listCosmosContainers: async (databaseId: string): Promise => [{ + id: 'items', + name: 'items', + databaseId, + partitionKeyPath: '/id', + createdAt: null, + metadata: {}, + }], + })]) + + const res = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body[0].databaseId).toBe('appdb') + expect(body[0].name).toBe('items') + }) + + test('lists DynamoDB records through the nosql adapter', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'nosql', + schema: awsDynamoDbSchema, + listNoSqlItems: async (resourceId: string): Promise => [{ + id: '{"pk":"item-1"}', + key: {pk: 'item-1'}, + document: {pk: 'item-1', name: 'First item', table: resourceId}, + }], + })]) + + const res = await app.request('/api/clouds/aws/services/nosql/resources/orders/items') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body[0].key).toEqual({pk: 'item-1'}) + expect(body[0].document.table).toBe('orders') + }) + + test('puts a DynamoDB record through the nosql adapter', async () => { + const calls: Array<{resourceId: string; document: Record}> = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'nosql', + schema: awsDynamoDbSchema, + putNoSqlItem: async (resourceId, document): Promise => { + calls.push({resourceId, document}) + return {id: JSON.stringify({pk: document.pk}), key: {pk: document.pk}, document} + }, + })]) + + const res = await app.request('/api/clouds/aws/services/nosql/resources/orders/items', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({pk: 'item-1', name: 'First item'}), + }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.key).toEqual({pk: 'item-1'}) + expect(calls).toEqual([{resourceId: 'orders', document: {pk: 'item-1', name: 'First item'}}]) + }) + + test('clears the SES mailbox through the email adapter', async () => { + let cleared = false + const app = appWithRoutes([mockAdapter('aws', { + service: 'email', + schema: awsSesEmailSchema, + clearEmailInbox: async () => { + cleared = true + }, + })]) + + const res = await app.request('/api/clouds/aws/services/email/inbox', {method: 'DELETE'}) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ok: true}) + expect(cleared).toBeTrue() + }) + + test('creates and deletes Cosmos databases through the cloud NoSQL adapter', async () => { + const deleted: string[] = [] + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + create: async (input: CreateResourceInput): Promise => ({ + id: String(input.values.databaseName), + name: String(input.values.databaseName), + cloud: 'azure', + service: 'nosql', + type: 'cosmos-database', + region: null, + createdAt: null, + metadata: {}, + }), + delete: async (id: string) => { + deleted.push(id) + }, + })]) + + const createRes = await app.request('/api/clouds/azure/services/nosql/resources', { + method: 'POST', + body: JSON.stringify({databaseName: 'appdb'}), + }) + const created = await createRes.json() + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb', {method: 'DELETE'}) + + expect(createRes.status).toBe(201) + expect(created.type).toBe('cosmos-database') + expect(created.name).toBe('appdb') + expect(deleteRes.status).toBe(200) + expect(deleted).toEqual(['appdb']) + }) + + test('creates and deletes Cosmos containers through the cloud NoSQL adapter', async () => { + const deleted: Array<{databaseId: string; containerId: string}> = [] + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + createCosmosContainer: async (databaseId: string, input: CreateResourceInput): Promise => ({ + id: String(input.values.containerName), + name: String(input.values.containerName), + databaseId, + partitionKeyPath: String(input.values.partitionKeyPath), + createdAt: null, + metadata: {}, + }), + deleteCosmosContainer: async (databaseId: string, containerId: string) => { + deleted.push({databaseId, containerId}) + }, + })]) + + const createRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers', { + method: 'POST', + body: JSON.stringify({containerName: 'items', partitionKeyPath: '/category'}), + }) + const created = await createRes.json() + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items', {method: 'DELETE'}) + + expect(createRes.status).toBe(201) + expect(created.databaseId).toBe('appdb') + expect(created.partitionKeyPath).toBe('/category') + expect(deleteRes.status).toBe(200) + expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items'}]) + }) + + test('upserts, deletes, and queries Cosmos items through the cloud NoSQL adapter', async () => { + const deleted: Array<{databaseId: string; containerId: string; itemId: string; partitionKey?: string | null}> = [] + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + upsertCosmosItem: async (databaseId: string, containerId: string, document: Record): Promise => ({ + id: String(document.id), + databaseId, + containerId, + partitionKey: String(document.category), + etag: 'etag', + timestamp: null, + document, + }), + deleteCosmosItem: async (databaseId: string, containerId: string, itemId: string, partitionKey?: string | null) => { + deleted.push({databaseId, containerId, itemId, partitionKey}) + }, + queryCosmosItems: async (_databaseId: string, _containerId: string, query: string): Promise => ({ + items: [{id: 'item-1', query}], + count: 1, + }), + })]) + + const upsertRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items', { + method: 'POST', + body: JSON.stringify({id: 'item-1', category: 'demo'}), + }) + const upserted = await upsertRes.json() + const queryRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/query', { + method: 'POST', + body: JSON.stringify({query: 'SELECT * FROM c'}), + }) + const queryBody = await queryRes.json() + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items/item-1?partitionKey=demo', {method: 'DELETE'}) + + expect(upsertRes.status).toBe(201) + expect(upserted.partitionKey).toBe('demo') + expect(queryRes.status).toBe(200) + expect(queryBody.count).toBe(1) + expect(deleteRes.status).toBe(200) + expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items', itemId: 'item-1', partitionKey: 'demo'}]) + }) + + test('runs a Logs Insights query through the route', async () => { + let received: {logGroupName?: string; queryString?: string; startTime?: number; endTime?: number; limit?: number} = {} + const app = appWithRoutes([mockAdapter('aws', { + service: 'logs', + schema: awsLogsSchema, + queryLogs: async (logGroupName, input): Promise => { + received = {logGroupName, ...input} + return {queryId: 'q-1', status: 'Complete', rows: [{'@message': 'hello'}]} + }, + })]) + + const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/query', { + method: 'POST', + body: JSON.stringify({queryString: 'fields @message', startTime: 1000, endTime: 2000}), + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({queryId: 'q-1', status: 'Complete', rows: [{'@message': 'hello'}]}) + expect(received).toEqual({logGroupName: '/floci/probe', queryString: 'fields @message', startTime: 1000, endTime: 2000, limit: undefined}) + }) + + test('rejects a Logs Insights query with a non-string queryString before calling the adapter', async () => { + let called = false + const app = appWithRoutes([mockAdapter('aws', { + service: 'logs', + schema: awsLogsSchema, + queryLogs: async (): Promise => { + called = true + return {queryId: 'q-1', status: 'Complete', rows: []} + }, + })]) + + const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/query', { + method: 'POST', + body: JSON.stringify({queryString: 123, startTime: 1000, endTime: 2000}), + }) + + expect(res.status).toBe(400) + expect(called).toBe(false) + }) + + test('rejects a Logs Insights query with a missing startTime before calling the adapter', async () => { + const app = appWithRoutes([mockAdapter('aws', { + service: 'logs', + schema: awsLogsSchema, + queryLogs: async (): Promise => ({queryId: 'q-1', status: 'Complete', rows: []}), + })]) + + const res = await app.request('/api/clouds/aws/services/logs/resources/%2Ffloci%2Fprobe/query', { + method: 'POST', + body: JSON.stringify({queryString: 'fields @message', endTime: 2000}), + }) + + expect(res.status).toBe(400) + }) + + test('normalizes runtime unavailable errors', async () => { + const app = appWithRoutes([ + mockAdapter('aws', { + list: async () => { + throw new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577: connection refused') + }, + }), + ]) + const res = await app.request('/api/clouds/aws/services/storage/resources') + const body = await res.json() + + expect(res.status).toBe(503) + expect(body.code).toBe('runtime_unavailable') + expect(body.message).toBe('Runtime unavailable') + expect(body.detail).toContain('connection refused') + }) + + test('normalizes not implemented runtime errors', async () => { + const app = appWithRoutes([ + mockAdapter('azure', { + create: async () => { + throw new NotImplementedByRuntimeError('Azure Blob request failed: HTTP 501') + }, + }), + ]) + const res = await app.request('/api/clouds/azure/services/storage/resources', { + method: 'POST', + body: JSON.stringify({containerName: 'demo'}), + }) + const body = await res.json() + + expect(res.status).toBe(501) + expect(body.code).toBe('operation_not_implemented') + expect(body.message).toBe('Operation is not implemented by the selected runtime') + }) + + test('reports a missing adapter as unsupported rather than a runtime failure', async () => { + const res = await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/azure/services/storage/resources') + const body = await res.json() + + expect(res.status).toBe(501) + expect(body.code).toBe('operation_not_supported') + expect(body.detail).toContain('No adapter registered for azure/storage') + }) + + test('maps a validation error to 400 with the adapter message intact', async () => { + const app = appWithRoutes([ + mockAdapter('aws', { + create: async () => { + throw new ValidationError('bucketName is required') + }, + }), + ]) + const res = await app.request('/api/clouds/aws/services/storage/resources', { + method: 'POST', + body: JSON.stringify({}), + }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.code).toBe('invalid_request') + expect(body.message).toBe('bucketName is required') + }) + + // Before typed errors these AWS SDK failures all collapsed into a blanket 502. + const sdkCases: Array<{name: string; status: number; code: string}> = [ + {name: 'BucketAlreadyOwnedByYou', status: 409, code: 'resource_conflict'}, + {name: 'AccessDenied', status: 403, code: 'access_denied'}, + {name: 'ValidationException', status: 400, code: 'invalid_request'}, + {name: 'ThrottlingException', status: 429, code: 'rate_limited'}, + {name: 'NoSuchBucket', status: 404, code: 'resource_not_found'}, + ] + + for (const sdkCase of sdkCases) { + test(`maps the AWS SDK ${sdkCase.name} error to ${sdkCase.status}`, async () => { + const app = appWithRoutes([ + mockAdapter('aws', { + create: async () => { + const err = new Error(`${sdkCase.name} raised by the runtime`) + err.name = sdkCase.name + Object.assign(err, {$metadata: {httpStatusCode: 500}, $fault: 'client'}) + throw err + }, + }), + ]) + const res = await app.request('/api/clouds/aws/services/storage/resources', { + method: 'POST', + body: JSON.stringify({name: 'demo'}), + }) + const body = await res.json() + + expect(res.status).toBe(sdkCase.status) + expect(body.code).toBe(sdkCase.code) + expect(body.detail ?? body.message).toContain(sdkCase.name) + }) + } +}) + +describe('KMS crypto routes', () => { + test('decodes plaintext and encodes the ciphertext response', async () => { + let delegatedId = '' + let delegatedPlaintext: Uint8Array | undefined + let delegatedContext: Record | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'kms', + schema: awsKmsSchema, + encrypt: async (id, input) => { + delegatedId = id + delegatedPlaintext = input.plaintext + delegatedContext = input.encryptionContext + return { + ciphertextBlob: new Uint8Array([1, 2, 3]), + keyId: id, + encryptionAlgorithm: input.encryptionAlgorithm, + } + }, + })]) + + const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + plaintextBase64: Buffer.from('hello').toString('base64'), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + encryptionContext: {purpose: 'test'}, + }), + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(delegatedId).toBe('key-1') + expect(new TextDecoder().decode(delegatedPlaintext)).toBe('hello') + expect(delegatedContext).toEqual({purpose: 'test'}) + expect(body).toEqual({ + ciphertextBlobBase64: 'AQID', + keyId: 'key-1', + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + }) + expect(res.headers.get('cache-control')).toBe('no-store') + }) + + test('decodes ciphertext and encodes the plaintext response', async () => { + let delegatedCiphertext: Uint8Array | undefined + const app = appWithRoutes([mockAdapter('aws', { + service: 'kms', + schema: awsKmsSchema, + decrypt: async (id, input) => { + delegatedCiphertext = input.ciphertextBlob + return { + plaintext: new TextEncoder().encode('round trip'), + keyId: id, + encryptionAlgorithm: input.encryptionAlgorithm, + } + }, + })]) + + const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/decrypt', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + ciphertextBlobBase64: 'AQID', + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + }), + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(delegatedCiphertext).toEqual(new Uint8Array([1, 2, 3])) + expect(Buffer.from(body.plaintextBase64, 'base64').toString('utf8')).toBe('round trip') + expect(res.headers.get('cache-control')).toBe('no-store') + }) + + test('rejects non-canonical base64 before calling the adapter', async () => { + let calls = 0 + const app = appWithRoutes([mockAdapter('aws', { + service: 'kms', + schema: awsKmsSchema, + encrypt: async () => { + calls += 1 + throw new Error('must not be called') + }, + })]) + + const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + plaintextBase64: 'not base64', + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + }), + }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.code).toBe('invalid_request') + expect(body.message).toContain('canonical base64') + expect(calls).toBe(0) + expect(res.headers.get('cache-control')).toBe('no-store') + }) + + test('rejects malformed JSON as a caller error', async () => { + const app = appWithRoutes([mockAdapter('aws', {service: 'kms', schema: awsKmsSchema})]) + const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/decrypt', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: '{', + }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.message).toBe('Request body must be valid JSON') + expect(res.headers.get('cache-control')).toBe('no-store') + }) + + test('maps provider contract failures to 502 without echoing request data', async () => { + const secretMarker = 'plaintext-that-must-not-be-returned' + const app = appWithRoutes([mockAdapter('aws', { + service: 'kms', + schema: awsKmsSchema, + encrypt: async () => { + throw new RuntimeError('KMS did not return ciphertext') + }, + })]) + + const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + plaintextBase64: Buffer.from(secretMarker).toString('base64'), + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + }), + }) + const responseText = await res.text() + + expect(res.status).toBe(502) + expect(responseText).not.toContain(secretMarker) + expect(responseText).not.toContain(Buffer.from(secretMarker).toString('base64')) + expect(res.headers.get('cache-control')).toBe('no-store') + }) + + test('sanitizes provider validation text for crypto requests', async () => { + const secretMarker = 'provider-echoed-sensitive-value' + const app = appWithRoutes([mockAdapter('aws', { + service: 'kms', + schema: awsKmsSchema, + encrypt: async () => { + throw Object.assign(new Error(secretMarker), { + name: 'ValidationException', + $fault: 'client', + $metadata: {httpStatusCode: 400}, + }) + }, + })]) + + const res = await app.request('/api/clouds/aws/services/kms/resources/key-1/encrypt', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + plaintextBase64: 'AQ==', + encryptionAlgorithm: 'SYMMETRIC_DEFAULT', + }), + }) + const responseText = await res.text() + + expect(res.status).toBe(400) + expect(responseText).not.toContain(secretMarker) + expect(responseText).toContain('KMS rejected the request') + expect(res.headers.get('cache-control')).toBe('no-store') + }) +}) + +describe('service descriptors', () => { + test('every descriptor carries nav metadata for every cloud', async () => { + for (const cloud of ['aws', 'azure', 'gcp']) { + const res = await appWithRoutes().request(`/api/clouds/${cloud}/services`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.length).toBeGreaterThan(0) + for (const descriptor of body) { + expect(descriptor.cloud).toBe(cloud) + expect(typeof descriptor.route).toBe('string') + expect(descriptor.route.length).toBeGreaterThan(0) + expect(typeof descriptor.iconKey).toBe('string') + expect(typeof descriptor.group).toBe('string') + expect(typeof descriptor.order).toBe('number') + expect(descriptor.displayName.length).toBeGreaterThan(0) + } + } + }) + + test('every unavailable service explains itself', async () => { + for (const cloud of ['aws', 'azure', 'gcp']) { + const body = await (await appWithRoutes().request(`/api/clouds/${cloud}/services`)).json() + const unexplained = body.filter( + (d: {availability: string; reason?: string}) => d.availability === 'coming_soon' && !d.reason, + ) + expect(unexplained).toEqual([]) + } + }) + + test('availability follows adapter registration rather than a hardcoded list', async () => { + const withK8s = await (await appWithRoutes([ + mockAdapter('gcp', {service: 'k8s'}), + ]).request('/api/clouds/gcp/services')).json() + const withoutK8s = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/gcp/services')).json() + + const find = (body: Array<{service: string; reason?: string}>, service: string) => + body.find((d) => d.service === service) + + expect(find(withK8s, 'k8s')).toMatchObject({availability: 'available'}) + expect(find(withoutK8s, 'k8s')).toMatchObject({availability: 'coming_soon'}) + expect(find(withoutK8s, 'k8s')?.reason).toContain('GCP') + }) + + test('an adapter can report coming_soon when its runtime does not implement it', async () => { + // floci-az answers 501 for /functions, so a registered adapter must still + // be able to tell the truth about the runtime behind it. + const app = appWithRoutes([ + mockAdapter('azure', { + service: 'serverless', + descriptorOverride: () => ({ + availability: 'coming_soon', + reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.', + }), + }), + ]) + const body = await (await app.request('/api/clouds/azure/services')).json() + const serverless = body.find((d: {service: string}) => d.service === 'serverless') + + expect(serverless.availability).toBe('coming_soon') + expect(serverless.reason).toContain('501') + }) + + test('routes AWS secrets to its bespoke page and GCP to the generic explorer', async () => { + // Availability now comes from adapter registration like every other service, + // but the absolute route is kept so the card still links to the standalone + // page instead of Cloud Explorer. Without an adapter it reads coming_soon. + const withAdapter = appWithRoutes([ + mockAdapter('aws'), + mockAdapter('aws', {service: 'secrets'}), + mockAdapter('gcp', {service: 'secrets'}), + ]) + const aws = await (await withAdapter.request('/api/clouds/aws/services')).json() + const bare = await (await appWithRoutes().request('/api/clouds/aws/services')).json() + const gcp = await (await withAdapter.request('/api/clouds/gcp/services')).json() + const secretsFor = (body: Array<{service: string}>) => body.find((d) => d.service === 'secrets') + + expect(secretsFor(aws)).toMatchObject({availability: 'available', route: '/secretsmanager'}) + expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'}) + expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'}) + }) +}) + +describe('AppConfig nested routes', () => { + test('routes AppConfig operations through the adapter', async () => { + const calls: string[] = [] + const environment: AppConfigEnvironment = { + id: 'env-1', + applicationId: 'app-1', + name: 'dev', + description: null, + state: 'READY_FOR_DEPLOYMENT', + } + const profile: AppConfigConfigurationProfile = { + id: 'profile-1', + applicationId: 'app-1', + name: 'settings', + description: null, + locationUri: 'hosted', + type: 'AWS.Freeform', + } + const version: AppConfigHostedConfigurationVersion = { + id: 'profile-1:1', + applicationId: 'app-1', + configurationProfileId: 'profile-1', + versionNumber: 1, + description: null, + contentType: 'application/json', + content: '{"enabled":true}', + } + const strategy: AppConfigDeploymentStrategy = { + id: 'strategy-1', + name: 'immediate', + description: null, + deploymentDurationInMinutes: 0, + growthType: 'LINEAR', + growthFactor: 100, + finalBakeTimeInMinutes: 0, + replicateTo: 'NONE', + } + const deployment: AppConfigDeployment = { + applicationId: 'app-1', + environmentId: 'env-1', + deploymentNumber: 1, + configurationProfileId: 'profile-1', + configurationVersion: '1', + deploymentStrategyId: 'strategy-1', + state: 'COMPLETE', + percentageComplete: 100, + startedAt: null, + completedAt: null, + description: null, + } + const app = appWithRoutes([mockAdapter('aws', { + service: 'configuration', + schema: awsAppConfigSchema, + listAppConfigEnvironments: async (applicationId) => { + calls.push(`list-environments:${applicationId}`) + return [environment] + }, + createAppConfigEnvironment: async (applicationId, input) => { + calls.push(`create-environment:${applicationId}:${input.values.name}`) + return environment + }, + deleteAppConfigEnvironment: async (applicationId, environmentId) => { + calls.push(`delete-environment:${applicationId}:${environmentId}`) + }, + listAppConfigConfigurationProfiles: async (applicationId) => { + calls.push(`list-profiles:${applicationId}`) + return [profile] + }, + createAppConfigConfigurationProfile: async (applicationId, input) => { + calls.push(`create-profile:${applicationId}:${input.values.name}`) + return profile + }, + deleteAppConfigConfigurationProfile: async (applicationId, profileId) => { + calls.push(`delete-profile:${applicationId}:${profileId}`) + }, + listAppConfigHostedConfigurationVersions: async (applicationId, profileId) => { + calls.push(`list-versions:${applicationId}:${profileId}`) + return [version] + }, + getAppConfigHostedConfigurationVersion: async (applicationId, profileId, versionNumber) => { + calls.push(`get-version:${applicationId}:${profileId}:${versionNumber}`) + return version + }, + createAppConfigHostedConfigurationVersion: async (applicationId, profileId, input) => { + calls.push(`create-version:${applicationId}:${profileId}:${input.values.contentType}`) + return version + }, + deleteAppConfigHostedConfigurationVersion: async (applicationId, profileId, versionNumber) => { + calls.push(`delete-version:${applicationId}:${profileId}:${versionNumber}`) + }, + listAppConfigDeploymentStrategies: async () => { + calls.push('list-strategies') + return [strategy] + }, + createAppConfigDeploymentStrategy: async (input) => { + calls.push(`create-strategy:${input.values.name}`) + return strategy + }, + deleteAppConfigDeploymentStrategy: async (strategyId) => { + calls.push(`delete-strategy:${strategyId}`) + }, + startAppConfigDeployment: async (applicationId, environmentId, input) => { + calls.push(`start-deployment:${applicationId}:${environmentId}:${input.values.configurationVersion}`) + return deployment + }, + getAppConfigDeployment: async (applicationId, environmentId, deploymentNumber) => { + calls.push(`get-deployment:${applicationId}:${environmentId}:${deploymentNumber}`) + return deployment + }, + })]) + + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'dev'}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments/env-1', {method: 'DELETE'})).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'settings'}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1', {method: 'DELETE'})).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({content: '{}', contentType: 'application/json'}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions/1')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/configuration-profiles/profile-1/hosted-configuration-versions/1', {method: 'DELETE'})).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/deployment-strategies')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/deployment-strategies', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'immediate'}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/configuration/deployment-strategies/strategy-1', {method: 'DELETE'})).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments/env-1/deployments', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({configurationVersion: '1'}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/configuration/resources/app-1/environments/env-1/deployments/1')).status).toBe(200) + + expect(calls).toEqual([ + 'list-environments:app-1', + 'create-environment:app-1:dev', + 'delete-environment:app-1:env-1', + 'list-profiles:app-1', + 'create-profile:app-1:settings', + 'delete-profile:app-1:profile-1', + 'list-versions:app-1:profile-1', + 'create-version:app-1:profile-1:application/json', + 'get-version:app-1:profile-1:1', + 'delete-version:app-1:profile-1:1', + 'list-strategies', + 'create-strategy:immediate', + 'delete-strategy:strategy-1', + 'start-deployment:app-1:env-1:1', + 'get-deployment:app-1:env-1:1', + ]) + }) +}) + +describe('per-service status', () => { + test('reports a reachable service with a latency measurement', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/storage/status') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toMatchObject({ + cloud: 'aws', + service: 'storage', + adapterRegistered: true, + runtime: 'reachable', + error: null, + errorCode: null, + }) + expect(body.latencyMs).toBeGreaterThanOrEqual(0) + }) + + test('reports coming_soon for a service with no adapter', async () => { + const res = await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/gcp/services/storage/status') + const body = await res.json() + + expect(body).toMatchObject({runtime: 'coming_soon', adapterRegistered: false, latencyMs: null}) + }) + + test('distinguishes a runtime that does not implement a service from one that is down', async () => { + // This is the whole point of errorCode: floci-az serves blob storage but + // answers 501 for functions, and the UI must not call that "offline". + const app = appWithRoutes([ + mockAdapter('azure', { + service: 'serverless', + list: async () => { + throw new NotImplementedByRuntimeError('HTTP 501 /functions') + }, + }), + mockAdapter('gcp', { + list: async () => { + throw new RuntimeUnavailableError('Cannot reach Floci-GCP') + }, + }), + ]) + + const notImplemented = await (await app.request('/api/clouds/azure/services/serverless/status')).json() + expect(notImplemented.runtime).toBe('unavailable') + expect(notImplemented.errorCode).toBe('operation_not_implemented') + + const down = await (await app.request('/api/clouds/gcp/services/storage/status')).json() + expect(down.errorCode).toBe('runtime_unavailable') + }) + + test('prefers an adapter health() override to list()', async () => { + let listCalls = 0 + let healthCalls = 0 + const app = appWithRoutes([ + mockAdapter('aws', { + list: async () => { + listCalls += 1 + return [] + }, + health: async () => { + healthCalls += 1 + }, + }), + ]) + + await app.request('/api/clouds/aws/services/storage/status') + expect(healthCalls).toBe(1) + expect(listCalls).toBe(0) + }) + + test('omits per-service detail from the cloud status by default', async () => { + const body = await (await appWithRoutes().request('/api/clouds/aws/status')).json() + expect(body.services).toBeUndefined() + }) + + test('includes per-service detail only when asked', async () => { + const body = await (await appWithRoutes().request('/api/clouds/aws/status?services=all')).json() + + expect(Array.isArray(body.services)).toBe(true) + expect(body.services[0]).toMatchObject({cloud: 'aws', service: 'storage'}) + }) + + test('caches probes so a polling sidebar does not fan out per request', async () => { + let probes = 0 + const app = appWithRoutes([ + mockAdapter('aws', { + list: async () => { + probes += 1 + return [] + }, + }), + ]) + + for (let i = 0; i < 5; i += 1) { + await app.request('/api/clouds/aws/services/storage/status') + } + expect(probes).toBe(1) + }) + + test('rejects an unknown service slug', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/queue/status') + expect(res.status).toBe(404) + }) + + test('routes nested EKS nodegroup and Fargate actions through the cloud adapter', async () => { + const calls: string[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'k8s', + schema: awsEksSchema, + listKubernetesNodegroups: async (clusterId) => [{ + id: 'workers', name: 'workers', clusterId, arn: null, status: 'ACTIVE', version: null, + releaseVersion: null, createdAt: null, modifiedAt: null, capacityType: null, + instanceTypes: ['t3.medium'], subnets: ['subnet-a'], nodeRole: null, + scalingConfig: null, labels: {}, tags: {}, + }], + createKubernetesNodegroup: async (clusterId, input) => { + calls.push(`create-nodegroup:${clusterId}:${input.name}`) + return { + id: input.name, name: input.name, clusterId, arn: null, status: null, version: null, + releaseVersion: null, createdAt: null, modifiedAt: null, capacityType: null, + instanceTypes: [], subnets: input.subnets, nodeRole: input.nodeRole, + scalingConfig: null, labels: {}, tags: {}, + } + }, + deleteKubernetesNodegroup: async (clusterId, nodegroupId) => { + calls.push(`delete-nodegroup:${clusterId}:${nodegroupId}`) + }, + listKubernetesFargateProfiles: async (clusterId) => [{ + id: 'default', name: 'default', clusterId, arn: null, status: 'ACTIVE', createdAt: null, + podExecutionRoleArn: null, subnets: [], selectors: [], tags: {}, + }], + createKubernetesFargateProfile: async (clusterId, input) => { + calls.push(`create-fargate:${clusterId}:${input.name}`) + return { + id: input.name, name: input.name, clusterId, arn: null, status: null, createdAt: null, + podExecutionRoleArn: input.podExecutionRoleArn, subnets: input.subnets ?? [], selectors: [], tags: {}, + } + }, + deleteKubernetesFargateProfile: async (clusterId, profileId) => { + calls.push(`delete-fargate:${clusterId}:${profileId}`) + }, + })]) + + expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/nodegroups')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/nodegroups', { + method: 'POST', headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'workers', nodeRole: 'role', subnets: ['subnet-a']}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/nodegroups/workers', {method: 'DELETE'})).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/fargate-profiles')).status).toBe(200) + expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/fargate-profiles', { + method: 'POST', headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'default', podExecutionRoleArn: 'role', selectors: [{namespace: 'default'}]}), + })).status).toBe(201) + expect((await app.request('/api/clouds/aws/services/k8s/resources/demo/fargate-profiles/default', {method: 'DELETE'})).status).toBe(200) + + expect(calls).toEqual([ + 'create-nodegroup:demo:workers', + 'delete-nodegroup:demo:workers', + 'create-fargate:demo:default', + 'delete-fargate:demo:default', + ]) + }) + + test('routes PATCH to the adapter update method', async () => { + let updatedId = '' + let updatedValues: Record = {} + const app = appWithRoutes([ + mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + update: async (id, input) => { + updatedId = id + updatedValues = input.values + return { + id, + name: id, + cloud: 'aws', + service: 'database', + type: 'db-instance', + region: 'us-east-1', + createdAt: null, + metadata: input.values, + } + }, + }), + ]) + + const res = await app.request('/api/clouds/aws/services/database/resources/orders-db', { + method: 'PATCH', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({autoMinorVersionUpgrade: 'true'}), + }) + + expect(res.status).toBe(200) + expect(updatedId).toBe('orders-db') + expect(updatedValues).toEqual({autoMinorVersionUpgrade: 'true'}) + }) + + test('returns 501 when the adapter does not implement update', async () => { + const app = appWithRoutes([mockAdapter('aws', {service: 'database', schema: awsDatabaseSchema})]) + const res = await app.request('/api/clouds/aws/services/database/resources/orders-db', { + method: 'PATCH', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({autoMinorVersionUpgrade: 'true'}), + }) + expect(res.status).toBe(501) + }) + + test('maps adapter ValidationError on PATCH to 400', async () => { + const app = appWithRoutes([ + mockAdapter('aws', { + service: 'database', + schema: awsDatabaseSchema, + update: async () => { + throw new ValidationError('Invalid password') + }, + }), + ]) + const res = await app.request('/api/clouds/aws/services/database/resources/orders-db', { + method: 'PATCH', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({masterUserPassword: 'short'}), + }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.message).toBe('Invalid password') + }) +}) + +const stubCollections: CollectionPage = { + items: [{id: 'stream-a', name: 'stream-a', parentId: 'group-1', createdAt: null, metadata: {}}], + nextCursor: 'cursor-2', +} + +const stubItems: CollectionPage = { + items: [{id: 'event-1', collectionId: 'stream-a', timestamp: null, body: {message: 'hello'}, metadata: {}}], + nextCursor: null, +} + +const documentsStub: DocumentStoreAdapter = { + listCollections: async () => stubCollections, + createCollection: async (resourceId, input) => ({ + id: String(input.values.name), + name: String(input.values.name), + parentId: resourceId, + createdAt: null, + metadata: {}, + }), + deleteCollection: async () => {}, + listItems: async () => stubItems, +} + +const itemsStub: ItemStoreAdapter = { + listItems: async () => stubItems, +} + +describe('child collection routes', () => { + test('lists collections and returns the cursor', async () => { + const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + items: [{id: 'stream-a', name: 'stream-a', parentId: 'group-1', createdAt: null, metadata: {}}], + nextCursor: 'cursor-2', + }) + }) + + test('creates a collection', async () => { + const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'stream-b'}), + }) + + expect(res.status).toBe(201) + expect(await res.json()).toMatchObject({id: 'stream-b', parentId: 'group-1'}) + }) + + test('lists items under a collection', async () => { + const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections/stream-a/items') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({items: stubItems.items, nextCursor: null}) + }) + + test('lists flat items', async () => { + const app = appWithRoutes([mockAdapter('aws', {items: itemsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/table-1/items') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({items: stubItems.items, nextCursor: null}) + }) + + // A documents-only adapter has no flat shape, and vice versa. + test('501s when the adapter has the other shape', async () => { + const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/items') + + expect(res.status).toBe(501) + }) + + test('501s when the adapter implements no child store', async () => { + const app = appWithRoutes([mockAdapter('aws')]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections') + + expect(res.status).toBe(501) + }) + + test('501s for a write the store does not implement', async () => { + const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections/stream-a/items', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({message: 'nope'}), + }) + + expect(res.status).toBe(501) + }) + + test('rejects an out-of-range limit', async () => { + const app = appWithRoutes([mockAdapter('aws', {documents: documentsStub})]) + const res = await app.request('/api/clouds/aws/services/storage/resources/group-1/collections?limit=5000') + + expect(res.status).toBe(400) + }) + + test('passes cursor and limit through to the adapter', async () => { + let seen: unknown + const app = appWithRoutes([ + mockAdapter('aws', { + documents: { + ...documentsStub, + listCollections: async (_resourceId, page) => { + seen = page + return stubCollections + }, + }, + }), + ]) + await app.request('/api/clouds/aws/services/storage/resources/group-1/collections?cursor=abc&limit=25') + + expect(seen).toEqual({cursor: 'abc', limit: 25}) + }) + + // Registration-order regression: the literal `nosql` segment must keep + // beating the new `:service` param, or the Cosmos panel silently breaks. + test('the existing Cosmos container routes still resolve', async () => { + const app = appWithRoutes([ + mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + listCosmosContainers: async (): Promise => [{ + id: 'items', + name: 'items', + databaseId: 'appdb', + partitionKeyPath: '/pk', + createdAt: null, + metadata: {}, + }], + }), + ]) + + const res = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers') + + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject([{id: 'items', databaseId: 'appdb'}]) + }) +}) diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts new file mode 100644 index 0000000..91cc66d --- /dev/null +++ b/packages/api/src/routes/clouds.ts @@ -0,0 +1,910 @@ +import {Hono} from 'hono' +import type {Context} from 'hono' +import type { + CloudProvider, + CloudServiceType, + CreateDatabaseSnapshotInput, + KmsEncryptionAlgorithm, + SqlConnectionInput, +} from '../cloud-spi/types' +import {clampLimit, type PageQuery} from '../cloud-spi/childCollections' +import {CloudError, toHttpError, ValidationError} from '../cloud-spi/errors' +import {isServiceType} from '../cloud-spi/serviceCatalog' +import {mapAwsSdkError} from '../adapter-aws/awsErrors' +import {serviceForAccount} from '../cloudProxy' +import {CloudProxyService} from '../service/CloudProxyService' + +// Header (and query-param fallback for direct links such as object downloads) +// used by the frontend to scope every request to an AWS account. +export const ACCOUNT_HEADER = 'x-floci-account-id' + +interface KmsEncryptRequest { + plaintextBase64?: unknown + encryptionAlgorithm?: unknown + encryptionContext?: unknown +} + +interface KmsDecryptRequest { + ciphertextBlobBase64?: unknown + encryptionAlgorithm?: unknown + encryptionContext?: unknown +} + +const KMS_ENCRYPTION_ALGORITHMS = new Set([ + 'SYMMETRIC_DEFAULT', + 'RSAES_OAEP_SHA_1', + 'RSAES_OAEP_SHA_256', +]) +const MAX_PLAINTEXT_BASE64_LENGTH = 5_464 +const MAX_CIPHERTEXT_BASE64_LENGTH = 8_192 + +export function createCloudRoutes(injectedService?: CloudProxyService) { + const app = new Hono() + + // Resolve the account-scoped service per request. An explicitly injected + // service (used by tests) always wins and ignores the account header. + const svc = (c: Context): CloudProxyService => + injectedService ?? serviceForAccount(c.req.header(ACCOUNT_HEADER) ?? c.req.query('account')) + + app.get('/', (c) => c.json(svc(c).clouds())) + + app.get('/:cloud/services', (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + return c.json(svc(c).services(cloud)) + }) + + app.get('/:cloud/status', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + // Per-service detail is opt-in; the connection indicator polls this often. + const includeServices = c.req.query('services') === 'all' + return c.json(await svc(c).status(cloud, {includeServices})) + }) + + app.get('/:cloud/services/:service/status', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + return c.json(await svc(c).serviceStatus(cloud, serviceType)) + }) + + app.get('/:cloud/services/:service/schema', (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + const schema = svc(c).schema(cloud, serviceType) + if (!schema) { + return c.json({error: 'Schema not available'}, 404) + } + + return c.json(schema) + }) + + app.get('/:cloud/services/database/snapshots', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const snapshots = await svc(c).listDatabaseSnapshots(cloud, c.req.query('instanceIdentifier')) + return c.json(snapshots) + }) + }) + + app.post('/:cloud/services/database/snapshots', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const input = await c.req.json() + const snapshot = await svc(c).createDatabaseSnapshot(cloud, input) + return c.json(snapshot, 201) + }) + }) + + app.get('/:cloud/services/database/orderable-classes', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const classes = await svc(c).listDatabaseOrderableInstanceClasses(cloud, c.req.query('engine')) + return c.json(classes) + }) + }) + + app.get('/:cloud/services/:service/resources', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const resources = await svc(c).listResources(cloud, serviceType, {search: c.req.query('search')}) + return c.json(resources) + }) + }) + + app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const containers = await svc(c).listCosmosContainers(cloud, c.req.param('id')) + return c.json(containers) + }) + }) + + app.post('/:cloud/services/nosql/resources/:id/containers', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + const container = await svc(c).createCosmosContainer(cloud, c.req.param('id'), {values}) + return c.json(container, 201) + }) + }) + + app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteCosmosContainer(cloud, c.req.param('id'), c.req.param('containerId')) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/nosql/resources/:id/containers/:containerId/items', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const items = await svc(c).listCosmosItems(cloud, c.req.param('id'), c.req.param('containerId')) + return c.json(items) + }) + }) + + app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/items', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const document = await c.req.json>() + const item = await svc(c).upsertCosmosItem(cloud, c.req.param('id'), c.req.param('containerId'), document) + return c.json(item, 201) + }) + }) + + app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteCosmosItem(cloud, c.req.param('id'), c.req.param('containerId'), c.req.param('itemId'), c.req.query('partitionKey') ?? null) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/query', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{query?: string}>() + const result = await svc(c).queryCosmosItems(cloud, c.req.param('id'), c.req.param('containerId'), body.query ?? '') + return c.json(result) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/databases', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const connection = await c.req.json() + const databases = await svc(c).listSqlDatabases(cloud, c.req.param('id'), connection) + return c.json(databases) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/tables', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const connection = await c.req.json() + const tables = await svc(c).listSqlTables(cloud, c.req.param('id'), connection) + return c.json(tables) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/query', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json() + const {query = '', ...connection} = body + const result = await svc(c).querySql(cloud, c.req.param('id'), connection, query) + return c.json(result) + }) + }) + + app.post('/:cloud/services/logs/resources/:id/query', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{queryString?: unknown; startTime?: unknown; endTime?: unknown; limit?: unknown}>() + if (typeof body.queryString !== 'string') throw new ValidationError('queryString must be a string.') + if (typeof body.startTime !== 'number') throw new ValidationError('startTime must be a number (epoch seconds).') + if (typeof body.endTime !== 'number') throw new ValidationError('endTime must be a number (epoch seconds).') + if (body.limit !== undefined && typeof body.limit !== 'number') throw new ValidationError('limit must be a number.') + + const result = await svc(c).queryLogs(cloud, c.req.param('id'), { + queryString: body.queryString, + startTime: body.startTime, + endTime: body.endTime, + limit: body.limit, + }) + return c.json(result) + }) + }) + + app.get('/:cloud/services/nosql/resources/:id/items', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const items = await svc(c).listNoSqlItems(cloud, c.req.param('id')) + return c.json(items) + }) + }) + + app.post('/:cloud/services/nosql/resources/:id/items', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const document = await c.req.json>() + const item = await svc(c).putNoSqlItem(cloud, c.req.param('id'), document) + return c.json(item, 201) + }) + }) + + app.delete('/:cloud/services/email/inbox', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).clearEmailInbox(cloud) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const nodegroups = await svc(c).listKubernetesNodegroups(cloud, c.req.param('id')) + return c.json(nodegroups) + }) + }) + + app.post('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const nodegroup = await svc(c).createKubernetesNodegroup(cloud, c.req.param('id'), await c.req.json()) + return c.json(nodegroup, 201) + }) + }) + + app.delete('/:cloud/services/k8s/resources/:id/nodegroups/:nodegroupId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteKubernetesNodegroup(cloud, c.req.param('id'), c.req.param('nodegroupId')) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/k8s/resources/:id/fargate-profiles', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const profiles = await svc(c).listKubernetesFargateProfiles(cloud, c.req.param('id')) + return c.json(profiles) + }) + }) + + app.post('/:cloud/services/k8s/resources/:id/fargate-profiles', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const profile = await svc(c).createKubernetesFargateProfile(cloud, c.req.param('id'), await c.req.json()) + return c.json(profile, 201) + }) + }) + + app.delete('/:cloud/services/k8s/resources/:id/fargate-profiles/:profileId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteKubernetesFargateProfile(cloud, c.req.param('id'), c.req.param('profileId')) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/configuration/resources/:id/environments', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + return c.json(await svc(c).listAppConfigEnvironments(cloud, c.req.param('id'))) + }) + }) + + app.post('/:cloud/services/configuration/resources/:id/environments', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + return c.json(await svc(c).createAppConfigEnvironment(cloud, c.req.param('id'), {values}), 201) + }) + }) + + app.delete('/:cloud/services/configuration/resources/:id/environments/:environmentId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteAppConfigEnvironment(cloud, c.req.param('id'), c.req.param('environmentId')) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/configuration/resources/:id/configuration-profiles', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + return c.json(await svc(c).listAppConfigConfigurationProfiles(cloud, c.req.param('id'))) + }) + }) + + app.post('/:cloud/services/configuration/resources/:id/configuration-profiles', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + return c.json(await svc(c).createAppConfigConfigurationProfile(cloud, c.req.param('id'), {values}), 201) + }) + }) + + app.delete('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteAppConfigConfigurationProfile(cloud, c.req.param('id'), c.req.param('profileId')) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + return c.json(await svc(c).listAppConfigHostedConfigurationVersions(cloud, c.req.param('id'), c.req.param('profileId'))) + }) + }) + + app.post('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + return c.json(await svc(c).createAppConfigHostedConfigurationVersion(cloud, c.req.param('id'), c.req.param('profileId'), {values}), 201) + }) + }) + + const hostedVersionNumber = (raw: string): number | null => { + const value = Number(raw) + return Number.isInteger(value) && value > 0 ? value : null + } + + app.get('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const versionNumber = hostedVersionNumber(c.req.param('versionNumber')) + if (!isCloudProvider(cloud) || versionNumber === null) return c.json({error: 'Unknown cloud or version'}, 404) + + return withRuntime(c, async () => { + const version = await svc(c).getAppConfigHostedConfigurationVersion(cloud, c.req.param('id'), c.req.param('profileId'), versionNumber) + if (!version) return c.json({error: 'Hosted configuration version not found'}, 404) + return c.json(version) + }) + }) + + app.delete('/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const versionNumber = hostedVersionNumber(c.req.param('versionNumber')) + if (!isCloudProvider(cloud) || versionNumber === null) return c.json({error: 'Unknown cloud or version'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteAppConfigHostedConfigurationVersion(cloud, c.req.param('id'), c.req.param('profileId'), versionNumber) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/configuration/deployment-strategies', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + return c.json(await svc(c).listAppConfigDeploymentStrategies(cloud)) + }) + }) + + app.post('/:cloud/services/configuration/deployment-strategies', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + return c.json(await svc(c).createAppConfigDeploymentStrategy(cloud, {values}), 201) + }) + }) + + app.delete('/:cloud/services/configuration/deployment-strategies/:strategyId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteAppConfigDeploymentStrategy(cloud, c.req.param('strategyId')) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + return c.json(await svc(c).startAppConfigDeployment(cloud, c.req.param('id'), c.req.param('environmentId'), {values}), 201) + }) + }) + + app.get('/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments/:deploymentNumber', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const deploymentNumber = Number(c.req.param('deploymentNumber')) + if (!isCloudProvider(cloud) || !Number.isInteger(deploymentNumber) || deploymentNumber <= 0) { + return c.json({error: 'Unknown cloud or deployment'}, 404) + } + + return withRuntime(c, async () => { + const deployment = await svc(c).getAppConfigDeployment(cloud, c.req.param('id'), c.req.param('environmentId'), deploymentNumber) + if (!deployment) return c.json({error: 'Deployment not found'}, 404) + return c.json(deployment) + }) + }) + + // Child collections, parameterised by service. All the literal-segment + // routes above (Cosmos containers, SQL, NoSQL items, email inbox, k8s + // nodegroups/fargate profiles) must stay registered before these, or the + // `:service` param below would swallow their requests first. + + app.post('/:cloud/services/apigateway/resources/:id/routes', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const input = await c.req.json>() + const route = await svc(c).createApiGatewayLambdaRoute(cloud, c.req.param('id'), { + path: typeof input.path === 'string' ? input.path : '', + method: typeof input.method === 'string' ? input.method : '', + stage: typeof input.stage === 'string' ? input.stage : '', + functionName: typeof input.functionName === 'string' ? input.functionName : '', + }) + return c.json(route, 201) + }) + }) + + app.get('/:cloud/services/:service/resources/:id/collections', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const page = await svc(c).listChildCollections(target.cloud, target.service, c.req.param('id'), pageQuery(c)) + return c.json(page) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/collections', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + const collection = await svc(c).createChildCollection(target.cloud, target.service, c.req.param('id'), {values}) + return c.json(collection, 201) + }) + }) + + app.delete('/:cloud/services/:service/resources/:id/collections/:cid', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteChildCollection(target.cloud, target.service, c.req.param('id'), c.req.param('cid')) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/:service/resources/:id/collections/:cid/items', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const page = await svc(c).listCollectionItems(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), pageQuery(c)) + return c.json(page) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/collections/:cid/items', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json>() + const item = await svc(c).putCollectionItem(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), body) + return c.json(item, 201) + }) + }) + + app.delete('/:cloud/services/:service/resources/:id/collections/:cid/items/:itemId', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteCollectionItem(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), c.req.param('itemId'), c.req.query('partitionKey') ?? null) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/collections/:cid/query', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{query?: string}>() + const page = await svc(c).queryCollectionItems(target.cloud, target.service, c.req.param('id'), c.req.param('cid'), body.query ?? '') + return c.json(page) + }) + }) + + app.get('/:cloud/services/:service/resources/:id/items', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const page = await svc(c).listFlatItems(target.cloud, target.service, c.req.param('id'), pageQuery(c)) + return c.json(page) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/items', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json>() + const item = await svc(c).putFlatItem(target.cloud, target.service, c.req.param('id'), body) + return c.json(item, 201) + }) + }) + + app.delete('/:cloud/services/:service/resources/:id/items/:itemId', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteFlatItem(target.cloud, target.service, c.req.param('id'), c.req.param('itemId'), c.req.query('partitionKey') ?? null) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/query', async (c) => { + const target = childTarget(c) + if (!target) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{query?: string}>() + const page = await svc(c).queryFlatItems(target.cloud, target.service, c.req.param('id'), body.query ?? '') + return c.json(page) + }) + }) + + app.get('/:cloud/services/:service/resources/:id', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const resource = await svc(c).getResource(cloud, serviceType, c.req.param('id')) + if (!resource) return c.json({error: 'Resource not found'}, 404) + return c.json(resource) + }) + }) + + app.get('/:cloud/services/:service/resources/:id/objects', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const objects = await svc(c).listObjects(cloud, serviceType, c.req.param('id'), c.req.query('prefix') ?? '') + return c.json(objects) + }) + }) + + app.put('/:cloud/services/:service/resources/:id/object', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + const key = c.req.query('key') ?? '' + if (!key) return c.json({error: 'Object key is required'}, 400) + const body = new Uint8Array(await c.req.arrayBuffer()) + const contentType = c.req.header('content-type') ?? 'application/octet-stream' + return withRuntime(c, async () => { + await svc(c).putObject(cloud, serviceType, c.req.param('id'), key, body, contentType) + return c.json({ok: true}) + }) + }) + + app.get('/:cloud/services/:service/resources/:id/object', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + const key = c.req.query('key') ?? '' + if (!key) return c.json({error: 'Object key is required'}, 400) + return withRuntime(c, async () => { + const object = await svc(c).getObject(cloud, serviceType, c.req.param('id'), key) + return new Response(object.body, { + headers: { + 'content-type': object.contentType, + ...(object.contentLength === null ? {} : {'content-length': String(object.contentLength)}), + 'content-disposition': `attachment; filename="${key.split('/').pop() ?? key}"`, + }, + }) + }) + }) + + app.delete('/:cloud/services/:service/resources/:id/object', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + const key = c.req.query('key') ?? '' + if (!key) return c.json({error: 'Object key is required'}, 400) + return withRuntime(c, async () => { + await svc(c).deleteObject(cloud, serviceType, c.req.param('id'), key) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/object/copy', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + const {srcKey, destKey, destResourceId} = await c.req.json<{srcKey: string; destKey: string; destResourceId?: string}>() + if (!srcKey || !destKey) return c.json({error: 'srcKey and destKey are required'}, 400) + + return withRuntime(c, async () => { + await svc(c).copyObject(cloud, serviceType, c.req.param('id'), srcKey, destKey, destResourceId) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/:service/resources/:id/invoke', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) { + return c.json({error: 'Unknown cloud or service'}, 404) + } + + return withRuntime(c, async () => { + const body: {payload?: string} = await c.req.json<{payload?: string}>().catch(() => ({})) + const result = await svc(c).invokeResource( + cloud, + serviceType, + c.req.param('id'), + body.payload ?? '{}', + ) + return c.json(result) + }) + }) + + app.post('/:cloud/services/kms/resources/:id/encrypt', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withSensitiveRuntime(c, async () => { + const body = await jsonBody(c) + const result = await svc(c).encryptKms(cloud, c.req.param('id'), { + plaintext: decodeBase64(body.plaintextBase64, 'plaintextBase64', MAX_PLAINTEXT_BASE64_LENGTH), + encryptionAlgorithm: encryptionAlgorithm(body.encryptionAlgorithm), + encryptionContext: encryptionContext(body.encryptionContext), + }) + return c.json({ + ciphertextBlobBase64: Buffer.from(result.ciphertextBlob).toString('base64'), + keyId: result.keyId, + encryptionAlgorithm: result.encryptionAlgorithm, + }) + }) + }) + + app.post('/:cloud/services/kms/resources/:id/decrypt', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withSensitiveRuntime(c, async () => { + const body = await jsonBody(c) + const result = await svc(c).decryptKms(cloud, c.req.param('id'), { + ciphertextBlob: decodeBase64( + body.ciphertextBlobBase64, + 'ciphertextBlobBase64', + MAX_CIPHERTEXT_BASE64_LENGTH, + ), + encryptionAlgorithm: encryptionAlgorithm(body.encryptionAlgorithm), + encryptionContext: encryptionContext(body.encryptionContext), + }) + return c.json({ + plaintextBase64: Buffer.from(result.plaintext).toString('base64'), + keyId: result.keyId, + encryptionAlgorithm: result.encryptionAlgorithm, + }) + }) + }) + + app.post('/:cloud/services/:service/resources', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + const resource = await svc(c).createResource(cloud, serviceType, {values}) + return c.json(resource, 201) + }) + }) + + app.patch('/:cloud/services/:service/resources/:id', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + const values = await c.req.json>() + const resource = await svc(c).updateResource(cloud, serviceType, c.req.param('id'), {values}) + return c.json(resource, 200) + }) + }) + + app.delete('/:cloud/services/:service/resources/:id', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + + return withRuntime(c, async () => { + await svc(c).deleteResource(cloud, serviceType, c.req.param('id')) + return c.json({ok: true}) + }) + }) + + return app +} + +function isCloudProvider(value: string): value is CloudProvider { + return value === 'aws' || value === 'azure' || value === 'gcp' +} + +async function jsonBody(c: Context): Promise { + try { + const body = await c.req.json() + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new ValidationError('Request body must be a JSON object') + } + return body as T + } catch (error) { + if (error instanceof ValidationError) throw error + throw new ValidationError('Request body must be valid JSON') + } +} + +function encryptionAlgorithm(value: unknown): KmsEncryptionAlgorithm { + if (typeof value !== 'string' || !KMS_ENCRYPTION_ALGORITHMS.has(value as KmsEncryptionAlgorithm)) { + throw new ValidationError('encryptionAlgorithm must be a supported KMS encryption algorithm') + } + return value as KmsEncryptionAlgorithm +} + +function encryptionContext(value: unknown): Record | undefined { + if (value === undefined) return undefined + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ValidationError('encryptionContext must be an object of string values') + } + + const entries = Object.entries(value) + if (entries.some(([, entryValue]) => typeof entryValue !== 'string')) { + throw new ValidationError('encryptionContext must be an object of string values') + } + return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record +} + +function decodeBase64(value: unknown, field: string, maxLength: number): Uint8Array { + if (typeof value !== 'string') throw new ValidationError(`${field} must be a base64 string`) + if (value.length > maxLength) throw new ValidationError(`${field} is too large`) + if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new ValidationError(`${field} must be canonical base64`) + } + + const decoded = Buffer.from(value, 'base64') + if (decoded.toString('base64') !== value) throw new ValidationError(`${field} must be canonical base64`) + return decoded +} + +/** + * Resolve and validate the cloud/service pair for a child-collection route. + * Returns null when either is unknown, which the caller turns into a 404. + */ +function childTarget(c: Context): {cloud: CloudProvider; service: CloudServiceType} | null { + const cloud = c.req.param('cloud') as CloudProvider + const service = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(service)) return null + return {cloud, service} +} + +/** Parse paging params. An out-of-range limit raises and becomes a 400. */ +function pageQuery(c: Context): PageQuery { + return {cursor: c.req.query('cursor'), limit: clampLimit(c.req.query('limit'))} +} + +async function withRuntime(c: Context, handler: () => Promise): Promise { + try { + return await handler() + } catch (err) { + const {status, body} = toHttpError(err, mapAwsSdkError) + return c.json(body, status) + } +} + +async function withSensitiveRuntime(c: Context, handler: () => Promise): Promise { + c.header('cache-control', 'no-store') + try { + return await handler() + } catch (err) { + const {status, body} = toHttpError(err, mapAwsSdkError) + if (err instanceof CloudError) return c.json(body, status) + + const message = body.code === 'invalid_request' ? 'KMS rejected the request' : body.error + return c.json({error: message, code: body.code, message}, status) + } +} + +export default createCloudRoutes() diff --git a/packages/api/src/routes/ec2.test.ts b/packages/api/src/routes/ec2.test.ts new file mode 100644 index 0000000..84eb3af --- /dev/null +++ b/packages/api/src/routes/ec2.test.ts @@ -0,0 +1,512 @@ +import {describe, expect, test} from 'bun:test' +import {createEc2Router} from './ec2' +import type { + CreateAmiInput, + CreateNatGatewayInput, + CreateRouteInput, + Ec2ConsoleOutput, + Ec2ElasticIp, + Ec2Image, + Ec2Instance, + Ec2InternetGateway, + Ec2IpPermission, + Ec2KeyPair, + Ec2KeyPairMaterial, + Ec2NatGateway, + Ec2RouteTable, + Ec2SecurityGroup, + Ec2Subnet, + Ec2Tag, + Ec2Vpc, + IpPermissionInput, + RunInstanceInput, +} from '../services/ec2' + +const baseInstance: Ec2Instance = { + instanceId: 'i-test1', + name: 'test-instance', + state: 'running', + instanceType: 't3.micro', + availabilityZone: 'us-east-1a', + securityGroups: [], + tags: [], +} + +const baseIgw: Ec2InternetGateway = { + internetGatewayId: 'igw-0a1b2c3d4e5f6a7b', + attachments: [], + tags: [], +} + +const baseNatGw: Ec2NatGateway = { + natGatewayId: 'nat-0a1b2c3d4e5f6a7b', + subnetId: 'subnet-0a1b2c3d4e5f0001', + vpcId: 'vpc-0a1b2c3d4e5f6a7b', + state: 'available', + tags: [], +} + +const baseRtb: Ec2RouteTable = { + routeTableId: 'rtb-0a1b2c3d4e5f6a7b', + vpcId: 'vpc-0a1b2c3d4e5f6a7b', + routes: [{destinationCidrBlock: '10.0.0.0/16', gatewayId: 'local', state: 'active', origin: 'CreateRouteTable'}], + associations: [], + tags: [], +} + +const baseEip: Ec2ElasticIp = { + allocationId: 'eipalloc-0a1b2c3d4e5f6a7b', + publicIp: '54.0.0.1', + domain: 'vpc', + tags: [], +} + +const fakeService = { + listInstances: async (): Promise => [baseInstance], + describeInstance: async (id: string): Promise => ({...baseInstance, instanceId: id}), + runInstance: async (_input: RunInstanceInput): Promise => ({...baseInstance, instanceId: 'i-new1', name: _input.name, state: 'pending'}), + startInstance: async () => {}, + stopInstance: async () => {}, + rebootInstance: async () => {}, + terminateInstance: async () => {}, + updateInstanceTags: async (_id: string, _add: Ec2Tag[], _rem: string[]) => {}, + createImage: async (_input: CreateAmiInput): Promise<{imageId: string}> => ({imageId: 'ami-0a1b2c3d4e5f6a7b'}), + deregisterImage: async () => {}, + getConsoleOutput: async (instanceId: string): Promise => ({instanceId, output: 'boot log'}), + listAmis: async (): Promise => [], + listKeyPairs: async (): Promise => [], + createKeyPair: async (name: string): Promise => ({keyPairId: 'key-0a1b2c3d4e5f6a7b', keyName: name, keyMaterial: '---PEM---'}), + deleteKeyPair: async () => {}, + listSecurityGroups: async (): Promise => [], + createSecurityGroup: async () => ({groupId: 'sg-0a1b2c3d4e5f6a7b'}), + deleteSecurityGroup: async () => {}, + authorizeSecurityGroupIngress: async (_id: string, _p: IpPermissionInput) => {}, + revokeSecurityGroupIngress: async (_id: string, _p: IpPermissionInput) => {}, + authorizeSecurityGroupEgress: async (_id: string, _p: IpPermissionInput) => {}, + revokeSecurityGroupEgress: async (_id: string, _p: IpPermissionInput) => {}, + listVpcs: async (): Promise => [], + createVpc: async (cidrBlock: string): Promise => ({vpcId: 'vpc-0a1b2c3d4e5f6a7b', cidrBlock, isDefault: false, tags: []}), + deleteVpc: async () => {}, + listSubnets: async (): Promise => [], + createSubnet: async (vpcId: string, cidrBlock: string): Promise => ({ + subnetId: 'subnet-0a1b2c3d4e5f6a7b', + vpcId, + cidrBlock, + availabilityZone: 'us-east-1a', + mapPublicIpOnLaunch: false, + tags: [], + }), + deleteSubnet: async () => {}, + // Internet Gateways + listInternetGateways: async (): Promise => [baseIgw], + createInternetGateway: async (): Promise => baseIgw, + attachInternetGateway: async () => {}, + detachInternetGateway: async () => {}, + deleteInternetGateway: async () => {}, + // NAT Gateways + listNatGateways: async (): Promise => [baseNatGw], + createNatGateway: async (_input: CreateNatGatewayInput): Promise => baseNatGw, + deleteNatGateway: async () => {}, + // Route Tables + listRouteTables: async (): Promise => [baseRtb], + createRouteTable: async (): Promise => baseRtb, + deleteRouteTable: async () => {}, + createRoute: async (_rtbId: string, _input: CreateRouteInput) => {}, + deleteRoute: async () => {}, + associateRouteTable: async (): Promise => 'rtbassoc-0a1b2c3d4e5f6a7b', + disassociateRouteTable: async () => {}, + // Elastic IPs + listElasticIps: async (): Promise => [baseEip], + allocateElasticIp: async (): Promise => baseEip, + releaseElasticIp: async () => {}, + associateElasticIp: async (): Promise<{associationId: string}> => ({associationId: 'eipassoc-test'}), + disassociateElasticIp: async () => {}, + // Meta + listAvailabilityZones: async () => [{zoneName: 'us-east-1a', zoneId: 'use1-az1', state: 'available'}], + listInstanceTypes: async () => [{instanceType: 't3.micro', vcpu: 2, memoryMiB: 1024}], + // VPC attributes + getVpcAttributes: async () => ({enableDnsHostnames: true, enableDnsSupport: true}), + modifyVpcAttribute: async () => {}, + // Subnet attribute + modifySubnetMapPublicIp: async () => {}, + // VPC Wizard + createVpcWizard: async () => ({ + vpcId: 'vpc-0a1b2c3d4e5f6a7b', + igwId: 'igw-0a1b2c3d4e5f6a7b', + publicSubnetIds: ['subnet-0a1b2c3d4e5f0001', 'subnet-0a1b2c3d4e5f0002'], + privateSubnetIds: ['subnet-0a1b2c3d4e5f0003', 'subnet-0a1b2c3d4e5f0004'], + subnetGroups: [ + {name: 'public', isPublic: true, subnetIds: ['subnet-0a1b2c3d4e5f0001', 'subnet-0a1b2c3d4e5f0002']}, + {name: 'private', isPublic: false, subnetIds: ['subnet-0a1b2c3d4e5f0003', 'subnet-0a1b2c3d4e5f0004']}, + ], + publicRouteTableId: 'rtb-0a1b2c3d4e5f6a7b', + privateRouteTableId: 'rtb-0a1b2c3d4e5f6a7c', + natGatewayId: 'nat-0a1b2c3d4e5f6a7b', + eipAllocationId: 'eipalloc-0a1b2c3d4e5f6a7b', + }), +} + +type FakeIpPermission = Ec2IpPermission // keep import used + +const app = createEc2Router(fakeService) + +describe('GET /instances', () => { + test('returns instance array', async () => { + const res = await app.request('/instances') + expect(res.status).toBe(200) + const body = await res.json() as unknown[] + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + }) +}) + +describe('GET /instances/:id', () => { + test('returns single instance with correct id', async () => { + const res = await app.request('/instances/i-test1') + expect(res.status).toBe(200) + const body = await res.json() as {instanceId: string} + expect(body.instanceId).toBe('i-test1') + }) +}) + +describe('POST /instances', () => { + test('creates instance and returns it', async () => { + const res = await app.request('/instances', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'new-instance', imageId: 'ami-1', instanceType: 't3.micro'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {instanceId: string; state: string} + expect(body.instanceId).toBe('i-new1') + expect(body.state).toBe('pending') + }) +}) + +describe('POST /instances/:id/start', () => { + test('returns ok', async () => { + const res = await app.request('/instances/i-test1/start', {method: 'POST'}) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /instances/:id/terminate', () => { + test('terminates instance and returns ok', async () => { + const res = await app.request('/instances/i-test1/terminate', {method: 'POST'}) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /amis/:imageId/deregister', () => { + test('deregisters AMI and returns ok', async () => { + const res = await app.request('/amis/ami-0a1b2c3d4e5f6a7b/deregister', {method: 'POST'}) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('GET /instances/:id/console', () => { + test('returns console output', async () => { + const res = await app.request('/instances/i-test1/console') + expect(res.status).toBe(200) + const body = await res.json() as {output: string} + expect(body.output).toBe('boot log') + }) +}) + +describe('GET /security-groups', () => { + test('returns array', async () => { + const res = await app.request('/security-groups') + expect(res.status).toBe(200) + expect(Array.isArray(await res.json())).toBe(true) + }) +}) + +describe('POST /vpcs', () => { + test('creates vpc and returns it', async () => { + const res = await app.request('/vpcs', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({cidrBlock: '10.0.0.0/16'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {vpcId: string} + expect(body.vpcId).toBe('vpc-0a1b2c3d4e5f6a7b') + }) +}) + +describe('DELETE /vpcs/:id', () => { + test('returns ok', async () => { + const res = await app.request('/vpcs/vpc-0a1b2c3d4e5f6a7b', {method: 'DELETE'}) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /key-pairs', () => { + test('creates key pair and returns PEM material', async () => { + const res = await app.request('/key-pairs', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'my-key'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {keyName: string; keyMaterial: string} + expect(body.keyName).toBe('my-key') + expect(body.keyMaterial).toBe('---PEM---') + }) +}) + +describe('GET /internet-gateways', () => { + test('returns array with igw', async () => { + const res = await app.request('/internet-gateways') + expect(res.status).toBe(200) + const body = await res.json() as {internetGatewayId: string}[] + expect(body).toHaveLength(1) + expect(body[0].internetGatewayId).toBe('igw-0a1b2c3d4e5f6a7b') + }) +}) + +describe('POST /internet-gateways', () => { + test('creates igw and returns it', async () => { + const res = await app.request('/internet-gateways', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'my-igw'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {internetGatewayId: string} + expect(body.internetGatewayId).toBe('igw-0a1b2c3d4e5f6a7b') + }) +}) + +describe('DELETE /internet-gateways/:igwId', () => { + test('returns ok', async () => { + const res = await app.request('/internet-gateways/igw-0a1b2c3d4e5f6a7b', {method: 'DELETE'}) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('GET /nat-gateways', () => { + test('returns array with nat gateway', async () => { + const res = await app.request('/nat-gateways') + expect(res.status).toBe(200) + const body = await res.json() as {natGatewayId: string}[] + expect(body).toHaveLength(1) + expect(body[0].natGatewayId).toBe('nat-0a1b2c3d4e5f6a7b') + }) +}) + +describe('POST /nat-gateways', () => { + test('creates nat gateway and returns it', async () => { + const res = await app.request('/nat-gateways', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'my-nat', subnetId: 'subnet-0a1b2c3d4e5f0001'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {natGatewayId: string} + expect(body.natGatewayId).toBe('nat-0a1b2c3d4e5f6a7b') + }) +}) + +describe('GET /route-tables', () => { + test('returns array with route table', async () => { + const res = await app.request('/route-tables') + expect(res.status).toBe(200) + const body = await res.json() as {routeTableId: string}[] + expect(body).toHaveLength(1) + expect(body[0].routeTableId).toBe('rtb-0a1b2c3d4e5f6a7b') + }) +}) + +describe('POST /route-tables/:rtbId/routes', () => { + test('creates route and returns ok', async () => { + const res = await app.request('/route-tables/rtb-0a1b2c3d4e5f6a7b/routes', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({destinationCidrBlock: '0.0.0.0/0', gatewayId: 'igw-0a1b2c3d4e5f6a7b'}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /route-tables/:rtbId/associations', () => { + test('associates subnet and returns associationId', async () => { + const res = await app.request('/route-tables/rtb-0a1b2c3d4e5f6a7b/associations', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({subnetId: 'subnet-0a1b2c3d4e5f0001'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {associationId: string} + expect(body.associationId).toBe('rtbassoc-0a1b2c3d4e5f6a7b') + }) +}) + +describe('GET /elastic-ips', () => { + test('returns array with eip', async () => { + const res = await app.request('/elastic-ips') + expect(res.status).toBe(200) + const body = await res.json() as {allocationId: string}[] + expect(body).toHaveLength(1) + expect(body[0].allocationId).toBe('eipalloc-0a1b2c3d4e5f6a7b') + }) +}) + +describe('POST /elastic-ips', () => { + test('allocates eip and returns it', async () => { + const res = await app.request('/elastic-ips', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {allocationId: string; publicIp: string} + expect(body.allocationId).toBe('eipalloc-0a1b2c3d4e5f6a7b') + expect(body.publicIp).toBe('54.0.0.1') + }) +}) + +describe('POST /elastic-ips/:allocationId/release', () => { + test('releases eip and returns ok', async () => { + const res = await app.request('/elastic-ips/eipalloc-0a1b2c3d4e5f6a7b/release', {method: 'POST'}) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /security-groups/:groupId/ingress', () => { + test('authorizes ingress rule and returns ok', async () => { + const res = await app.request('/security-groups/sg-0a1b2c3d/ingress', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('DELETE /security-groups/:groupId/ingress', () => { + test('revokes ingress rule and returns ok', async () => { + const res = await app.request('/security-groups/sg-0a1b2c3d/ingress', { + method: 'DELETE', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /security-groups/:groupId/egress', () => { + test('authorizes egress rule and returns ok', async () => { + const res = await app.request('/security-groups/sg-0a1b2c3d/egress', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('DELETE /security-groups/:groupId/egress', () => { + test('revokes egress rule and returns ok', async () => { + const res = await app.request('/security-groups/sg-0a1b2c3d/egress', { + method: 'DELETE', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('GET /availability-zones', () => { + test('returns availability zones array', async () => { + const res = await app.request('/availability-zones') + expect(res.status).toBe(200) + const body = await res.json() as {zoneName: string}[] + expect(body).toHaveLength(1) + expect(body[0].zoneName).toBe('us-east-1a') + }) +}) + +describe('GET /instance-types', () => { + test('returns instance types array', async () => { + const res = await app.request('/instance-types') + expect(res.status).toBe(200) + const body = await res.json() as {instanceType: string; vcpu: number}[] + expect(body).toHaveLength(1) + expect(body[0].instanceType).toBe('t3.micro') + expect(body[0].vcpu).toBe(2) + }) +}) + +describe('PUT /subnets/:subnetId/attributes', () => { + test('modifies subnet mapPublicIpOnLaunch and returns ok', async () => { + const res = await app.request('/subnets/subnet-0a1b2c3d/attributes', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({mapPublicIpOnLaunch: true}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('GET /vpcs/:vpcId/attributes', () => { + test('returns vpc dns attributes', async () => { + const res = await app.request('/vpcs/vpc-0a1b2c3d/attributes') + expect(res.status).toBe(200) + const body = await res.json() as {enableDnsHostnames: boolean; enableDnsSupport: boolean} + expect(body).toHaveProperty('enableDnsHostnames', true) + expect(body).toHaveProperty('enableDnsSupport', true) + }) +}) + +describe('PUT /vpcs/:vpcId/attributes', () => { + test('modifies vpc dns attribute and returns ok', async () => { + const res = await app.request('/vpcs/vpc-0a1b2c3d/attributes', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({attribute: 'enableDnsHostnames', value: false}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +describe('POST /elastic-ips/:allocationId/associate', () => { + test('associates eip to instance and returns associationId', async () => { + const res = await app.request('/elastic-ips/eipalloc-0a1b2c3d4e5f6a7b/associate', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({instanceId: 'i-test1'}), + }) + expect(res.status).toBe(200) + const body = await res.json() as {associationId: string} + expect(body.associationId).toBe('eipassoc-test') + }) +}) + +describe('POST /elastic-ips/:allocationId/disassociate', () => { + test('disassociates eip and returns ok', async () => { + const res = await app.request('/elastic-ips/eipalloc-0a1b2c3d4e5f6a7b/disassociate', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({associationId: 'eipassoc-test'}), + }) + expect(res.status).toBe(200) + expect((await res.json() as {ok: boolean}).ok).toBe(true) + }) +}) + +// Suppress unused-type lint warnings +void ({} as FakeIpPermission) diff --git a/packages/api/src/routes/ec2.ts b/packages/api/src/routes/ec2.ts new file mode 100644 index 0000000..7000fb6 --- /dev/null +++ b/packages/api/src/routes/ec2.ts @@ -0,0 +1,482 @@ +import {Hono} from 'hono' +import type {Context} from 'hono' +import type { + CreateAmiInput, + CreateNatGatewayInput, + CreateRouteInput, + Ec2Tag, + IpPermissionInput, + RunInstanceInput, + VpcWizardInput, +} from '../services/ec2' +import {ec2Service} from '../services/ec2' + +type Ec2Service = typeof ec2Service + +function handleError(c: Context, error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error' + return c.json({error: msg}, 400) +} + +export function createEc2Router(svc: Ec2Service = ec2Service) { + const app = new Hono() + + // Instances + app.get('/instances', async (c) => { + return c.json(await svc.listInstances()) + }) + + app.get('/instances/:instanceId', async (c) => { + return c.json(await svc.describeInstance(c.req.param('instanceId'))) + }) + + app.post('/instances', async (c) => { + try { + const input = await c.req.json() + return c.json(await svc.runInstance(input)) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/instances/:instanceId/start', async (c) => { + try { + await svc.startInstance(c.req.param('instanceId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/instances/:instanceId/stop', async (c) => { + try { + await svc.stopInstance(c.req.param('instanceId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/instances/:instanceId/reboot', async (c) => { + try { + await svc.rebootInstance(c.req.param('instanceId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/instances/:instanceId/terminate', async (c) => { + try { + await svc.terminateInstance(c.req.param('instanceId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.put('/instances/:instanceId/tags', async (c) => { + try { + const {toAdd, toRemove} = await c.req.json<{toAdd: Ec2Tag[]; toRemove: string[]}>() + await svc.updateInstanceTags(c.req.param('instanceId'), toAdd, toRemove) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/instances/:instanceId/image', async (c) => { + try { + const body = await c.req.json>() + const result = await svc.createImage({...body, instanceId: c.req.param('instanceId')}) + return c.json(result) + } catch (error) { + return handleError(c, error) + } + }) + + app.get('/instances/:instanceId/console', async (c) => { + try { + return c.json(await svc.getConsoleOutput(c.req.param('instanceId'))) + } catch (error) { + return handleError(c, error) + } + }) + + // AMIs + app.get('/amis', async (c) => { + return c.json(await svc.listAmis()) + }) + + app.post('/amis/:imageId/deregister', async (c) => { + try { + await svc.deregisterImage(c.req.param('imageId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Key pairs + app.get('/key-pairs', async (c) => { + return c.json(await svc.listKeyPairs()) + }) + + app.post('/key-pairs', async (c) => { + try { + const {name} = await c.req.json<{name: string}>() + return c.json(await svc.createKeyPair(name)) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/key-pairs/:name', async (c) => { + try { + await svc.deleteKeyPair(c.req.param('name')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Security groups + app.get('/security-groups', async (c) => { + const vpcId = c.req.query('vpcId') + return c.json(await svc.listSecurityGroups(vpcId)) + }) + + app.post('/security-groups', async (c) => { + try { + const {name, description, vpcId} = await c.req.json<{name: string; description: string; vpcId: string}>() + return c.json(await svc.createSecurityGroup(name, description, vpcId)) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/security-groups/:groupId', async (c) => { + try { + await svc.deleteSecurityGroup(c.req.param('groupId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/security-groups/:groupId/ingress', async (c) => { + try { + const permission = await c.req.json() + await svc.authorizeSecurityGroupIngress(c.req.param('groupId'), permission) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/security-groups/:groupId/ingress', async (c) => { + try { + const permission = await c.req.json() + await svc.revokeSecurityGroupIngress(c.req.param('groupId'), permission) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/security-groups/:groupId/egress', async (c) => { + try { + const permission = await c.req.json() + await svc.authorizeSecurityGroupEgress(c.req.param('groupId'), permission) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/security-groups/:groupId/egress', async (c) => { + try { + const permission = await c.req.json() + await svc.revokeSecurityGroupEgress(c.req.param('groupId'), permission) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Meta + app.get('/availability-zones', async (c) => { + return c.json(await svc.listAvailabilityZones()) + }) + + app.get('/instance-types', async (c) => { + return c.json(await svc.listInstanceTypes()) + }) + + // VPCs + app.get('/vpcs', async (c) => { + return c.json(await svc.listVpcs()) + }) + + app.post('/vpcs', async (c) => { + try { + const {cidrBlock} = await c.req.json<{cidrBlock: string}>() + return c.json(await svc.createVpc(cidrBlock)) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/vpcs/:vpcId', async (c) => { + try { + await svc.deleteVpc(c.req.param('vpcId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.get('/vpcs/:vpcId/attributes', async (c) => { + try { + return c.json(await svc.getVpcAttributes(c.req.param('vpcId'))) + } catch (error) { + return handleError(c, error) + } + }) + + app.put('/vpcs/:vpcId/attributes', async (c) => { + try { + const {attribute, value} = await c.req.json<{attribute: 'enableDnsHostnames' | 'enableDnsSupport'; value: boolean}>() + await svc.modifyVpcAttribute(c.req.param('vpcId'), attribute, value) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Subnets + app.get('/subnets', async (c) => { + const vpcId = c.req.query('vpcId') + return c.json(await svc.listSubnets(vpcId)) + }) + + app.post('/subnets', async (c) => { + try { + const {vpcId, cidrBlock, availabilityZone} = await c.req.json<{ + vpcId: string + cidrBlock: string + availabilityZone?: string + }>() + return c.json(await svc.createSubnet(vpcId, cidrBlock, availabilityZone)) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/subnets/:subnetId', async (c) => { + try { + await svc.deleteSubnet(c.req.param('subnetId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.put('/subnets/:subnetId/attributes', async (c) => { + try { + const {mapPublicIpOnLaunch} = await c.req.json<{mapPublicIpOnLaunch: boolean}>() + await svc.modifySubnetMapPublicIp(c.req.param('subnetId'), mapPublicIpOnLaunch) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Internet Gateways + app.get('/internet-gateways', async (c) => { + return c.json(await svc.listInternetGateways()) + }) + + app.post('/internet-gateways', async (c) => { + try { + const body = await c.req.json<{name?: string}>().catch(() => ({} as {name?: string})) + return c.json(await svc.createInternetGateway(body.name)) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/internet-gateways/:igwId/attach', async (c) => { + try { + const {vpcId} = await c.req.json<{vpcId: string}>() + await svc.attachInternetGateway(c.req.param('igwId'), vpcId) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/internet-gateways/:igwId/detach', async (c) => { + try { + const {vpcId} = await c.req.json<{vpcId: string}>() + await svc.detachInternetGateway(c.req.param('igwId'), vpcId) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/internet-gateways/:igwId', async (c) => { + try { + await svc.deleteInternetGateway(c.req.param('igwId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // NAT Gateways + app.get('/nat-gateways', async (c) => { + const vpcId = c.req.query('vpcId') + return c.json(await svc.listNatGateways(vpcId)) + }) + + app.post('/nat-gateways', async (c) => { + try { + const input = await c.req.json() + return c.json(await svc.createNatGateway(input)) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/nat-gateways/:natId', async (c) => { + try { + await svc.deleteNatGateway(c.req.param('natId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Route Tables + app.get('/route-tables', async (c) => { + const vpcId = c.req.query('vpcId') + return c.json(await svc.listRouteTables(vpcId)) + }) + + app.post('/route-tables', async (c) => { + try { + const {vpcId, name} = await c.req.json<{vpcId: string; name?: string}>() + return c.json(await svc.createRouteTable(vpcId, name)) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/route-tables/:rtbId', async (c) => { + try { + await svc.deleteRouteTable(c.req.param('rtbId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/route-tables/:rtbId/routes', async (c) => { + try { + const input = await c.req.json() + await svc.createRoute(c.req.param('rtbId'), input) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/route-tables/:rtbId/routes', async (c) => { + try { + const cidr = c.req.query('cidr') + if (!cidr) return handleError(c, new Error('cidr query parameter is required')) + await svc.deleteRoute(c.req.param('rtbId'), cidr) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/route-tables/:rtbId/associations', async (c) => { + try { + const {subnetId} = await c.req.json<{subnetId: string}>() + const associationId = await svc.associateRouteTable(c.req.param('rtbId'), subnetId) + return c.json({associationId}) + } catch (error) { + return handleError(c, error) + } + }) + + app.delete('/route-table-associations/:associationId', async (c) => { + try { + await svc.disassociateRouteTable(c.req.param('associationId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // Elastic IPs + app.get('/elastic-ips', async (c) => { + return c.json(await svc.listElasticIps()) + }) + + app.post('/elastic-ips', async (c) => { + try { + const body = await c.req.json<{name?: string}>().catch(() => ({} as {name?: string})) + return c.json(await svc.allocateElasticIp(body.name)) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/elastic-ips/:allocationId/release', async (c) => { + try { + await svc.releaseElasticIp(c.req.param('allocationId')) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/elastic-ips/:allocationId/associate', async (c) => { + try { + const {instanceId} = await c.req.json<{instanceId: string}>() + return c.json(await svc.associateElasticIp(c.req.param('allocationId'), instanceId)) + } catch (error) { + return handleError(c, error) + } + }) + + app.post('/elastic-ips/:allocationId/disassociate', async (c) => { + try { + const {associationId} = await c.req.json<{associationId: string}>() + await svc.disassociateElasticIp(associationId) + return c.json({ok: true}) + } catch (error) { + return handleError(c, error) + } + }) + + // VPC Wizard — creates VPC + IGW + subnets + route tables + optional NAT GW in one call + app.post('/vpc-wizard', async (c) => { + try { + const input = await c.req.json() + const result = await svc.createVpcWizard(input) + return c.json(result) + } catch (error) { + return handleError(c, error) + } + }) + + return app +} + +export default createEc2Router() diff --git a/packages/api/src/routes/eks.ts b/packages/api/src/routes/eks.ts new file mode 100644 index 0000000..fa3c475 --- /dev/null +++ b/packages/api/src/routes/eks.ts @@ -0,0 +1,71 @@ +import { Hono } from "hono"; +import { eksService } from "../services/eks"; + +const app = new Hono(); + +app.get("/clusters", async (c) => { + return c.json(await eksService.listClusters()); +}); + +app.get("/clusters/:name", async (c) => { + return c.json(await eksService.describeCluster(c.req.param("name"))); +}); + +app.get("/clusters/:name/nodegroups", async (c) => { + return c.json(await eksService.listNodegroups(c.req.param("name"))); +}); + +app.get("/clusters/:name/nodegroups/:nodegroup", async (c) => { + return c.json( + await eksService.describeNodegroup( + c.req.param("name"), + c.req.param("nodegroup"), + ), + ); +}); + +app.post("/clusters/:name/nodegroups", async (c) => { + const body = await c.req.json(); + return c.json(await eksService.createNodegroup(c.req.param("name"), body), 201); +}); + +app.delete("/clusters/:name/nodegroups/:nodegroup", async (c) => { + return c.json( + await eksService.deleteNodegroup( + c.req.param("name"), + c.req.param("nodegroup"), + ), + ); +}); + +app.get("/clusters/:name/fargate-profiles", async (c) => { + return c.json(await eksService.listFargateProfiles(c.req.param("name"))); +}); + +app.get("/clusters/:name/fargate-profiles/:profile", async (c) => { + return c.json( + await eksService.describeFargateProfile( + c.req.param("name"), + c.req.param("profile"), + ), + ); +}); + +app.post("/clusters/:name/fargate-profiles", async (c) => { + const body = await c.req.json(); + return c.json( + await eksService.createFargateProfile(c.req.param("name"), body), + 201, + ); +}); + +app.delete("/clusters/:name/fargate-profiles/:profile", async (c) => { + return c.json( + await eksService.deleteFargateProfile( + c.req.param("name"), + c.req.param("profile"), + ), + ); +}); + +export default app; diff --git a/packages/api/src/routes/secretsmanager.test.ts b/packages/api/src/routes/secretsmanager.test.ts new file mode 100644 index 0000000..ba00bac --- /dev/null +++ b/packages/api/src/routes/secretsmanager.test.ts @@ -0,0 +1,232 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test' + +// The secretsmanager router imports the `secretsManager` singleton directly from +// `../aws`, so we replace that module with a stub whose `send()` is driven per +// test. `responder` returns the fake AWS SDK response and `lastCommand` captures +// the command that was dispatched so we can assert on its input shape. +let lastCommand: {constructor: {name: string}; input: Record} | null = null +let responder: (commandName: string, command: {input: Record}) => unknown + +mock.module('../aws', () => ({ + secretsManager: { + async send(command: {constructor: {name: string}; input: Record}) { + lastCommand = command + return responder(command.constructor.name, command) + }, + }, +})) + +// Imported after the mock is registered so the route binds to the stubbed client. +const {default: app} = await import('./secretsmanager') + +beforeEach(() => { + lastCommand = null + responder = () => ({}) +}) + +describe('GET /secrets', () => { + test('follows NextToken pagination and maps each entry', async () => { + const pages = [ + { + SecretList: [{Name: 'a', ARN: 'arn:a', Tags: [{Key: 'env', Value: 'prod'}]}], + NextToken: 'page2', + }, + {SecretList: [{Name: 'b', ARN: 'arn:b'}], NextToken: undefined}, + ] + let call = 0 + responder = () => pages[call++] + + const res = await app.request('/secrets') + expect(res.status).toBe(200) + const body = await res.json() + + // Both pages are merged, proving the do/while followed NextToken. + expect(call).toBe(2) + expect(body).toHaveLength(2) + expect(body[0]).toMatchObject({ + name: 'a', + arn: 'arn:a', + rotationEnabled: false, + tags: [{key: 'env', value: 'prod'}], + }) + expect(body[1]).toMatchObject({name: 'b', arn: 'arn:b', tags: []}) + }) +}) + +describe('GET /secret', () => { + test('derives versionIds from VersionIdsToStages and maps tags', async () => { + responder = () => ({ + Name: 'svc', + ARN: 'arn:svc', + Description: 'desc', + VersionIdsToStages: {v1: ['AWSCURRENT'], v2: ['AWSPREVIOUS']}, + Tags: [{Key: 'team', Value: 'core'}], + }) + + const res = await app.request('/secret?id=svc') + expect(res.status).toBe(200) + const body = await res.json() + + expect(body.versionIds).toEqual(['v1', 'v2']) + expect(body.tags).toEqual([{key: 'team', value: 'core'}]) + expect(lastCommand?.input.SecretId).toBe('svc') + }) + + test('returns 400 when id is missing and never calls AWS', async () => { + const res = await app.request('/secret') + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) +}) + +describe('GET /secret/value', () => { + test('returns SecretString verbatim', async () => { + responder = () => ({Name: 's', VersionId: 'v1', SecretString: '{"k":"v"}'}) + + const res = await app.request('/secret/value?id=s') + const body = await res.json() + + expect(body.secretString).toBe('{"k":"v"}') + expect(body.secretBinary).toBeUndefined() + expect(body.versionId).toBe('v1') + }) + + test('encodes SecretBinary as base64', async () => { + const bytes = new Uint8Array([1, 2, 3, 4]) + responder = () => ({Name: 's', SecretBinary: bytes}) + + const res = await app.request('/secret/value?id=s') + const body = await res.json() + + expect(body.secretString).toBeUndefined() + expect(body.secretBinary).toBe(Buffer.from(bytes).toString('base64')) + }) + + test('marks the plaintext response as no-store', async () => { + responder = () => ({Name: 's', SecretString: 'plain'}) + + const res = await app.request('/secret/value?id=s') + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).toBe('no-store') + }) + + test('returns 400 when id is missing and never calls AWS', async () => { + const res = await app.request('/secret/value') + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) +}) + +describe('POST /secrets', () => { + test('passes the create input through and echoes the result', async () => { + responder = (_name, command) => ({Name: command.input.Name, ARN: 'arn:new', VersionId: 'v1'}) + + const res = await app.request('/secrets', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'newSecret', secretString: 'val', description: 'd'}), + }) + const body = await res.json() + + expect(lastCommand?.input).toMatchObject({Name: 'newSecret', SecretString: 'val', Description: 'd'}) + expect(body).toMatchObject({name: 'newSecret', arn: 'arn:new', versionId: 'v1'}) + }) + + test('returns 400 when name is missing and never calls AWS', async () => { + const res = await app.request('/secrets', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({secretString: 'val'}), + }) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) + + test('returns 400 when secretString is missing and never calls AWS', async () => { + const res = await app.request('/secrets', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'newSecret'}), + }) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) + + test('returns 400 on an invalid JSON body and never calls AWS', async () => { + const res = await app.request('/secrets', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: 'not json', + }) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) +}) + +describe('PUT /secret/value', () => { + test('writes a new SecretString version', async () => { + responder = () => ({ARN: 'arn:x', VersionId: 'v2'}) + + const res = await app.request('/secret/value', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({id: 'x', secretString: 'updated'}), + }) + const body = await res.json() + + expect(lastCommand?.input).toMatchObject({SecretId: 'x', SecretString: 'updated'}) + expect(body).toMatchObject({arn: 'arn:x', versionId: 'v2'}) + }) + + test('returns 400 when id is missing and never calls AWS', async () => { + const res = await app.request('/secret/value', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({secretString: 'updated'}), + }) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) +}) + +describe('DELETE /secret', () => { + test('uses the 7-day recovery window by default', async () => { + await app.request('/secret?id=x', {method: 'DELETE'}) + + expect(lastCommand?.input.RecoveryWindowInDays).toBe(7) + expect(lastCommand?.input.ForceDeleteWithoutRecovery).toBeUndefined() + }) + + test('force deletes without recovery when force=true', async () => { + await app.request('/secret?id=x&force=true', {method: 'DELETE'}) + + expect(lastCommand?.input.ForceDeleteWithoutRecovery).toBe(true) + expect(lastCommand?.input.RecoveryWindowInDays).toBeUndefined() + }) + + test('returns 400 when id is missing and never calls AWS', async () => { + const res = await app.request('/secret', {method: 'DELETE'}) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) + + test('returns 400 when secretString is missing and never calls AWS', async () => { + const res = await app.request('/secret/value', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({id: 'x'}), + }) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) + + test('returns 400 on an invalid JSON body and never calls AWS', async () => { + const res = await app.request('/secret/value', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: 'not json', + }) + expect(res.status).toBe(400) + expect(lastCommand).toBeNull() + }) +}) diff --git a/packages/api/src/routes/secretsmanager.ts b/packages/api/src/routes/secretsmanager.ts new file mode 100644 index 0000000..d9a21bc --- /dev/null +++ b/packages/api/src/routes/secretsmanager.ts @@ -0,0 +1,126 @@ +import {Hono} from 'hono' +import { + CreateSecretCommand, + DeleteSecretCommand, + DescribeSecretCommand, + GetSecretValueCommand, + ListSecretsCommand, + PutSecretValueCommand, + type SecretListEntry, +} from '@aws-sdk/client-secrets-manager' +import {secretsManager} from '../aws' + +const app = new Hono() + +function iso(date?: Date): string | undefined { + return date ? date.toISOString() : undefined +} + +app.get('/secrets', async (c) => { + // ListSecrets is paginated; follow NextToken so environments with many + // secrets do not silently return only the first page. + const secrets: SecretListEntry[] = [] + let nextToken: string | undefined + do { + const res = await secretsManager.send(new ListSecretsCommand({NextToken: nextToken})) + secrets.push(...(res.SecretList ?? [])) + nextToken = res.NextToken + } while (nextToken) + return c.json(secrets.map(s => ({ + name: s.Name ?? '', + arn: s.ARN, + description: s.Description, + rotationEnabled: s.RotationEnabled ?? false, + kmsKeyId: s.KmsKeyId, + lastChangedDate: iso(s.LastChangedDate), + lastAccessedDate: iso(s.LastAccessedDate), + createdDate: iso(s.CreatedDate), + tags: (s.Tags ?? []).map(t => ({key: t.Key ?? '', value: t.Value ?? ''})), + }))) +}) + +app.get('/secret', async (c) => { + const id = c.req.query('id') ?? '' + if (!id) return c.json({error: 'id is required'}, 400) + const res = await secretsManager.send(new DescribeSecretCommand({SecretId: id})) + return c.json({ + name: res.Name ?? '', + arn: res.ARN, + description: res.Description, + rotationEnabled: res.RotationEnabled ?? false, + kmsKeyId: res.KmsKeyId, + lastChangedDate: iso(res.LastChangedDate), + lastAccessedDate: iso(res.LastAccessedDate), + createdDate: iso(res.CreatedDate), + deletedDate: iso(res.DeletedDate), + versionIds: Object.keys(res.VersionIdsToStages ?? {}), + tags: (res.Tags ?? []).map(t => ({key: t.Key ?? '', value: t.Value ?? ''})), + }) +}) + +app.get('/secret/value', async (c) => { + const id = c.req.query('id') ?? '' + if (!id) return c.json({error: 'id is required'}, 400) + const res = await secretsManager.send(new GetSecretValueCommand({SecretId: id})) + // The body carries plaintext secret material; mark it no-store so browsers, + // disk caches and intermediary proxies never retain a copy. + c.header('Cache-Control', 'no-store') + return c.json({ + name: res.Name ?? '', + arn: res.ARN, + versionId: res.VersionId, + secretString: res.SecretString, + // SecretBinary is a Uint8Array; expose it as base64 so binary secrets survive JSON transport. + secretBinary: res.SecretBinary ? Buffer.from(res.SecretBinary).toString('base64') : undefined, + createdDate: iso(res.CreatedDate), + }) +}) + +app.post('/secrets', async (c) => { + let body: {name?: string; description?: string; secretString?: string} + try { + body = await c.req.json() + } catch { + return c.json({error: 'invalid JSON body'}, 400) + } + const {name, description, secretString} = body + if (!name) return c.json({error: 'name is required'}, 400) + if (typeof secretString !== 'string') return c.json({error: 'secretString is required'}, 400) + const res = await secretsManager.send(new CreateSecretCommand({ + Name: name, + Description: description, + SecretString: secretString, + })) + return c.json({name: res.Name ?? name, arn: res.ARN, versionId: res.VersionId}) +}) + +app.put('/secret/value', async (c) => { + let body: {id?: string; secretString?: string} + try { + body = await c.req.json() + } catch { + return c.json({error: 'invalid JSON body'}, 400) + } + const {id, secretString} = body + if (!id) return c.json({error: 'id is required'}, 400) + if (typeof secretString !== 'string') return c.json({error: 'secretString is required'}, 400) + const res = await secretsManager.send(new PutSecretValueCommand({ + SecretId: id, + SecretString: secretString, + })) + return c.json({arn: res.ARN, versionId: res.VersionId}) +}) + +app.delete('/secret', async (c) => { + const id = c.req.query('id') ?? '' + if (!id) return c.json({error: 'id is required'}, 400) + // Local development favours immediate removal over AWS's recovery window. + const force = c.req.query('force') === 'true' + await secretsManager.send(new DeleteSecretCommand({ + SecretId: id, + ...(force ? {ForceDeleteWithoutRecovery: true} : {RecoveryWindowInDays: 7}), + })) + return c.json({ok: true}) +}) + +export default app diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts new file mode 100644 index 0000000..a2da280 --- /dev/null +++ b/packages/api/src/service/CloudProxyService.ts @@ -0,0 +1,603 @@ +import type { + CloudAvailability, + CloudDescriptor, + CloudProvider, + CloudResource, + CloudServiceAdapter, + CloudServiceDescriptor, + CloudServiceType, + CloudServiceStatus, + CloudStatus, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateDatabaseSnapshotInput, + CreateKubernetesFargateProfileInput, + CreateKubernetesNodegroupInput, + CreateResourceInput, + AppConfigConfigurationProfile, + AppConfigDeployment, + AppConfigDeploymentStrategy, + AppConfigEnvironment, + AppConfigHostedConfigurationVersion, + ApiGatewayLambdaRouteInput, + ApiGatewayLambdaRouteResult, + DatabaseSnapshot, + KubernetesFargateProfile, + KubernetesNodegroup, + LogsInsightsQueryInput, + LogsInsightsQueryResult, + KmsDecryptInput, + KmsDecryptResult, + KmsEncryptInput, + KmsEncryptResult, + NoSqlItem, + ResourceQuery, + ServerlessInvokeResult, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, + RuntimeReachability, + ServiceSchema, + StorageObjectDownload, + StorageObjectList, + UpdateResourceInput, +} from '../cloud-spi/types' +import type { + ChildCollection, + ChildItem, + CollectionPage, + DocumentStoreAdapter, + ItemStoreAdapter, + PageQuery, +} from '../cloud-spi/childCollections' +import {NotSupportedError} from '../cloud-spi/errors' +import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' +import {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog' +import {toHttpError} from '../cloud-spi/errors' +import {mapAwsSdkError} from '../adapter-aws/awsErrors' +import {endpointFor, runtimeProbes, type RuntimeProbe} from './runtimeProbe' + +/** + * Status probes are real network calls and the console polls them on a short + * interval, so results are memoized briefly. Without this, asking for + * per-service detail turns one page load into a probe per registered service. + */ +const STATUS_TTL_MS = 5_000 + +export class CloudProxyService { + private readonly runtimeCache = new Map() + private readonly serviceStatusCache = new Map() + private readonly probes: Record + + /** + * `probes` is injectable so tests can exercise status without reaching the + * network — the probes hit real runtime endpoints, which are present on a + * developer machine and absent in CI. + */ + constructor( + private readonly registry: CloudAdapterRegistry, + probes: Record = runtimeProbes, + ) { + this.probes = probes + } + + clouds(): CloudDescriptor[] { + return [ + {id: 'aws', displayName: 'AWS', availability: 'available'}, + {id: 'azure', displayName: 'Azure', availability: 'available'}, + {id: 'gcp', displayName: 'GCP', availability: 'available'}, + ] + } + + /** + * Derived from the service catalog and the adapter registry — never from a + * hardcoded per-cloud list. Registering an adapter is therefore the only + * thing needed to make a service appear as available in the UI. + */ + services(cloud: CloudProvider): CloudServiceDescriptor[] { + return SERVICE_CATALOG_ENTRIES.map((entry) => { + const adapter = this.registry.get(cloud, entry.service) + const override = adapter?.descriptorOverride?.() ?? {} + const derived: CloudAvailability = entry.legacyAvailability?.[cloud] + ?? (adapter ? 'available' : 'coming_soon') + const availability = override.availability ?? derived + const displayName = override.displayName ?? displayNameFor(entry, cloud) + + return { + cloud, + service: entry.service, + displayName, + availability, + reason: override.reason ?? unavailableReason(availability, cloud, displayName), + route: routeFor(entry, cloud), + iconKey: entry.iconKey, + group: entry.group, + order: entry.order, + } + }) + } + + /** + * Only a registered adapter can describe a service. Returning a static + * schema for an unregistered pair used to make the UI render a table that + * then failed on every request. + */ + schema(cloud: CloudProvider, service: CloudServiceType): ServiceSchema | null { + return this.registry.get(cloud, service)?.schema() ?? null + } + + /** + * Cloud-level reachability, probed against the runtime itself rather than + * inferred from one adapter's list call. + * + * Per-service detail is opt-in: the sidebar polls this every few seconds, so + * fanning out across every service by default would hammer the runtime. + */ + async status(cloud: CloudProvider, options: {includeServices?: boolean} = {}): Promise { + const services = this.registry.servicesFor(cloud) + const cached = await this.probeRuntime(cloud) + + const base: CloudStatus = { + cloud, + adapterRegistered: services.length > 0, + runtime: cached.runtime, + endpoint: endpointFor(cloud), + checkedAt: cached.checkedAt, + error: cached.error, + } + + if (!options.includeServices) return base + + return { + ...base, + services: await Promise.all(services.map((service) => this.serviceStatus(cloud, service))), + } + } + + /** Health of a single service, so the UI can gate on the service it is showing. */ + async serviceStatus(cloud: CloudProvider, service: CloudServiceType): Promise { + const cacheKey = `${cloud}:${service}` + const cached = this.serviceStatusCache.get(cacheKey) + if (cached && Date.now() - cached.at < STATUS_TTL_MS) return cached.value + + const value = await this.measureServiceStatus(cloud, service) + this.serviceStatusCache.set(cacheKey, {at: Date.now(), value}) + return value + } + + private async measureServiceStatus(cloud: CloudProvider, service: CloudServiceType): Promise { + const endpoint = endpointFor(cloud) + const adapter = this.registry.get(cloud, service) + const checkedAt = new Date().toISOString() + + if (!adapter) { + return { + cloud, service, adapterRegistered: false, runtime: 'coming_soon', + endpoint, checkedAt, latencyMs: null, error: null, errorCode: null, + } + } + + const startedAt = performance.now() + try { + // list() is a valid probe for every current adapter; health() overrides it. + if (adapter.health) await adapter.health() + else await adapter.list() + return { + cloud, service, adapterRegistered: true, runtime: 'reachable', + endpoint, checkedAt, latencyMs: Math.round(performance.now() - startedAt), + error: null, errorCode: null, + } + } catch (error) { + // The mapped code is what lets the UI distinguish "the runtime does not + // implement this" from "the runtime is down". + const {body} = toHttpError(error, mapAwsSdkError) + return { + cloud, service, adapterRegistered: true, runtime: 'unavailable', + endpoint, checkedAt, latencyMs: Math.round(performance.now() - startedAt), + error: body.detail ?? body.message, errorCode: body.code, + } + } + } + + private async probeRuntime(cloud: CloudProvider): Promise<{runtime: RuntimeReachability; checkedAt: string; error: string | null}> { + const cached = this.runtimeCache.get(cloud) + if (cached && Date.now() - cached.at < STATUS_TTL_MS) return cached.value + + const checkedAt = new Date().toISOString() + let value: {runtime: RuntimeReachability; checkedAt: string; error: string | null} + try { + await this.probes[cloud]() + value = {runtime: 'reachable', checkedAt, error: null} + } catch (error) { + value = { + runtime: 'unavailable', + checkedAt, + error: error instanceof Error ? error.message : 'Runtime check failed', + } + } + + this.runtimeCache.set(cloud, {at: Date.now(), value}) + return value + } + + async listResources(cloud: CloudProvider, service: CloudServiceType, query: ResourceQuery): Promise { + return this.requireAdapter(cloud, service).list(query) + } + + async getResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise { + return this.requireAdapter(cloud, service).get(id) + } + + async createResource(cloud: CloudProvider, service: CloudServiceType, input: CreateResourceInput): Promise { + return this.requireAdapter(cloud, service).create(input) + } + + async createApiGatewayLambdaRoute(cloud: CloudProvider, apiId: string, input: ApiGatewayLambdaRouteInput): Promise { + const adapter = this.requireAdapter(cloud, 'apigateway') + if (!adapter.createApiGatewayLambdaRoute) { + throw new NotSupportedError(`API Gateway route creation is not supported for ${cloud}`) + } + return adapter.createApiGatewayLambdaRoute(apiId, input) + } + + async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`) + return adapter.update(id, input) + } + + async deleteResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise { + await this.requireAdapter(cloud, service).delete(id) + } + + async encryptKms(cloud: CloudProvider, keyId: string, input: KmsEncryptInput): Promise { + const adapter = this.requireAdapter(cloud, 'kms') + if (!adapter.encrypt) throw new NotSupportedError(`${cloud}/kms encrypt is not supported`) + return adapter.encrypt(keyId, input) + } + + async decryptKms(cloud: CloudProvider, keyId: string, input: KmsDecryptInput): Promise { + const adapter = this.requireAdapter(cloud, 'kms') + if (!adapter.decrypt) throw new NotSupportedError(`${cloud}/kms decrypt is not supported`) + return adapter.decrypt(keyId, input) + } + + async invokeResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, + payload: string, + ): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.invoke) throw new NotSupportedError(`${cloud}/${service} invoke is not supported`) + return adapter.invoke(id, payload) + } + async listObjects(cloud: CloudProvider, service: CloudServiceType, resourceId: string, prefix?: string): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.listObjects) throw new NotSupportedError(`Object listing is not supported for ${cloud}/${service}`) + return adapter.listObjects(resourceId, prefix) + } + + async putObject(cloud: CloudProvider, service: CloudServiceType, resourceId: string, key: string, body: Uint8Array, contentType: string): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.putObject) throw new NotSupportedError(`Object upload is not supported for ${cloud}/${service}`) + await adapter.putObject(resourceId, key, body, contentType) + } + + async getObject(cloud: CloudProvider, service: CloudServiceType, resourceId: string, key: string): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.getObject) throw new NotSupportedError(`Object download is not supported for ${cloud}/${service}`) + return adapter.getObject(resourceId, key) + } + + async deleteObject(cloud: CloudProvider, service: CloudServiceType, resourceId: string, key: string): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.deleteObject) throw new NotSupportedError(`Object delete is not supported for ${cloud}/${service}`) + await adapter.deleteObject(resourceId, key) + } + + async copyObject(cloud: CloudProvider, service: CloudServiceType, srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.copyObject) throw new NotSupportedError(`Object copy is not supported for ${cloud}/${service}`) + await adapter.copyObject(srcResourceId, srcKey, destKey, destResourceId) + } + + // Child collections. `requireDocuments`/`requireItems` reject the wrong + // shape rather than the missing method, so a flat store asked for + // collections gets a 501 that names the actual problem. + + async listChildCollections(cloud: CloudProvider, service: CloudServiceType, resourceId: string, page: PageQuery): Promise> { + return this.requireDocuments(cloud, service).listCollections(resourceId, page) + } + + async createChildCollection(cloud: CloudProvider, service: CloudServiceType, resourceId: string, input: CreateResourceInput): Promise { + const documents = this.requireDocuments(cloud, service) + if (!documents.createCollection) throw new NotSupportedError(`Creating collections is not supported for ${cloud}/${service}`) + return documents.createCollection(resourceId, input) + } + + async deleteChildCollection(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string): Promise { + const documents = this.requireDocuments(cloud, service) + if (!documents.deleteCollection) throw new NotSupportedError(`Deleting collections is not supported for ${cloud}/${service}`) + await documents.deleteCollection(resourceId, collectionId) + } + + async listCollectionItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string, page: PageQuery): Promise> { + return this.requireDocuments(cloud, service).listItems(resourceId, collectionId, page) + } + + async putCollectionItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string, body: Record): Promise { + const documents = this.requireDocuments(cloud, service) + if (!documents.putItem) throw new NotSupportedError(`Writing items is not supported for ${cloud}/${service}`) + return documents.putItem(resourceId, collectionId, body) + } + + async deleteCollectionItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string, itemId: string, partitionKey?: string | null): Promise { + const documents = this.requireDocuments(cloud, service) + if (!documents.deleteItem) throw new NotSupportedError(`Deleting items is not supported for ${cloud}/${service}`) + await documents.deleteItem(resourceId, collectionId, itemId, partitionKey) + } + + async queryCollectionItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, collectionId: string, query: string): Promise> { + const documents = this.requireDocuments(cloud, service) + if (!documents.queryItems) throw new NotSupportedError(`Querying items is not supported for ${cloud}/${service}`) + return documents.queryItems(resourceId, collectionId, query) + } + + async listFlatItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, page: PageQuery): Promise> { + return this.requireItems(cloud, service).listItems(resourceId, page) + } + + async putFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, body: Record): Promise { + const items = this.requireItems(cloud, service) + if (!items.putItem) throw new NotSupportedError(`Writing items is not supported for ${cloud}/${service}`) + return items.putItem(resourceId, body) + } + + async deleteFlatItem(cloud: CloudProvider, service: CloudServiceType, resourceId: string, itemId: string, partitionKey?: string | null): Promise { + const items = this.requireItems(cloud, service) + if (!items.deleteItem) throw new NotSupportedError(`Deleting items is not supported for ${cloud}/${service}`) + await items.deleteItem(resourceId, itemId, partitionKey) + } + + async queryFlatItems(cloud: CloudProvider, service: CloudServiceType, resourceId: string, query: string): Promise> { + const items = this.requireItems(cloud, service) + if (!items.queryItems) throw new NotSupportedError(`Querying items is not supported for ${cloud}/${service}`) + return items.queryItems(resourceId, query) + } + + private requireDocuments(cloud: CloudProvider, service: CloudServiceType): DocumentStoreAdapter { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.documents) throw new NotSupportedError(`Nested collections are not supported for ${cloud}/${service}`) + return adapter.documents + } + + private requireItems(cloud: CloudProvider, service: CloudServiceType): ItemStoreAdapter { + const adapter = this.requireAdapter(cloud, service) + if (!adapter.items) throw new NotSupportedError(`Flat items are not supported for ${cloud}/${service}`) + return adapter.items + } + + async listCosmosContainers(cloud: CloudProvider, databaseId: string): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listCosmosContainers) throw new NotSupportedError(`Cosmos containers are not supported for ${cloud}/database`) + return adapter.listCosmosContainers(databaseId) + } + + async createCosmosContainer(cloud: CloudProvider, databaseId: string, input: CreateResourceInput): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.createCosmosContainer) throw new NotSupportedError(`Cosmos container creation is not supported for ${cloud}/database`) + return adapter.createCosmosContainer(databaseId, input) + } + + async deleteCosmosContainer(cloud: CloudProvider, databaseId: string, containerId: string): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.deleteCosmosContainer) throw new NotSupportedError(`Cosmos container deletion is not supported for ${cloud}/database`) + await adapter.deleteCosmosContainer(databaseId, containerId) + } + + async listCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listCosmosItems) throw new NotSupportedError(`Cosmos items are not supported for ${cloud}/database`) + return adapter.listCosmosItems(databaseId, containerId) + } + + async upsertCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, document: Record): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.upsertCosmosItem) throw new NotSupportedError(`Cosmos item upsert is not supported for ${cloud}/database`) + return adapter.upsertCosmosItem(databaseId, containerId, document) + } + + async deleteCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.deleteCosmosItem) throw new NotSupportedError(`Cosmos item deletion is not supported for ${cloud}/database`) + await adapter.deleteCosmosItem(databaseId, containerId, itemId, partitionKey) + } + + async queryCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string, query: string): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.queryCosmosItems) throw new NotSupportedError(`Cosmos query is not supported for ${cloud}/database`) + return adapter.queryCosmosItems(databaseId, containerId, query) + } + + async listDatabaseSnapshots(cloud: CloudProvider, instanceIdentifier?: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listDatabaseSnapshots) throw new NotSupportedError(`Snapshot listing is not supported for ${cloud}/database`) + return adapter.listDatabaseSnapshots(instanceIdentifier) + } + + async createDatabaseSnapshot(cloud: CloudProvider, input: CreateDatabaseSnapshotInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.createDatabaseSnapshot) throw new NotSupportedError(`Snapshot creation is not supported for ${cloud}/database`) + return adapter.createDatabaseSnapshot(input) + } + + async listDatabaseOrderableInstanceClasses(cloud: CloudProvider, engine?: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listDatabaseOrderableInstanceClasses) throw new NotSupportedError(`Orderable instance class listing is not supported for ${cloud}/database`) + return adapter.listDatabaseOrderableInstanceClasses(engine) + } + + async listSqlDatabases(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listSqlDatabases) throw new NotSupportedError(`SQL database browsing is not supported for ${cloud}/database`) + return adapter.listSqlDatabases(serverId, connection) + } + + async listSqlTables(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listSqlTables) throw new NotSupportedError(`SQL table browsing is not supported for ${cloud}/database`) + return adapter.listSqlTables(serverId, connection) + } + + async querySql(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput, query: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.querySql) throw new NotSupportedError(`SQL query is not supported for ${cloud}/database`) + return adapter.querySql(serverId, connection, query) + } + + async queryLogs(cloud: CloudProvider, logGroupName: string, input: LogsInsightsQueryInput): Promise { + const adapter = this.requireAdapter(cloud, 'logs') + if (!adapter.queryLogs) throw new NotSupportedError(`Logs Insights query is not supported for ${cloud}/logs`) + return adapter.queryLogs(logGroupName, input) + } + + async listNoSqlItems(cloud: CloudProvider, resourceId: string): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listNoSqlItems) throw new NotSupportedError(`Item listing is not supported for ${cloud}/nosql`) + return adapter.listNoSqlItems(resourceId) + } + + async putNoSqlItem(cloud: CloudProvider, resourceId: string, document: Record): Promise { + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.putNoSqlItem) throw new NotSupportedError(`Item creation is not supported for ${cloud}/nosql`) + return adapter.putNoSqlItem(resourceId, document) + } + + async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise { + const adapter = this.requireAdapter(cloud, 'k8s') + if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`) + return adapter.listKubernetesNodegroups(clusterId) + } + + async createKubernetesNodegroup(cloud: CloudProvider, clusterId: string, input: CreateKubernetesNodegroupInput): Promise { + const adapter = this.requireAdapter(cloud, 'k8s') + if (!adapter.createKubernetesNodegroup) throw new NotSupportedError(`Nodegroup creation is not supported for ${cloud}/k8s`) + return adapter.createKubernetesNodegroup(clusterId, input) + } + + async deleteKubernetesNodegroup(cloud: CloudProvider, clusterId: string, nodegroupId: string): Promise { + const adapter = this.requireAdapter(cloud, 'k8s') + if (!adapter.deleteKubernetesNodegroup) throw new NotSupportedError(`Nodegroup deletion is not supported for ${cloud}/k8s`) + await adapter.deleteKubernetesNodegroup(clusterId, nodegroupId) + } + + async listKubernetesFargateProfiles(cloud: CloudProvider, clusterId: string): Promise { + const adapter = this.requireAdapter(cloud, 'k8s') + if (!adapter.listKubernetesFargateProfiles) throw new NotSupportedError(`Fargate profiles are not supported for ${cloud}/k8s`) + return adapter.listKubernetesFargateProfiles(clusterId) + } + + async createKubernetesFargateProfile(cloud: CloudProvider, clusterId: string, input: CreateKubernetesFargateProfileInput): Promise { + const adapter = this.requireAdapter(cloud, 'k8s') + if (!adapter.createKubernetesFargateProfile) throw new NotSupportedError(`Fargate profile creation is not supported for ${cloud}/k8s`) + return adapter.createKubernetesFargateProfile(clusterId, input) + } + + async deleteKubernetesFargateProfile(cloud: CloudProvider, clusterId: string, profileId: string): Promise { + const adapter = this.requireAdapter(cloud, 'k8s') + if (!adapter.deleteKubernetesFargateProfile) throw new NotSupportedError(`Fargate profile deletion is not supported for ${cloud}/k8s`) + await adapter.deleteKubernetesFargateProfile(clusterId, profileId) + } + + async clearEmailInbox(cloud: CloudProvider): Promise { + const adapter = this.requireAdapter(cloud, 'email') + if (!adapter.clearEmailInbox) throw new NotSupportedError(`Inbox clearing is not supported for ${cloud}/email`) + await adapter.clearEmailInbox() + } + + private appConfigAdapter(cloud: CloudProvider): CloudServiceAdapter { + const adapter = this.requireAdapter(cloud, 'configuration') + const method = adapter.listAppConfigEnvironments + if (!method) throw new NotSupportedError(`AppConfig resources are not supported for ${cloud}/configuration`) + return adapter + } + + async listAppConfigEnvironments(cloud: CloudProvider, applicationId: string): Promise { + return this.appConfigAdapter(cloud).listAppConfigEnvironments!(applicationId) + } + + async createAppConfigEnvironment(cloud: CloudProvider, applicationId: string, input: CreateResourceInput): Promise { + return this.appConfigAdapter(cloud).createAppConfigEnvironment!(applicationId, input) + } + + async deleteAppConfigEnvironment(cloud: CloudProvider, applicationId: string, environmentId: string): Promise { + await this.appConfigAdapter(cloud).deleteAppConfigEnvironment!(applicationId, environmentId) + } + + async listAppConfigConfigurationProfiles(cloud: CloudProvider, applicationId: string): Promise { + return this.appConfigAdapter(cloud).listAppConfigConfigurationProfiles!(applicationId) + } + + async createAppConfigConfigurationProfile(cloud: CloudProvider, applicationId: string, input: CreateResourceInput): Promise { + return this.appConfigAdapter(cloud).createAppConfigConfigurationProfile!(applicationId, input) + } + + async deleteAppConfigConfigurationProfile(cloud: CloudProvider, applicationId: string, profileId: string): Promise { + await this.appConfigAdapter(cloud).deleteAppConfigConfigurationProfile!(applicationId, profileId) + } + + async listAppConfigHostedConfigurationVersions(cloud: CloudProvider, applicationId: string, profileId: string): Promise { + return this.appConfigAdapter(cloud).listAppConfigHostedConfigurationVersions!(applicationId, profileId) + } + + async getAppConfigHostedConfigurationVersion(cloud: CloudProvider, applicationId: string, profileId: string, versionNumber: number): Promise { + return this.appConfigAdapter(cloud).getAppConfigHostedConfigurationVersion!(applicationId, profileId, versionNumber) + } + + async createAppConfigHostedConfigurationVersion(cloud: CloudProvider, applicationId: string, profileId: string, input: CreateResourceInput): Promise { + return this.appConfigAdapter(cloud).createAppConfigHostedConfigurationVersion!(applicationId, profileId, input) + } + + async deleteAppConfigHostedConfigurationVersion(cloud: CloudProvider, applicationId: string, profileId: string, versionNumber: number): Promise { + await this.appConfigAdapter(cloud).deleteAppConfigHostedConfigurationVersion!(applicationId, profileId, versionNumber) + } + + async listAppConfigDeploymentStrategies(cloud: CloudProvider): Promise { + return this.appConfigAdapter(cloud).listAppConfigDeploymentStrategies!() + } + + async createAppConfigDeploymentStrategy(cloud: CloudProvider, input: CreateResourceInput): Promise { + return this.appConfigAdapter(cloud).createAppConfigDeploymentStrategy!(input) + } + + async deleteAppConfigDeploymentStrategy(cloud: CloudProvider, strategyId: string): Promise { + await this.appConfigAdapter(cloud).deleteAppConfigDeploymentStrategy!(strategyId) + } + + async startAppConfigDeployment(cloud: CloudProvider, applicationId: string, environmentId: string, input: CreateResourceInput): Promise { + return this.appConfigAdapter(cloud).startAppConfigDeployment!(applicationId, environmentId, input) + } + + async getAppConfigDeployment(cloud: CloudProvider, applicationId: string, environmentId: string, deploymentNumber: number): Promise { + return this.appConfigAdapter(cloud).getAppConfigDeployment!(applicationId, environmentId, deploymentNumber) + } + + private requireAdapter(cloud: CloudProvider, service: CloudServiceType) { + const adapter = this.registry.get(cloud, service) + if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`) + return adapter + } +} + +/** Keeps the promise that every coming_soon descriptor explains itself. */ +function unavailableReason( + availability: CloudAvailability, + cloud: CloudProvider, + displayName: string, +): string | undefined { + if (availability === 'available') return undefined + return `No ${cloud.toUpperCase()} adapter is registered for ${displayName} yet.` +} diff --git a/packages/api/src/service/runtimeProbe.ts b/packages/api/src/service/runtimeProbe.ts new file mode 100644 index 0000000..85a772d --- /dev/null +++ b/packages/api/src/service/runtimeProbe.ts @@ -0,0 +1,47 @@ +import {RuntimeUnavailableError} from '../cloud-spi/errors' +import type {CloudProvider} from '../cloud-spi/types' +import {azure, azureEndpoint} from '../azure' +import {gcp, gcpEndpoint} from '../gcp' + +/** + * Liveness probes per runtime. + * + * Cloud status used to be inferred from whether the *storage* adapter could list, + * which meant a cloud whose storage worked reported "reachable" no matter what + * else was broken, and a cloud with no storage adapter reported "coming_soon" + * even when its runtime was up. These probe the runtime itself. + */ +export type RuntimeProbe = () => Promise + +export const runtimeProbes: Record = { + // Every runtime exposes a health endpoint, but under its own path: core and + // Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health. + aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'Floci core'), + azure: async () => { + await azure.fetch('/_floci/health', {method: 'GET'}) + }, + gcp: () => gcp.health(), +} + +export function endpointFor(cloud: CloudProvider): string | null { + if (cloud === 'aws') return awsEndpoint() + if (cloud === 'azure') return azureEndpoint() + if (cloud === 'gcp') return gcpEndpoint() + return null +} + +export function awsEndpoint(): string { + return process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566' +} + +async function probeHttp(endpoint: string, label: string): Promise { + let res: Response + try { + res = await globalThis.fetch(endpoint, {method: 'GET'}) + } catch (error) { + throw new RuntimeUnavailableError(`Cannot reach ${label} at ${endpoint}`, {cause: error}) + } + if (res.status >= 500) { + throw new RuntimeUnavailableError(`${label} at ${endpoint} returned HTTP ${res.status}`) + } +} diff --git a/packages/api/src/services/ec2.test.ts b/packages/api/src/services/ec2.test.ts new file mode 100644 index 0000000..7ca5b0d --- /dev/null +++ b/packages/api/src/services/ec2.test.ts @@ -0,0 +1,583 @@ +import {describe, expect, test} from 'bun:test' +import type {EC2Client} from '@aws-sdk/client-ec2' +import { + AllocateAddressCommand, + AssociateAddressCommand, + AssociateRouteTableCommand, + AuthorizeSecurityGroupEgressCommand, + AuthorizeSecurityGroupIngressCommand, + CreateImageCommand, + CreateInternetGatewayCommand, + CreateKeyPairCommand, + CreateNatGatewayCommand, + CreateRouteCommand, + CreateRouteTableCommand, + CreateSecurityGroupCommand, + CreateSubnetCommand, + CreateTagsCommand, + CreateVpcCommand, + DeleteInternetGatewayCommand, + DeleteKeyPairCommand, + DeleteNatGatewayCommand, + DeleteRouteCommand, + DeleteSecurityGroupCommand, + DeleteSubnetCommand, + DeleteTagsCommand, + DeleteVpcCommand, + DeregisterImageCommand, + DescribeAddressesCommand, + DescribeAvailabilityZonesCommand, + DescribeImagesCommand, + DescribeInstanceTypesCommand, + DescribeInternetGatewaysCommand, + DescribeKeyPairsCommand, + DescribeNatGatewaysCommand, + DescribeRouteTablesCommand, + DescribeSecurityGroupsCommand, + DescribeSubnetsCommand, + DescribeVpcAttributeCommand, + DescribeVpcsCommand, + DetachInternetGatewayCommand, + DisassociateAddressCommand, + DisassociateRouteTableCommand, + GetConsoleOutputCommand, + RebootInstancesCommand, + ModifySubnetAttributeCommand, + ModifyVpcAttributeCommand, + ReleaseAddressCommand, + RevokeSecurityGroupEgressCommand, + RevokeSecurityGroupIngressCommand, + RunInstancesCommand, + StartInstancesCommand, + StopInstancesCommand, + TerminateInstancesCommand, +} from '@aws-sdk/client-ec2' +import {createEc2Service} from './ec2' + +function fakeClient(responses: Record = {}): EC2Client { + return { + send: async (cmd: {constructor: {name: string}}) => { + const name = cmd.constructor.name + if (name in responses) return responses[name] + return {} + }, + } as unknown as EC2Client +} + +describe('ec2Service.runInstance', () => { + test('sends RunInstancesCommand and returns mapped instance', async () => { + const sentCmds: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sentCmds.push(cmd) + return { + Instances: [{ + InstanceId: 'i-new123', + Tags: [{Key: 'Name', Value: 'my-instance'}], + State: {Name: 'pending'}, + }], + } + }, + } as unknown as EC2Client + + const service = createEc2Service(client) + const result = await service.runInstance({ + name: 'my-instance', + imageId: 'ami-abc', + instanceType: 't3.micro', + }) + + expect(sentCmds[0]).toBeInstanceOf(RunInstancesCommand) + expect(result.instanceId).toBe('i-new123') + expect(result.name).toBe('my-instance') + expect(result.state).toBe('pending') + }) +}) + +describe('ec2Service lifecycle', () => { + test('startInstance sends StartInstancesCommand with correct id', async () => { + const sentCmds: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sentCmds.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).startInstance('i-abc') + expect(sentCmds[0]).toBeInstanceOf(StartInstancesCommand) + }) + + test('stopInstance sends StopInstancesCommand with correct id', async () => { + const sentCmds: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sentCmds.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).stopInstance('i-abc') + expect(sentCmds[0]).toBeInstanceOf(StopInstancesCommand) + }) + + test('rebootInstance sends RebootInstancesCommand', async () => { + const sentCmds: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sentCmds.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).rebootInstance('i-abc') + expect(sentCmds[0]).toBeInstanceOf(RebootInstancesCommand) + }) + + test('terminateInstance sends TerminateInstancesCommand', async () => { + const sentCmds: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sentCmds.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).terminateInstance('i-abc') + expect(sentCmds[0]).toBeInstanceOf(TerminateInstancesCommand) + }) +}) + +describe('ec2Service.updateInstanceTags', () => { + test('calls CreateTags for additions and DeleteTags for removals', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + const service = createEc2Service(client) + await service.updateInstanceTags('i-abc', [{key: 'Env', value: 'prod'}], ['OldKey']) + expect(sent[0]).toBeInstanceOf(CreateTagsCommand) + expect(sent[1]).toBeInstanceOf(DeleteTagsCommand) + }) + + test('skips CreateTags when nothing to add', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).updateInstanceTags('i-abc', [], ['OldKey']) + expect(sent[0]).toBeInstanceOf(DeleteTagsCommand) + expect(sent).toHaveLength(1) + }) +}) + +describe('ec2Service.createImage', () => { + test('sends CreateImageCommand and returns imageId', async () => { + const client = fakeClient({CreateImageCommand: {ImageId: 'ami-new'}}) + const result = await createEc2Service(client).createImage({instanceId: 'i-abc', name: 'snap-1'}) + expect(result.imageId).toBe('ami-new') + }) +}) + +describe('ec2Service.deregisterImage', () => { + test('sends DeregisterImageCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).deregisterImage('ami-old') + expect(sent[0]).toBeInstanceOf(DeregisterImageCommand) + }) +}) + +describe('ec2Service.listAmis', () => { + test('requests the complete image catalog and maps launchable AMIs', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + return { + Images: [{ + ImageId: 'ami-amazonlinux2023', + Name: 'Amazon Linux 2023', + }], + } + }, + } as unknown as EC2Client + + const result = await createEc2Service(client).listAmis() + + expect(sent[0]).toBeInstanceOf(DescribeImagesCommand) + expect((sent[0] as DescribeImagesCommand).input).toEqual({}) + expect(result).toEqual([ + expect.objectContaining({ + imageId: 'ami-amazonlinux2023', + name: 'Amazon Linux 2023', + }), + ]) + }) +}) + +describe('ec2Service.getConsoleOutput', () => { + test('decodes base64 output', async () => { + const encoded = Buffer.from('hello console').toString('base64') + const client = fakeClient({GetConsoleOutputCommand: {Output: encoded}}) + const result = await createEc2Service(client).getConsoleOutput('i-abc') + expect(result.output).toBe('hello console') + }) + + test('returns empty output when operation is unsupported', async () => { + const client: EC2Client = { + send: async () => { throw new Error('GetConsoleOutput is not supported') }, + } as unknown as EC2Client + const result = await createEc2Service(client).getConsoleOutput('i-abc') + expect(result.output).toBe('') + }) +}) + +describe('ec2Service key pairs', () => { + test('listKeyPairs returns empty array when unsupported', async () => { + const client: EC2Client = { + send: async () => { throw new Error('DescribeKeyPairs is not supported') }, + } as unknown as EC2Client + const result = await createEc2Service(client).listKeyPairs() + expect(result).toEqual([]) + }) + + test('createKeyPair sends CreateKeyPairCommand and returns material', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + return {KeyPairId: 'kp-1', KeyName: 'my-key', KeyMaterial: '-----BEGIN RSA-----'} + }, + } as unknown as EC2Client + const result = await createEc2Service(client).createKeyPair('my-key') + expect(sent[0]).toBeInstanceOf(CreateKeyPairCommand) + expect(result.keyName).toBe('my-key') + expect(result.keyMaterial).toBe('-----BEGIN RSA-----') + }) + + test('deleteKeyPair sends DeleteKeyPairCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).deleteKeyPair('my-key') + expect(sent[0]).toBeInstanceOf(DeleteKeyPairCommand) + }) +}) + +describe('ec2Service security groups', () => { + test('listSecurityGroups returns empty array when unsupported', async () => { + const client: EC2Client = { + send: async () => { throw new Error('DescribeSecurityGroups is not supported') }, + } as unknown as EC2Client + expect(await createEc2Service(client).listSecurityGroups()).toEqual([]) + }) + + test('createSecurityGroup sends CreateSecurityGroupCommand and returns groupId', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { sent.push(cmd); return {GroupId: 'sg-new'} }, + } as unknown as EC2Client + const result = await createEc2Service(client).createSecurityGroup('web', 'Web SG', 'vpc-1') + expect(sent[0]).toBeInstanceOf(CreateSecurityGroupCommand) + expect(result.groupId).toBe('sg-new') + }) + + test('deleteSecurityGroup sends DeleteSecurityGroupCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).deleteSecurityGroup('sg-1') + expect(sent[0]).toBeInstanceOf(DeleteSecurityGroupCommand) + }) + + test('authorizeSecurityGroupIngress sends correct command', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).authorizeSecurityGroupIngress('sg-1', {protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'}) + expect(sent[0]).toBeInstanceOf(AuthorizeSecurityGroupIngressCommand) + }) + + test('revokeSecurityGroupIngress sends correct command', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).revokeSecurityGroupIngress('sg-1', {protocol: 'tcp', fromPort: 80, toPort: 80, cidr: '0.0.0.0/0'}) + expect(sent[0]).toBeInstanceOf(RevokeSecurityGroupIngressCommand) + }) + + test('authorizeSecurityGroupEgress sends correct command', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).authorizeSecurityGroupEgress('sg-1', {protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}) + expect(sent[0]).toBeInstanceOf(AuthorizeSecurityGroupEgressCommand) + }) + + test('revokeSecurityGroupEgress sends correct command', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).revokeSecurityGroupEgress('sg-1', {protocol: 'tcp', fromPort: 443, toPort: 443, cidr: '0.0.0.0/0'}) + expect(sent[0]).toBeInstanceOf(RevokeSecurityGroupEgressCommand) + }) +}) + +describe('ec2Service VPCs and subnets', () => { + test('listVpcs returns empty array when unsupported', async () => { + const client: EC2Client = { + send: async () => { throw new Error('DescribeVpcs is not supported') }, + } as unknown as EC2Client + expect(await createEc2Service(client).listVpcs()).toEqual([]) + }) + + test('createVpc sends CreateVpcCommand and returns mapped vpc', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { sent.push(cmd); return {Vpc: {VpcId: 'vpc-new', CidrBlock: '10.0.0.0/16', IsDefault: false}} }, + } as unknown as EC2Client + const result = await createEc2Service(client).createVpc('10.0.0.0/16') + expect(sent[0]).toBeInstanceOf(CreateVpcCommand) + expect(result.vpcId).toBe('vpc-new') + expect(result.cidrBlock).toBe('10.0.0.0/16') + }) + + test('deleteVpc sends DeleteVpcCommand as final command', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).deleteVpc('vpc-1') + // deleteVpc first cleans up SGs, subnets, IGWs, RTBs then sends DeleteVpcCommand last + expect(sent[sent.length - 1]).toBeInstanceOf(DeleteVpcCommand) + }) + + test('listSubnets returns empty array when unsupported', async () => { + const client: EC2Client = { + send: async () => { throw new Error('DescribeSubnets is not supported') }, + } as unknown as EC2Client + expect(await createEc2Service(client).listSubnets()).toEqual([]) + }) + + test('createSubnet sends CreateSubnetCommand and returns mapped subnet', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + return {Subnet: {SubnetId: 'subnet-new', VpcId: 'vpc-1', CidrBlock: '10.0.1.0/24', AvailabilityZone: 'us-east-1a'}} + }, + } as unknown as EC2Client + const result = await createEc2Service(client).createSubnet('vpc-1', '10.0.1.0/24', 'us-east-1a') + expect(sent[0]).toBeInstanceOf(CreateSubnetCommand) + expect(result.subnetId).toBe('subnet-new') + expect(result.availabilityZone).toBe('us-east-1a') + }) + + test('deleteSubnet sends DeleteSubnetCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).deleteSubnet('subnet-1') + expect(sent[0]).toBeInstanceOf(DeleteSubnetCommand) + }) + + test('modifySubnetMapPublicIp sends ModifySubnetAttributeCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).modifySubnetMapPublicIp('subnet-1', true) + expect(sent[0]).toBeInstanceOf(ModifySubnetAttributeCommand) + }) + + test('getVpcAttributes returns dns settings from two parallel describe calls', async () => { + const client = fakeClient({ + DescribeVpcAttributeCommand: {AttributeValue: {Value: true}}, + }) + const result = await createEc2Service(client).getVpcAttributes('vpc-1') + expect(result).toHaveProperty('enableDnsHostnames') + expect(result).toHaveProperty('enableDnsSupport') + }) + + test('modifyVpcAttribute sends ModifyVpcAttributeCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).modifyVpcAttribute('vpc-1', 'enableDnsHostnames', true) + expect(sent[0]).toBeInstanceOf(ModifyVpcAttributeCommand) + }) +}) + +describe('ec2Service Internet Gateways', () => { + test('listInternetGateways returns empty array when unsupported', async () => { + const client: EC2Client = {send: async () => { throw new Error('DescribeInternetGateways is not supported') }} as unknown as EC2Client + expect(await createEc2Service(client).listInternetGateways()).toEqual([]) + }) + + test('createInternetGateway sends CreateInternetGatewayCommand and returns mapped igw', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + if (cmd instanceof CreateInternetGatewayCommand) return {InternetGateway: {InternetGatewayId: 'igw-0a1b2c3d', Attachments: [], Tags: []}} + return {} + }, + } as unknown as EC2Client + const result = await createEc2Service(client).createInternetGateway() + expect(sent[0]).toBeInstanceOf(CreateInternetGatewayCommand) + expect(result.internetGatewayId).toBe('igw-0a1b2c3d') + }) + + test('deleteInternetGateway detaches all VPCs then deletes', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + if (cmd instanceof DescribeInternetGatewaysCommand) { + return {InternetGateways: [{InternetGatewayId: 'igw-0a1b2c3d', Attachments: [{VpcId: 'vpc-0a1b2c3d', State: 'available'}]}]} + } + return {} + }, + } as unknown as EC2Client + await createEc2Service(client).deleteInternetGateway('igw-0a1b2c3d') + expect(sent[0]).toBeInstanceOf(DescribeInternetGatewaysCommand) + expect(sent[1]).toBeInstanceOf(DetachInternetGatewayCommand) + expect(sent[2]).toBeInstanceOf(DeleteInternetGatewayCommand) + }) +}) + +describe('ec2Service NAT Gateways', () => { + test('listNatGateways returns empty array when unsupported', async () => { + const client: EC2Client = {send: async () => { throw new Error('DescribeNatGateways is not supported') }} as unknown as EC2Client + expect(await createEc2Service(client).listNatGateways()).toEqual([]) + }) + + test('createNatGateway auto-allocates EIP when allocationId is absent', async () => { + const sent: unknown[] = [] + let attempt = 0 + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + if (cmd instanceof AllocateAddressCommand) return {AllocationId: 'eipalloc-0a1b2c3d', PublicIp: '1.2.3.4'} + if (cmd instanceof CreateNatGatewayCommand) return {NatGateway: {NatGatewayId: 'nat-0a1b2c3d', SubnetId: 'subnet-1', VpcId: 'vpc-1'}} + if (cmd instanceof DescribeNatGatewaysCommand) { + attempt++ + return {NatGateways: [{NatGatewayId: 'nat-0a1b2c3d', SubnetId: 'subnet-1', VpcId: 'vpc-1', State: attempt >= 1 ? 'available' : 'pending'}]} + } + return {} + }, + } as unknown as EC2Client + const result = await createEc2Service(client).createNatGateway({subnetId: 'subnet-1'}) + expect(sent[0]).toBeInstanceOf(AllocateAddressCommand) + expect(sent[1]).toBeInstanceOf(CreateNatGatewayCommand) + expect(result.natGatewayId).toBe('nat-0a1b2c3d') + }) + + test('deleteNatGateway sends DeleteNatGatewayCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + if (cmd instanceof DescribeNatGatewaysCommand) return {NatGateways: [{NatGatewayId: 'nat-0a1b2c3d', State: 'deleted'}]} + return {} + }, + } as unknown as EC2Client + await createEc2Service(client).deleteNatGateway('nat-0a1b2c3d') + expect(sent[0]).toBeInstanceOf(DeleteNatGatewayCommand) + }) +}) + +describe('ec2Service Route Tables', () => { + test('listRouteTables returns empty array when unsupported', async () => { + const client: EC2Client = {send: async () => { throw new Error('DescribeRouteTables is not supported') }} as unknown as EC2Client + expect(await createEc2Service(client).listRouteTables()).toEqual([]) + }) + + test('createRoute sends CreateRouteCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).createRoute('rtb-0a1b2c3d', {destinationCidrBlock: '0.0.0.0/0', gatewayId: 'igw-0a1b2c3d'}) + expect(sent[0]).toBeInstanceOf(CreateRouteCommand) + }) + + test('deleteRoute sends DeleteRouteCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).deleteRoute('rtb-0a1b2c3d', '0.0.0.0/0') + expect(sent[0]).toBeInstanceOf(DeleteRouteCommand) + }) + + test('associateRouteTable sends AssociateRouteTableCommand and returns associationId', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { sent.push(cmd); return {AssociationId: 'rtbassoc-0a1b2c3d'} }, + } as unknown as EC2Client + const id = await createEc2Service(client).associateRouteTable('rtb-0a1b2c3d', 'subnet-0a1b2c3d') + expect(sent[0]).toBeInstanceOf(AssociateRouteTableCommand) + expect(id).toBe('rtbassoc-0a1b2c3d') + }) + + test('disassociateRouteTable sends DisassociateRouteTableCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).disassociateRouteTable('rtbassoc-0a1b2c3d') + expect(sent[0]).toBeInstanceOf(DisassociateRouteTableCommand) + }) +}) + +describe('ec2Service Elastic IPs', () => { + test('listElasticIps returns empty array when unsupported', async () => { + const client: EC2Client = {send: async () => { throw new Error('DescribeAddresses is not supported') }} as unknown as EC2Client + expect(await createEc2Service(client).listElasticIps()).toEqual([]) + }) + + test('allocateElasticIp sends AllocateAddressCommand and returns eip', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + if (cmd instanceof AllocateAddressCommand) return {AllocationId: 'eipalloc-0a1b2c3d', PublicIp: '54.0.0.1', Domain: 'vpc'} + return {} + }, + } as unknown as EC2Client + const result = await createEc2Service(client).allocateElasticIp() + expect(sent[0]).toBeInstanceOf(AllocateAddressCommand) + expect(result.allocationId).toBe('eipalloc-0a1b2c3d') + expect(result.publicIp).toBe('54.0.0.1') + }) + + test('releaseElasticIp sends ReleaseAddressCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).releaseElasticIp('eipalloc-0a1b2c3d') + expect(sent[0]).toBeInstanceOf(ReleaseAddressCommand) + }) + + test('associateElasticIp sends AssociateAddressCommand and returns associationId', async () => { + const sent: unknown[] = [] + const client: EC2Client = { + send: async (cmd: unknown) => { + sent.push(cmd) + if (cmd instanceof AssociateAddressCommand) return {AssociationId: 'eipassoc-test'} + return {} + }, + } as unknown as EC2Client + const result = await createEc2Service(client).associateElasticIp('eipalloc-0a1b2c3d', 'i-test1') + expect(sent[0]).toBeInstanceOf(AssociateAddressCommand) + expect(result.associationId).toBe('eipassoc-test') + }) + + test('disassociateElasticIp sends DisassociateAddressCommand', async () => { + const sent: unknown[] = [] + const client: EC2Client = {send: async (cmd: unknown) => { sent.push(cmd); return {} }} as unknown as EC2Client + await createEc2Service(client).disassociateElasticIp('eipassoc-test') + expect(sent[0]).toBeInstanceOf(DisassociateAddressCommand) + }) +}) + +describe('ec2Service metadata', () => { + test('listAvailabilityZones returns empty array when unsupported', async () => { + const client: EC2Client = {send: async () => { throw new Error('DescribeAvailabilityZones is not supported') }} as unknown as EC2Client + expect(await createEc2Service(client).listAvailabilityZones()).toEqual([]) + }) + + test('listAvailabilityZones returns mapped zones', async () => { + const client = fakeClient({ + DescribeAvailabilityZonesCommand: {AvailabilityZones: [{ZoneName: 'us-east-1a', ZoneId: 'use1-az1', State: 'available'}]}, + }) + const result = await createEc2Service(client).listAvailabilityZones() + expect(result).toHaveLength(1) + expect(result[0].zoneName).toBe('us-east-1a') + expect(result[0].zoneId).toBe('use1-az1') + }) + + test('listInstanceTypes returns empty array when unsupported', async () => { + const client: EC2Client = {send: async () => { throw new Error('DescribeInstanceTypes is not supported') }} as unknown as EC2Client + expect(await createEc2Service(client).listInstanceTypes()).toEqual([]) + }) + + test('listInstanceTypes returns mapped types', async () => { + const client = fakeClient({ + DescribeInstanceTypesCommand: {InstanceTypes: [{InstanceType: 't3.micro', VCpuInfo: {DefaultVCpus: 2}, MemoryInfo: {SizeInMiB: 1024}}]}, + }) + const result = await createEc2Service(client).listInstanceTypes() + expect(result).toHaveLength(1) + expect(result[0].instanceType).toBe('t3.micro') + expect(result[0].vcpu).toBe(2) + expect(result[0].memoryMiB).toBe(1024) + }) +}) + +// Suppress unused import warnings +void DescribeKeyPairsCommand +void DescribeSecurityGroupsCommand +void DescribeSubnetsCommand +void DescribeVpcAttributeCommand +void DescribeVpcsCommand +void DescribeInternetGatewaysCommand +void DescribeNatGatewaysCommand +void DescribeRouteTablesCommand +void DescribeAddressesCommand +void CreateRouteTableCommand +void ReleaseAddressCommand diff --git a/packages/api/src/services/ec2.ts b/packages/api/src/services/ec2.ts new file mode 100644 index 0000000..4085b10 --- /dev/null +++ b/packages/api/src/services/ec2.ts @@ -0,0 +1,1204 @@ +import { + AllocateAddressCommand, + AssociateAddressCommand, + AssociateRouteTableCommand, + AttachInternetGatewayCommand, + AuthorizeSecurityGroupEgressCommand, + AuthorizeSecurityGroupIngressCommand, + CreateImageCommand, + CreateInternetGatewayCommand, + CreateKeyPairCommand, + CreateNatGatewayCommand, + CreateRouteCommand, + CreateRouteTableCommand, + CreateSecurityGroupCommand, + CreateSubnetCommand, + CreateTagsCommand, + CreateVpcCommand, + DeleteInternetGatewayCommand, + DeleteKeyPairCommand, + DeleteNatGatewayCommand, + DeleteRouteCommand, + DeleteRouteTableCommand, + DeleteSecurityGroupCommand, + DeleteSubnetCommand, + DeleteTagsCommand, + DeleteVpcCommand, + DeregisterImageCommand, + DescribeAddressesCommand, + DescribeAvailabilityZonesCommand, + DescribeImagesCommand, + DescribeInstancesCommand, + DescribeInstanceTypesCommand, + DescribeInternetGatewaysCommand, + DescribeKeyPairsCommand, + DescribeNatGatewaysCommand, + DescribeRouteTablesCommand, + DescribeSecurityGroupsCommand, + DescribeSubnetsCommand, + DescribeVpcAttributeCommand, + DescribeVpcsCommand, + DetachInternetGatewayCommand, + DisassociateAddressCommand, + DisassociateRouteTableCommand, + GetConsoleOutputCommand, + ModifySubnetAttributeCommand, + ModifyVpcAttributeCommand, + RebootInstancesCommand, + ReleaseAddressCommand, + RevokeSecurityGroupEgressCommand, + RevokeSecurityGroupIngressCommand, + RunInstancesCommand, + StartInstancesCommand, + StopInstancesCommand, + TerminateInstancesCommand, + type Address, + type EC2Client, + type Image, + type Instance, + type InternetGateway, + type IpPermission, + type KeyPairInfo, + type NatGateway, + type RouteTable, + type SecurityGroup, + type Subnet, + type Vpc, +} from '@aws-sdk/client-ec2' +import {awsClients} from '../aws' + +export type Ec2Tag = { + key: string + value: string +} + +export type Ec2SecurityGroupRef = { + id?: string + name?: string +} + +export type Ec2IpPermission = { + protocol: string + fromPort: number | null + toPort: number | null + ipRanges: string[] + ipv6Ranges: string[] +} + +export type Ec2SecurityGroup = { + groupId: string + groupName: string + description: string + vpcId?: string + inboundRules: Ec2IpPermission[] + outboundRules: Ec2IpPermission[] + tags: Ec2Tag[] +} + +export type Ec2Image = { + imageId: string + name: string + description?: string + state?: string + architecture?: string + platform?: string + virtualizationType?: string + rootDeviceType?: string + createdAt?: string + ownerId?: string + public?: boolean + tags: Ec2Tag[] +} + +export type Ec2Instance = { + instanceId: string + name: string + state?: string + instanceType?: string + availabilityZone?: string + publicIpAddress?: string + privateIpAddress?: string + vpcId?: string + subnetId?: string + imageId?: string + keyName?: string + launchTime?: string + architecture?: string + platform?: string + securityGroups: Ec2SecurityGroupRef[] + tags: Ec2Tag[] +} + +export type Ec2KeyPair = { + keyPairId: string + keyName: string + keyFingerprint?: string + tags: Ec2Tag[] +} + +export type Ec2KeyPairMaterial = { + keyPairId: string + keyName: string + keyMaterial: string +} + +export type Ec2Vpc = { + vpcId: string + cidrBlock: string + state?: string + isDefault: boolean + tags: Ec2Tag[] +} + +export type Ec2Subnet = { + subnetId: string + vpcId: string + cidrBlock: string + availabilityZone: string + availableIpAddressCount?: number + mapPublicIpOnLaunch: boolean + state?: string + tags: Ec2Tag[] +} + +export type Ec2InternetGateway = { + internetGatewayId: string + attachments: Array<{vpcId: string; state: string}> + tags: Ec2Tag[] +} + +export type Ec2NatGateway = { + natGatewayId: string + subnetId: string + vpcId: string + state?: string + publicIp?: string + privateIp?: string + eipAllocationId?: string + tags: Ec2Tag[] +} + +export type Ec2Route = { + destinationCidrBlock?: string + gatewayId?: string + natGatewayId?: string + vpcPeeringConnectionId?: string + state?: string + origin?: string +} + +export type Ec2RouteTableAssociation = { + associationId: string + subnetId?: string + isMain: boolean +} + +export type Ec2RouteTable = { + routeTableId: string + vpcId: string + routes: Ec2Route[] + associations: Ec2RouteTableAssociation[] + tags: Ec2Tag[] +} + +export type Ec2ElasticIp = { + allocationId: string + publicIp: string + domain?: string + associationId?: string + instanceId?: string + networkInterfaceId?: string + tags: Ec2Tag[] +} + +export type CreateNatGatewayInput = { + name?: string + subnetId: string + allocationId?: string +} + +export type CreateRouteInput = { + destinationCidrBlock: string + gatewayId?: string + natGatewayId?: string + vpcPeeringConnectionId?: string +} + +export type Ec2ConsoleOutput = { + instanceId: string + output: string + timestamp?: string +} + +export type RunInstanceInput = { + name: string + imageId: string + instanceType: string + keyName?: string + subnetId?: string + securityGroupIds?: string[] + userData?: string + iamInstanceProfileName?: string + associatePublicIpAddress?: boolean + rootVolumeSize?: number + rootVolumeType?: string +} + +export type CreateAmiInput = { + instanceId: string + name: string + description?: string + noReboot?: boolean +} + +export type IpPermissionInput = { + protocol: string + fromPort: number + toPort: number + cidr: string +} + +export type Ec2AvailabilityZone = { + zoneName: string + zoneId: string + state: string +} + +export type Ec2InstanceType = { + instanceType: string + vcpu: number + memoryMiB: number +} + +export type Ec2VpcAttributes = { + enableDnsHostnames: boolean + enableDnsSupport: boolean +} + +function toTags(sdkTags: Array<{Key?: string; Value?: string}> | undefined): Ec2Tag[] { + return (sdkTags ?? []).map((t) => ({key: t.Key ?? '', value: t.Value ?? ''})) +} + +function nameFromTags(tags: Array<{Key?: string; Value?: string}> | undefined): string { + return tags?.find((t) => t.Key === 'Name')?.Value ?? '' +} + +function toEc2Instance(instance: Instance): Ec2Instance { + const tags = toTags(instance.Tags) + return { + instanceId: instance.InstanceId ?? '', + name: nameFromTags(instance.Tags) || (instance.InstanceId ?? ''), + state: instance.State?.Name, + instanceType: instance.InstanceType, + availabilityZone: instance.Placement?.AvailabilityZone, + publicIpAddress: instance.PublicIpAddress, + privateIpAddress: instance.PrivateIpAddress, + vpcId: instance.VpcId, + subnetId: instance.SubnetId, + imageId: instance.ImageId, + keyName: instance.KeyName, + launchTime: instance.LaunchTime?.toISOString(), + architecture: instance.Architecture, + platform: instance.Platform, + securityGroups: (instance.SecurityGroups ?? []).map((sg) => ({ + id: sg.GroupId, + name: sg.GroupName, + })), + tags, + } +} + +function toEc2Image(image: Image): Ec2Image { + return { + imageId: image.ImageId ?? '', + name: image.Name ?? image.ImageId ?? '', + description: image.Description, + state: image.State, + architecture: image.Architecture, + platform: image.Platform, + virtualizationType: image.VirtualizationType, + rootDeviceType: image.RootDeviceType, + createdAt: image.CreationDate, + ownerId: image.OwnerId, + public: image.Public, + tags: toTags(image.Tags), + } +} + +function toEc2IpPermission(p: IpPermission): Ec2IpPermission { + return { + protocol: p.IpProtocol ?? '-1', + fromPort: p.FromPort ?? null, + toPort: p.ToPort ?? null, + ipRanges: (p.IpRanges ?? []).map((r) => r.CidrIp ?? '').filter(Boolean), + ipv6Ranges: (p.Ipv6Ranges ?? []).map((r) => r.CidrIpv6 ?? '').filter(Boolean), + } +} + +function toEc2SecurityGroup(sg: SecurityGroup): Ec2SecurityGroup { + return { + groupId: sg.GroupId ?? '', + groupName: sg.GroupName ?? '', + description: sg.Description ?? '', + vpcId: sg.VpcId, + inboundRules: (sg.IpPermissions ?? []).map(toEc2IpPermission), + outboundRules: (sg.IpPermissionsEgress ?? []).map(toEc2IpPermission), + tags: toTags(sg.Tags), + } +} + +function toEc2KeyPair(kp: KeyPairInfo): Ec2KeyPair { + return { + keyPairId: kp.KeyPairId ?? '', + keyName: kp.KeyName ?? '', + keyFingerprint: kp.KeyFingerprint, + tags: toTags(kp.Tags), + } +} + +function toEc2Vpc(vpc: Vpc): Ec2Vpc { + return { + vpcId: vpc.VpcId ?? '', + cidrBlock: vpc.CidrBlock ?? '', + state: vpc.State, + isDefault: vpc.IsDefault ?? false, + tags: toTags(vpc.Tags), + } +} + +function toEc2Subnet(subnet: Subnet): Ec2Subnet { + return { + subnetId: subnet.SubnetId ?? '', + vpcId: subnet.VpcId ?? '', + cidrBlock: subnet.CidrBlock ?? '', + availabilityZone: subnet.AvailabilityZone ?? '', + availableIpAddressCount: subnet.AvailableIpAddressCount, + mapPublicIpOnLaunch: subnet.MapPublicIpOnLaunch ?? false, + state: subnet.State, + tags: toTags(subnet.Tags), + } +} + +function toEc2InternetGateway(igw: InternetGateway): Ec2InternetGateway { + return { + internetGatewayId: igw.InternetGatewayId ?? '', + attachments: (igw.Attachments ?? []).map((a) => ({ + vpcId: a.VpcId ?? '', + state: a.State ?? '', + })), + tags: toTags(igw.Tags), + } +} + +function toEc2NatGateway(nat: NatGateway): Ec2NatGateway { + const addr = nat.NatGatewayAddresses?.[0] + return { + natGatewayId: nat.NatGatewayId ?? '', + subnetId: nat.SubnetId ?? '', + vpcId: nat.VpcId ?? '', + state: nat.State, + publicIp: addr?.PublicIp, + privateIp: addr?.PrivateIp, + eipAllocationId: addr?.AllocationId, + tags: toTags(nat.Tags), + } +} + +function toEc2RouteTable(rt: RouteTable): Ec2RouteTable { + return { + routeTableId: rt.RouteTableId ?? '', + vpcId: rt.VpcId ?? '', + routes: (rt.Routes ?? []).map((r) => ({ + destinationCidrBlock: r.DestinationCidrBlock, + gatewayId: r.GatewayId, + natGatewayId: r.NatGatewayId, + vpcPeeringConnectionId: r.VpcPeeringConnectionId, + state: r.State, + origin: r.Origin, + })), + associations: (rt.Associations ?? []).map((a) => ({ + associationId: a.RouteTableAssociationId ?? '', + subnetId: a.SubnetId, + isMain: a.Main ?? false, + })), + tags: toTags(rt.Tags), + } +} + +function toEc2ElasticIp(addr: Address): Ec2ElasticIp { + return { + allocationId: addr.AllocationId ?? '', + publicIp: addr.PublicIp ?? '', + domain: addr.Domain, + associationId: addr.AssociationId, + instanceId: addr.InstanceId, + networkInterfaceId: addr.NetworkInterfaceId, + tags: toTags(addr.Tags), + } +} + +function isUnsupportedOperation(error: unknown) { + return error instanceof Error && error.message.includes('is not supported') +} + +export function createEc2Service(client: EC2Client = awsClients.ec2) { + return { + async listInstances(): Promise { + const instances: Ec2Instance[] = [] + let nextToken: string | undefined + + do { + const res = await client.send(new DescribeInstancesCommand({NextToken: nextToken})) + for (const reservation of res.Reservations ?? []) { + instances.push(...(reservation.Instances ?? []).map(toEc2Instance)) + } + nextToken = res.NextToken + } while (nextToken) + + return instances + }, + + async describeInstance(instanceId: string): Promise { + const res = await client.send(new DescribeInstancesCommand({InstanceIds: [instanceId]})) + const instance = res.Reservations?.[0]?.Instances?.[0] + return toEc2Instance(instance ?? {}) + }, + + async runInstance(input: RunInstanceInput): Promise { + const blockDeviceMappings = input.rootVolumeSize + ? [{ + DeviceName: '/dev/xvda', + Ebs: { + VolumeSize: input.rootVolumeSize, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + VolumeType: (input.rootVolumeType ?? 'gp3') as any, + DeleteOnTermination: true, + }, + }] + : undefined + const networkInterfaces = input.subnetId + ? [{ + DeviceIndex: 0, + SubnetId: input.subnetId, + AssociatePublicIpAddress: input.associatePublicIpAddress ?? false, + Groups: input.securityGroupIds?.length ? input.securityGroupIds : undefined, + }] + : undefined + const res = await client.send(new RunInstancesCommand({ + ImageId: input.imageId, + MinCount: 1, + MaxCount: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InstanceType: input.instanceType as any, + KeyName: input.keyName || undefined, + ...(networkInterfaces + ? {NetworkInterfaces: networkInterfaces} + : { + SubnetId: input.subnetId || undefined, + SecurityGroupIds: input.securityGroupIds?.length ? input.securityGroupIds : undefined, + }), + UserData: input.userData ? Buffer.from(input.userData).toString('base64') : undefined, + IamInstanceProfile: input.iamInstanceProfileName + ? {Name: input.iamInstanceProfileName} + : undefined, + BlockDeviceMappings: blockDeviceMappings, + TagSpecifications: [{ + ResourceType: 'instance', + Tags: [{Key: 'Name', Value: input.name}], + }], + })) + const instance = res.Instances?.[0] + if (!instance) throw new Error('RunInstances returned no instance') + return toEc2Instance(instance) + }, + + async startInstance(instanceId: string): Promise { + await client.send(new StartInstancesCommand({InstanceIds: [instanceId]})) + }, + + async stopInstance(instanceId: string): Promise { + await client.send(new StopInstancesCommand({InstanceIds: [instanceId]})) + }, + + async rebootInstance(instanceId: string): Promise { + await client.send(new RebootInstancesCommand({InstanceIds: [instanceId]})) + }, + + async terminateInstance(instanceId: string): Promise { + await client.send(new TerminateInstancesCommand({InstanceIds: [instanceId]})) + }, + + async updateInstanceTags(instanceId: string, toAdd: Ec2Tag[], toRemove: string[]): Promise { + if (toAdd.length > 0) { + await client.send(new CreateTagsCommand({ + Resources: [instanceId], + Tags: toAdd.map((t) => ({Key: t.key, Value: t.value})), + })) + } + if (toRemove.length > 0) { + await client.send(new DeleteTagsCommand({ + Resources: [instanceId], + Tags: toRemove.map((k) => ({Key: k})), + })) + } + }, + + async createImage(input: CreateAmiInput): Promise<{imageId: string}> { + const res = await client.send(new CreateImageCommand({ + InstanceId: input.instanceId, + Name: input.name, + Description: input.description, + NoReboot: input.noReboot, + })) + return {imageId: res.ImageId ?? ''} + }, + + async deregisterImage(imageId: string): Promise { + await client.send(new DeregisterImageCommand({ImageId: imageId})) + }, + + async listAmis(): Promise { + try { + const res = await client.send(new DescribeImagesCommand({})) + return (res.Images ?? []).map(toEc2Image) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async getConsoleOutput(instanceId: string): Promise { + try { + const res = await client.send(new GetConsoleOutputCommand({InstanceId: instanceId})) + const decoded = res.Output ? Buffer.from(res.Output, 'base64').toString('utf-8') : '' + return {instanceId, output: decoded, timestamp: res.Timestamp?.toISOString()} + } catch (error) { + if (isUnsupportedOperation(error)) return {instanceId, output: ''} + throw error + } + }, + + async listKeyPairs(): Promise { + try { + const res = await client.send(new DescribeKeyPairsCommand({})) + return (res.KeyPairs ?? []).map(toEc2KeyPair) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createKeyPair(name: string): Promise { + const res = await client.send(new CreateKeyPairCommand({KeyName: name})) + return { + keyPairId: res.KeyPairId ?? '', + keyName: res.KeyName ?? name, + keyMaterial: res.KeyMaterial ?? '', + } + }, + + async deleteKeyPair(keyName: string): Promise { + await client.send(new DeleteKeyPairCommand({KeyName: keyName})) + }, + + async listSecurityGroups(vpcId?: string): Promise { + try { + const res = await client.send(new DescribeSecurityGroupsCommand({ + Filters: vpcId ? [{Name: 'vpc-id', Values: [vpcId]}] : undefined, + })) + return (res.SecurityGroups ?? []).map(toEc2SecurityGroup) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createSecurityGroup(name: string, description: string, vpcId: string): Promise<{groupId: string}> { + const res = await client.send(new CreateSecurityGroupCommand({ + GroupName: name, + Description: description, + VpcId: vpcId, + })) + return {groupId: res.GroupId ?? ''} + }, + + async deleteSecurityGroup(groupId: string): Promise { + await client.send(new DeleteSecurityGroupCommand({GroupId: groupId})) + }, + + async authorizeSecurityGroupIngress(groupId: string, permission: IpPermissionInput): Promise { + await client.send(new AuthorizeSecurityGroupIngressCommand({ + GroupId: groupId, + IpPermissions: [{ + IpProtocol: permission.protocol, + FromPort: permission.fromPort, + ToPort: permission.toPort, + IpRanges: [{CidrIp: permission.cidr}], + }], + })) + }, + + async revokeSecurityGroupIngress(groupId: string, permission: IpPermissionInput): Promise { + await client.send(new RevokeSecurityGroupIngressCommand({ + GroupId: groupId, + IpPermissions: [{ + IpProtocol: permission.protocol, + FromPort: permission.fromPort, + ToPort: permission.toPort, + IpRanges: [{CidrIp: permission.cidr}], + }], + })) + }, + + async authorizeSecurityGroupEgress(groupId: string, permission: IpPermissionInput): Promise { + await client.send(new AuthorizeSecurityGroupEgressCommand({ + GroupId: groupId, + IpPermissions: [{ + IpProtocol: permission.protocol, + FromPort: permission.fromPort, + ToPort: permission.toPort, + IpRanges: [{CidrIp: permission.cidr}], + }], + })) + }, + + async revokeSecurityGroupEgress(groupId: string, permission: IpPermissionInput): Promise { + await client.send(new RevokeSecurityGroupEgressCommand({ + GroupId: groupId, + IpPermissions: [{ + IpProtocol: permission.protocol, + FromPort: permission.fromPort, + ToPort: permission.toPort, + IpRanges: [{CidrIp: permission.cidr}], + }], + })) + }, + + async listAvailabilityZones(): Promise { + try { + const res = await client.send(new DescribeAvailabilityZonesCommand({})) + return (res.AvailabilityZones ?? []).map((az) => ({ + zoneName: az.ZoneName ?? '', + zoneId: az.ZoneId ?? '', + state: az.State ?? 'available', + })) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async listInstanceTypes(): Promise { + try { + const res = await client.send(new DescribeInstanceTypesCommand({})) + return (res.InstanceTypes ?? []).map((it) => ({ + instanceType: it.InstanceType ?? '', + vcpu: it.VCpuInfo?.DefaultVCpus ?? 0, + memoryMiB: it.MemoryInfo?.SizeInMiB ?? 0, + })) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async modifySubnetMapPublicIp(subnetId: string, mapPublicIpOnLaunch: boolean): Promise { + await client.send(new ModifySubnetAttributeCommand({ + SubnetId: subnetId, + MapPublicIpOnLaunch: {Value: mapPublicIpOnLaunch}, + })) + }, + + async getVpcAttributes(vpcId: string): Promise { + const [hostnamesRes, supportRes] = await Promise.all([ + client.send(new DescribeVpcAttributeCommand({VpcId: vpcId, Attribute: 'enableDnsHostnames'})), + client.send(new DescribeVpcAttributeCommand({VpcId: vpcId, Attribute: 'enableDnsSupport'})), + ]) + return { + enableDnsHostnames: hostnamesRes.EnableDnsHostnames?.Value ?? false, + enableDnsSupport: supportRes.EnableDnsSupport?.Value ?? false, + } + }, + + async modifyVpcAttribute(vpcId: string, attribute: 'enableDnsHostnames' | 'enableDnsSupport', value: boolean): Promise { + const cmd = attribute === 'enableDnsHostnames' + ? new ModifyVpcAttributeCommand({VpcId: vpcId, EnableDnsHostnames: {Value: value}}) + : new ModifyVpcAttributeCommand({VpcId: vpcId, EnableDnsSupport: {Value: value}}) + await client.send(cmd) + }, + + async associateElasticIp(allocationId: string, instanceId: string): Promise<{associationId: string}> { + const res = await client.send(new AssociateAddressCommand({AllocationId: allocationId, InstanceId: instanceId})) + return {associationId: res.AssociationId ?? ''} + }, + + async disassociateElasticIp(associationId: string): Promise { + await client.send(new DisassociateAddressCommand({AssociationId: associationId})) + }, + + async listVpcs(): Promise { + try { + const res = await client.send(new DescribeVpcsCommand({})) + return (res.Vpcs ?? []).map(toEc2Vpc) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createVpc(cidrBlock: string): Promise { + const res = await client.send(new CreateVpcCommand({CidrBlock: cidrBlock})) + return toEc2Vpc(res.Vpc ?? {}) + }, + + async deleteVpc(vpcId: string): Promise { + const vpcFilter = [{Name: 'vpc-id', Values: [vpcId]}] + + // 1. Delete non-default security groups + try { + const sgsRes = await client.send(new DescribeSecurityGroupsCommand({Filters: vpcFilter})) + for (const sg of sgsRes.SecurityGroups ?? []) { + if (sg.GroupName !== 'default' && sg.GroupId) { + try { await client.send(new DeleteSecurityGroupCommand({GroupId: sg.GroupId})) } catch { /* skip */ } + } + } + } catch (error) { if (!isUnsupportedOperation(error)) throw error } + + // 2. Delete subnets + try { + const subnetsRes = await client.send(new DescribeSubnetsCommand({Filters: vpcFilter})) + for (const subnet of subnetsRes.Subnets ?? []) { + if (subnet.SubnetId) { + try { await client.send(new DeleteSubnetCommand({SubnetId: subnet.SubnetId})) } catch { /* skip */ } + } + } + } catch (error) { if (!isUnsupportedOperation(error)) throw error } + + // 3. Detach and delete internet gateways + try { + const igwRes = await client.send(new DescribeInternetGatewaysCommand({Filters: [{Name: 'attachment.vpc-id', Values: [vpcId]}]})) + for (const igw of igwRes.InternetGateways ?? []) { + if (!igw.InternetGatewayId) continue + for (const att of igw.Attachments ?? []) { + if (att.VpcId) { + try { await client.send(new DetachInternetGatewayCommand({InternetGatewayId: igw.InternetGatewayId, VpcId: att.VpcId})) } catch { /* skip */ } + } + } + try { await client.send(new DeleteInternetGatewayCommand({InternetGatewayId: igw.InternetGatewayId})) } catch { /* skip */ } + } + } catch (error) { if (!isUnsupportedOperation(error)) throw error } + + // 4. Disassociate and delete non-main route tables + try { + const rtbRes = await client.send(new DescribeRouteTablesCommand({Filters: vpcFilter})) + for (const rt of rtbRes.RouteTables ?? []) { + if (!rt.RouteTableId) continue + const isMain = (rt.Associations ?? []).some((a) => a.Main) + if (isMain) continue + for (const assoc of rt.Associations ?? []) { + if (!assoc.Main && assoc.RouteTableAssociationId) { + try { await client.send(new DisassociateRouteTableCommand({AssociationId: assoc.RouteTableAssociationId})) } catch { /* skip */ } + } + } + try { await client.send(new DeleteRouteTableCommand({RouteTableId: rt.RouteTableId})) } catch { /* skip */ } + } + } catch (error) { if (!isUnsupportedOperation(error)) throw error } + + // 5. Delete the VPC + await client.send(new DeleteVpcCommand({VpcId: vpcId})) + }, + + async listSubnets(vpcId?: string): Promise { + try { + const res = await client.send(new DescribeSubnetsCommand({ + Filters: vpcId ? [{Name: 'vpc-id', Values: [vpcId]}] : undefined, + })) + return (res.Subnets ?? []).map(toEc2Subnet) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createSubnet(vpcId: string, cidrBlock: string, availabilityZone?: string): Promise { + const res = await client.send(new CreateSubnetCommand({ + VpcId: vpcId, + CidrBlock: cidrBlock, + AvailabilityZone: availabilityZone || undefined, + })) + return toEc2Subnet(res.Subnet ?? {}) + }, + + async deleteSubnet(subnetId: string): Promise { + await client.send(new DeleteSubnetCommand({SubnetId: subnetId})) + }, + + // ── Internet Gateways ────────────────────────────────────────────────── + + async listInternetGateways(): Promise { + try { + const res = await client.send(new DescribeInternetGatewaysCommand({})) + return (res.InternetGateways ?? []).map(toEc2InternetGateway) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createInternetGateway(name?: string): Promise { + const res = await client.send(new CreateInternetGatewayCommand({})) + const igw = res.InternetGateway ?? {} + if (name && igw.InternetGatewayId) { + await client.send(new CreateTagsCommand({ + Resources: [igw.InternetGatewayId], + Tags: [{Key: 'Name', Value: name}], + })) + igw.Tags = [{Key: 'Name', Value: name}] + } + return toEc2InternetGateway(igw) + }, + + async attachInternetGateway(igwId: string, vpcId: string): Promise { + await client.send(new AttachInternetGatewayCommand({InternetGatewayId: igwId, VpcId: vpcId})) + }, + + async detachInternetGateway(igwId: string, vpcId: string): Promise { + await client.send(new DetachInternetGatewayCommand({InternetGatewayId: igwId, VpcId: vpcId})) + }, + + async deleteInternetGateway(igwId: string): Promise { + // Auto-detach from any attached VPCs before deleting + const res = await client.send(new DescribeInternetGatewaysCommand({InternetGatewayIds: [igwId]})) + const attachments = res.InternetGateways?.[0]?.Attachments ?? [] + for (const attachment of attachments) { + if (attachment.VpcId) { + await client.send(new DetachInternetGatewayCommand({InternetGatewayId: igwId, VpcId: attachment.VpcId})) + } + } + await client.send(new DeleteInternetGatewayCommand({InternetGatewayId: igwId})) + }, + + // ── NAT Gateways ─────────────────────────────────────────────────────── + + async listNatGateways(vpcId?: string): Promise { + try { + const res = await client.send(new DescribeNatGatewaysCommand({ + Filter: vpcId ? [{Name: 'vpc-id', Values: [vpcId]}] : undefined, + })) + return (res.NatGateways ?? []).filter((n) => n.State !== 'deleted').map(toEc2NatGateway) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createNatGateway(input: CreateNatGatewayInput): Promise { + let allocationId = input.allocationId + if (!allocationId) { + const eipRes = await client.send(new AllocateAddressCommand({Domain: 'vpc'})) + allocationId = eipRes.AllocationId! + } + const natRes = await client.send(new CreateNatGatewayCommand({ + SubnetId: input.subnetId, + AllocationId: allocationId, + ConnectivityType: 'public', + })) + const natId = natRes.NatGateway!.NatGatewayId! + if (input.name) { + await client.send(new CreateTagsCommand({Resources: [natId], Tags: [{Key: 'Name', Value: input.name}]})) + } + // Poll until available + for (let attempt = 0; attempt < 15; attempt++) { + const st = await client.send(new DescribeNatGatewaysCommand({NatGatewayIds: [natId]})) + const nat = st.NatGateways?.[0] + if (nat?.State === 'available') return toEc2NatGateway(nat) + if (nat?.State === 'failed') throw new Error(`NAT Gateway ${natId} failed to become available`) + await new Promise((r) => setTimeout(r, 1000)) + } + // Return last known state after polling + const final = await client.send(new DescribeNatGatewaysCommand({NatGatewayIds: [natId]})) + return toEc2NatGateway(final.NatGateways?.[0] ?? {NatGatewayId: natId, SubnetId: input.subnetId}) + }, + + async deleteNatGateway(natId: string): Promise { + await client.send(new DeleteNatGatewayCommand({NatGatewayId: natId})) + // Poll until deleted + for (let attempt = 0; attempt < 15; attempt++) { + const st = await client.send(new DescribeNatGatewaysCommand({NatGatewayIds: [natId]})) + const state = st.NatGateways?.[0]?.State + if (!state || state === 'deleted') return + if (state === 'failed') throw new Error(`NAT Gateway ${natId} deletion failed`) + await new Promise((r) => setTimeout(r, 1000)) + } + }, + + // ── Route Tables ─────────────────────────────────────────────────────── + + async listRouteTables(vpcId?: string): Promise { + try { + const res = await client.send(new DescribeRouteTablesCommand({ + Filters: vpcId ? [{Name: 'vpc-id', Values: [vpcId]}] : undefined, + })) + return (res.RouteTables ?? []).map(toEc2RouteTable) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async createRouteTable(vpcId: string, name?: string): Promise { + const res = await client.send(new CreateRouteTableCommand({VpcId: vpcId})) + const rt = res.RouteTable ?? {} + if (name && rt.RouteTableId) { + await client.send(new CreateTagsCommand({ + Resources: [rt.RouteTableId], + Tags: [{Key: 'Name', Value: name}], + })) + rt.Tags = [{Key: 'Name', Value: name}] + } + return toEc2RouteTable(rt) + }, + + async deleteRouteTable(rtbId: string): Promise { + // Describe first to check associations and main flag + const res = await client.send(new DescribeRouteTablesCommand({RouteTableIds: [rtbId]})) + const rt = res.RouteTables?.[0] + if (!rt) throw new Error(`Route table ${rtbId} not found`) + const isMain = (rt.Associations ?? []).some((a) => a.Main) + if (isMain) throw new Error(`Cannot delete the main route table ${rtbId}. Detach subnets and update the VPC's main route table first.`) + // Disassociate all non-main associations + for (const assoc of rt.Associations ?? []) { + if (!assoc.Main && assoc.RouteTableAssociationId) { + await client.send(new DisassociateRouteTableCommand({AssociationId: assoc.RouteTableAssociationId})) + } + } + await client.send(new DeleteRouteTableCommand({RouteTableId: rtbId})) + }, + + async createRoute(rtbId: string, input: CreateRouteInput): Promise { + await client.send(new CreateRouteCommand({ + RouteTableId: rtbId, + DestinationCidrBlock: input.destinationCidrBlock, + GatewayId: input.gatewayId || undefined, + NatGatewayId: input.natGatewayId || undefined, + VpcPeeringConnectionId: input.vpcPeeringConnectionId || undefined, + })) + }, + + async deleteRoute(rtbId: string, destinationCidr: string): Promise { + await client.send(new DeleteRouteCommand({ + RouteTableId: rtbId, + DestinationCidrBlock: destinationCidr, + })) + }, + + async associateRouteTable(rtbId: string, subnetId: string): Promise { + const res = await client.send(new AssociateRouteTableCommand({ + RouteTableId: rtbId, + SubnetId: subnetId, + })) + return res.AssociationId ?? '' + }, + + async disassociateRouteTable(associationId: string): Promise { + await client.send(new DisassociateRouteTableCommand({AssociationId: associationId})) + }, + + // ── Elastic IPs ──────────────────────────────────────────────────────── + + async listElasticIps(): Promise { + try { + const res = await client.send(new DescribeAddressesCommand({})) + return (res.Addresses ?? []).map(toEc2ElasticIp) + } catch (error) { + if (isUnsupportedOperation(error)) return [] + throw error + } + }, + + async allocateElasticIp(name?: string): Promise { + const res = await client.send(new AllocateAddressCommand({Domain: 'vpc'})) + const allocationId = res.AllocationId ?? '' + if (name && allocationId) { + await client.send(new CreateTagsCommand({Resources: [allocationId], Tags: [{Key: 'Name', Value: name}]})) + } + return { + allocationId, + publicIp: res.PublicIp ?? '', + domain: res.Domain, + tags: name ? [{key: 'Name', value: name}] : [], + } + }, + + async releaseElasticIp(allocationId: string): Promise { + await client.send(new ReleaseAddressCommand({AllocationId: allocationId})) + }, + + async createVpcWizard(input: VpcWizardInput): Promise { + const {name, cidrBlock, subnetGroups, natGateway, availabilityZone} = input + const groupCidrs = calculateSubnetCidrs(cidrBlock, subnetGroups) + + // 1. VPC + const vpcRes = await client.send(new CreateVpcCommand({CidrBlock: cidrBlock})) + const vpcId = vpcRes.Vpc!.VpcId! + await client.send(new CreateTagsCommand({Resources: [vpcId], Tags: [{Key: 'Name', Value: name}]})) + + // 2. Internet Gateway + const igwRes = await client.send(new CreateInternetGatewayCommand({})) + const igwId = igwRes.InternetGateway!.InternetGatewayId! + await client.send(new CreateTagsCommand({Resources: [igwId], Tags: [{Key: 'Name', Value: `${name}-igw`}]})) + await client.send(new AttachInternetGatewayCommand({InternetGatewayId: igwId, VpcId: vpcId})) + + // 3. Create subnets per group + const createdGroups: Array<{name: string; isPublic: boolean; subnetIds: string[]}> = [] + for (let gi = 0; gi < subnetGroups.length; gi++) { + const group = subnetGroups[gi] + const subnetIds: string[] = [] + for (let i = 0; i < group.count; i++) { + const res = await client.send(new CreateSubnetCommand({ + VpcId: vpcId, CidrBlock: groupCidrs[gi][i], + AvailabilityZone: group.az || availabilityZone || undefined, + })) + const sid = res.Subnet!.SubnetId! + await client.send(new CreateTagsCommand({Resources: [sid], Tags: [{Key: 'Name', Value: `${name}-${group.name}-${i + 1}`}]})) + subnetIds.push(sid) + } + createdGroups.push({name: group.name, isPublic: group.isPublic, subnetIds}) + } + + const publicSubnetIds = createdGroups.filter((g) => g.isPublic).flatMap((g) => g.subnetIds) + const privateSubnetIds = createdGroups.filter((g) => !g.isPublic).flatMap((g) => g.subnetIds) + + // 4. Public route table → 0.0.0.0/0 → IGW (if any public subnets) + const pubRtRes = await client.send(new CreateRouteTableCommand({VpcId: vpcId})) + const publicRtbId = pubRtRes.RouteTable!.RouteTableId! + await client.send(new CreateTagsCommand({Resources: [publicRtbId], Tags: [{Key: 'Name', Value: `${name}-pub-rtb`}]})) + if (publicSubnetIds.length > 0) { + await client.send(new CreateRouteCommand({RouteTableId: publicRtbId, DestinationCidrBlock: '0.0.0.0/0', GatewayId: igwId})) + for (const sid of publicSubnetIds) { + await client.send(new AssociateRouteTableCommand({RouteTableId: publicRtbId, SubnetId: sid})) + } + } + + // 5. NAT Gateway + private route table (if any private subnets and NAT requested) + // NAT Gateway is skipped silently when not supported (e.g. LocalStack free tier). + let natGatewayId: string | undefined + let eipAllocationId: string | undefined + let privateRtbId: string | undefined + + if (natGateway && privateSubnetIds.length > 0 && publicSubnetIds.length > 0) { + try { + const eipRes = await client.send(new AllocateAddressCommand({Domain: 'vpc'})) + eipAllocationId = eipRes.AllocationId! + + const natRes = await client.send(new CreateNatGatewayCommand({ + SubnetId: publicSubnetIds[0], AllocationId: eipAllocationId, ConnectivityType: 'public', + })) + natGatewayId = natRes.NatGateway!.NatGatewayId! + await client.send(new CreateTagsCommand({Resources: [natGatewayId], Tags: [{Key: 'Name', Value: `${name}-nat`}]})) + + // Poll until available + for (let attempt = 0; attempt < 15; attempt++) { + const st = await client.send(new DescribeNatGatewaysCommand({NatGatewayIds: [natGatewayId]})) + const state = st.NatGateways?.[0]?.State + if (state === 'available') break + if (state === 'failed') throw new Error(`NAT Gateway ${natGatewayId} failed to become available`) + await new Promise((r) => setTimeout(r, 1000)) + } + + const privRtRes = await client.send(new CreateRouteTableCommand({VpcId: vpcId})) + privateRtbId = privRtRes.RouteTable!.RouteTableId! + await client.send(new CreateTagsCommand({Resources: [privateRtbId], Tags: [{Key: 'Name', Value: `${name}-pri-rtb`}]})) + await client.send(new CreateRouteCommand({RouteTableId: privateRtbId, DestinationCidrBlock: '0.0.0.0/0', NatGatewayId: natGatewayId})) + for (const sid of privateSubnetIds) { + await client.send(new AssociateRouteTableCommand({RouteTableId: privateRtbId, SubnetId: sid})) + } + } catch (err) { + if (!isUnsupportedOperation(err)) throw err + // Not supported (e.g. LocalStack) — skip NAT GW silently + natGatewayId = undefined + eipAllocationId = undefined + } + } + + return { + vpcId, igwId, + publicSubnetIds, privateSubnetIds, + subnetGroups: createdGroups, + natGatewayId, eipAllocationId, + publicRouteTableId: publicRtbId, privateRouteTableId: privateRtbId, + } + }, + } +} + +// ─── VPC Wizard types and CIDR helpers ─────────────────────────────────────── + +export type SubnetGroup = { + /** User-defined group name, e.g. "public", "private-app", "dmz". */ + name: string + /** Number of subnets in this group (1–4). */ + count: number + /** true → IGW route (public); false → NAT GW route (private). */ + isPublic: boolean + /** Optional AZ override for subnets in this group, e.g. "us-east-1a". */ + az?: string + /** Subnet prefix length override, e.g. 24 for /24. Defaults to min(vpcPrefix+4, 28). */ + prefix?: number +} + +export type VpcWizardInput = { + name: string + cidrBlock: string + subnetGroups: SubnetGroup[] + natGateway: boolean + availabilityZone?: string +} + +export type VpcWizardResult = { + vpcId: string + igwId: string + /** Flat list of all public-type subnet IDs (backward compat). */ + publicSubnetIds: string[] + /** Flat list of all private-type subnet IDs (backward compat). */ + privateSubnetIds: string[] + /** Named group breakdown with the subnets that belong to each. */ + subnetGroups: Array<{name: string; isPublic: boolean; subnetIds: string[]}> + natGatewayId?: string + eipAllocationId?: string + publicRouteTableId: string + privateRouteTableId?: string +} + +/** + * Allocates non-overlapping subnet CIDRs across named groups sequentially. + * Each group gets `group.count` contiguous subnets. If `group.prefix` is set it is used + * as the subnet prefix; otherwise defaults to min(vpcPrefix+4, 28). Groups with different + * prefixes are laid out sequentially without gaps. + */ +export function calculateSubnetCidrs( + vpcCidr: string, + groups: SubnetGroup[], +): string[][] { + const m = vpcCidr.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/) + if (!m) throw new Error(`Invalid VPC CIDR: ${vpcCidr}`) + + const vpcBase = Number(m[1]) * 16777216 + Number(m[2]) * 65536 + Number(m[3]) * 256 + Number(m[4]) + const vpcPrefix = Number(m[5]) + const defaultPrefix = Math.min(vpcPrefix + 4, 28) + + function baseToCidr(base: number, prefix: number): string { + return `${Math.floor(base / 16777216)}.${Math.floor((base % 16777216) / 65536)}.${Math.floor((base % 65536) / 256)}.${base % 256}/${prefix}` + } + + let currentBase = vpcBase + return groups.map((g) => { + const prefix = g.prefix ?? defaultPrefix + const size = Math.pow(2, 32 - prefix) + const cidrs = Array.from({length: g.count}, (_, i) => baseToCidr(currentBase + i * size, prefix)) + currentBase += g.count * size + return cidrs + }) +} + +export const ec2Service = createEc2Service() diff --git a/packages/api/src/services/eks.ts b/packages/api/src/services/eks.ts new file mode 100644 index 0000000..d223a00 --- /dev/null +++ b/packages/api/src/services/eks.ts @@ -0,0 +1,386 @@ +import { + CreateFargateProfileCommand, + CreateNodegroupCommand, + DeleteFargateProfileCommand, + DeleteNodegroupCommand, + DescribeClusterCommand, + DescribeFargateProfileCommand, + DescribeNodegroupCommand, + EKSClient, + ListClustersCommand, + ListFargateProfilesCommand, + ListNodegroupsCommand, + type Cluster, + type FargateProfile, + type FargateProfileSelector, + type Nodegroup, +} from "@aws-sdk/client-eks"; +import { awsClients } from "../aws"; + +export type EksVpcConfig = { + subnetIds: string[]; + securityGroupIds: string[]; + clusterSecurityGroupId?: string; + vpcId?: string; + endpointPublicAccess?: boolean; + endpointPrivateAccess?: boolean; + publicAccessCidrs: string[]; +}; + +export type EksCluster = { + name: string; + arn?: string; + createdAt?: string; + version?: string; + endpoint?: string; + roleArn?: string; + status?: string; + platformVersion?: string; + certificateAuthority?: { + data?: string; + }; + resourcesVpcConfig?: EksVpcConfig; + tags: Record; + nodegroupCount?: number; + fargateProfileCount?: number; +}; + +export type EksNodegroup = { + name: string; + arn?: string; + clusterName: string; + version?: string; + releaseVersion?: string; + createdAt?: string; + modifiedAt?: string; + status?: string; + capacityType?: string; + instanceTypes: string[]; + subnets: string[]; + nodeRole?: string; + scalingConfig?: { + minSize?: number; + maxSize?: number; + desiredSize?: number; + }; + labels: Record; + tags: Record; +}; + +export type EksFargateProfile = { + name: string; + arn?: string; + clusterName: string; + createdAt?: string; + status?: string; + podExecutionRoleArn?: string; + subnets: string[]; + selectors: { + namespace?: string; + labels: Record; + }[]; + tags: Record; +}; + +export type CreateEksNodegroupInput = { + name: string; + nodeRole: string; + subnets: string[]; + instanceTypes?: string[]; + scalingConfig?: { + minSize?: number; + maxSize?: number; + desiredSize?: number; + }; + labels?: Record; + tags?: Record; +}; + +export type CreateEksFargateProfileInput = { + name: string; + podExecutionRoleArn: string; + subnets?: string[]; + selectors: { + namespace?: string; + labels?: Record; + }[]; + tags?: Record; +}; + +function toEksVpcConfig( + config: Cluster["resourcesVpcConfig"], +): EksVpcConfig | undefined { + if (!config) return undefined; + + return { + subnetIds: config.subnetIds ?? [], + securityGroupIds: config.securityGroupIds ?? [], + clusterSecurityGroupId: config.clusterSecurityGroupId, + vpcId: config.vpcId, + endpointPublicAccess: config.endpointPublicAccess, + endpointPrivateAccess: config.endpointPrivateAccess, + publicAccessCidrs: config.publicAccessCidrs ?? [], + }; +} + +function toEksCluster(cluster: Cluster, nodegroupCount?: number): EksCluster { + return { + name: cluster.name ?? "", + arn: cluster.arn, + createdAt: cluster.createdAt?.toISOString(), + version: cluster.version, + endpoint: cluster.endpoint, + roleArn: cluster.roleArn, + status: cluster.status, + platformVersion: cluster.platformVersion, + certificateAuthority: cluster.certificateAuthority + ? { data: cluster.certificateAuthority.data } + : undefined, + resourcesVpcConfig: toEksVpcConfig(cluster.resourcesVpcConfig), + tags: cluster.tags ?? {}, + nodegroupCount, + }; +} + +function toEksNodegroup(nodegroup: Nodegroup): EksNodegroup { + return { + name: nodegroup.nodegroupName ?? "", + arn: nodegroup.nodegroupArn, + clusterName: nodegroup.clusterName ?? "", + version: nodegroup.version, + releaseVersion: nodegroup.releaseVersion, + createdAt: nodegroup.createdAt?.toISOString(), + modifiedAt: nodegroup.modifiedAt?.toISOString(), + status: nodegroup.status, + capacityType: nodegroup.capacityType, + instanceTypes: nodegroup.instanceTypes ?? [], + subnets: nodegroup.subnets ?? [], + nodeRole: nodegroup.nodeRole, + scalingConfig: nodegroup.scalingConfig + ? { + minSize: nodegroup.scalingConfig.minSize, + maxSize: nodegroup.scalingConfig.maxSize, + desiredSize: nodegroup.scalingConfig.desiredSize, + } + : undefined, + labels: nodegroup.labels ?? {}, + tags: nodegroup.tags ?? {}, + }; +} + +function toEksFargateProfile(profile: FargateProfile): EksFargateProfile { + return { + name: profile.fargateProfileName ?? "", + arn: profile.fargateProfileArn, + clusterName: profile.clusterName ?? "", + createdAt: profile.createdAt?.toISOString(), + status: profile.status, + podExecutionRoleArn: profile.podExecutionRoleArn, + subnets: profile.subnets ?? [], + selectors: (profile.selectors ?? []).map(toEksFargateProfileSelector), + tags: profile.tags ?? {}, + }; +} + +function toEksFargateProfileSelector(selector: FargateProfileSelector) { + return { + namespace: selector.namespace, + labels: selector.labels ?? {}, + }; +} + +function getHttpStatus(error: unknown) { + if (typeof error !== "object" || error === null) return undefined; + const metadata = (error as { $metadata?: { httpStatusCode?: number } }) + .$metadata; + return metadata?.httpStatusCode; +} + +export function createEksService(client: EKSClient = awsClients.eks) { + async function listClusterNames(): Promise { + const clusters: string[] = []; + let nextToken: string | undefined; + + do { + const res = await client.send(new ListClustersCommand({ nextToken })); + clusters.push(...(res.clusters ?? [])); + nextToken = res.nextToken; + } while (nextToken); + + return clusters; + } + + async function listNodegroupNames(clusterName: string): Promise { + const nodegroups: string[] = []; + let nextToken: string | undefined; + + try { + do { + const res = await client.send( + new ListNodegroupsCommand({ clusterName, nextToken }), + ); + nodegroups.push(...(res.nodegroups ?? [])); + nextToken = res.nextToken; + } while (nextToken); + } catch (error) { + if (getHttpStatus(error) === 404) return []; + throw error; + } + + return nodegroups; + } + + async function listFargateProfileNames(clusterName: string): Promise { + const profiles: string[] = []; + let nextToken: string | undefined; + + try { + do { + const res = await client.send( + new ListFargateProfilesCommand({ clusterName, nextToken }), + ); + profiles.push(...(res.fargateProfileNames ?? [])); + nextToken = res.nextToken; + } while (nextToken); + } catch (error) { + if (getHttpStatus(error) === 404) return []; + throw error; + } + + return profiles; + } + + return { + async listClusters(): Promise { + const names = await listClusterNames(); + + return Promise.all( + names.map(async (name) => { + const [cluster, nodegroups, fargateProfiles] = await Promise.all([ + this.describeCluster(name), + listNodegroupNames(name), + listFargateProfileNames(name), + ]); + + return { + ...cluster, + nodegroupCount: nodegroups.length, + fargateProfileCount: fargateProfiles.length, + }; + }), + ); + }, + + async describeCluster(name: string): Promise { + const res = await client.send(new DescribeClusterCommand({ name })); + return toEksCluster(res.cluster ?? {}); + }, + + async listNodegroups(clusterName: string): Promise { + const names = await listNodegroupNames(clusterName); + + return Promise.all( + names.map((nodegroupName) => + this.describeNodegroup(clusterName, nodegroupName), + ), + ); + }, + + async describeNodegroup( + clusterName: string, + nodegroupName: string, + ): Promise { + const res = await client.send( + new DescribeNodegroupCommand({ clusterName, nodegroupName }), + ); + return toEksNodegroup(res.nodegroup ?? {}); + }, + + async createNodegroup( + clusterName: string, + input: CreateEksNodegroupInput, + ): Promise { + const res = await client.send( + new CreateNodegroupCommand({ + clusterName, + nodegroupName: input.name, + nodeRole: input.nodeRole, + subnets: input.subnets, + instanceTypes: input.instanceTypes, + scalingConfig: input.scalingConfig, + labels: input.labels, + tags: input.tags, + }), + ); + return toEksNodegroup(res.nodegroup ?? {}); + }, + + async deleteNodegroup( + clusterName: string, + nodegroupName: string, + ): Promise { + const res = await client.send( + new DeleteNodegroupCommand({ clusterName, nodegroupName }), + ); + return toEksNodegroup(res.nodegroup ?? {}); + }, + + async listFargateProfiles(clusterName: string): Promise { + const names = await listFargateProfileNames(clusterName); + + return Promise.all( + names.map((profileName) => + this.describeFargateProfile(clusterName, profileName), + ), + ); + }, + + async describeFargateProfile( + clusterName: string, + profileName: string, + ): Promise { + const res = await client.send( + new DescribeFargateProfileCommand({ + clusterName, + fargateProfileName: profileName, + }), + ); + return toEksFargateProfile(res.fargateProfile ?? {}); + }, + + async createFargateProfile( + clusterName: string, + input: CreateEksFargateProfileInput, + ): Promise { + const res = await client.send( + new CreateFargateProfileCommand({ + clusterName, + fargateProfileName: input.name, + podExecutionRoleArn: input.podExecutionRoleArn, + subnets: input.subnets, + selectors: input.selectors.map((selector) => ({ + namespace: selector.namespace, + labels: selector.labels, + })), + tags: input.tags, + }), + ); + return toEksFargateProfile(res.fargateProfile ?? {}); + }, + + async deleteFargateProfile( + clusterName: string, + profileName: string, + ): Promise { + const res = await client.send( + new DeleteFargateProfileCommand({ + clusterName, + fargateProfileName: profileName, + }), + ); + return toEksFargateProfile(res.fargateProfile ?? {}); + }, + }; +} + +export const eksService = createEksService(); diff --git a/packages/api/src/services/rds.ts b/packages/api/src/services/rds.ts new file mode 100644 index 0000000..c6eb6b7 --- /dev/null +++ b/packages/api/src/services/rds.ts @@ -0,0 +1,249 @@ +import { + CreateDBInstanceCommand, + CreateDBSnapshotCommand, + DeleteDBInstanceCommand, + DescribeDBInstancesCommand, + DescribeDBSnapshotsCommand, + DescribeOrderableDBInstanceOptionsCommand, + ModifyDBInstanceCommand, + type CreateDBInstanceCommandInput, + type DBInstance, + type DBSnapshot, + type ModifyDBInstanceCommandInput, + type RDSClient, +} from "@aws-sdk/client-rds"; +import { awsClients } from "../aws"; + +export type RdsEndpoint = { + address?: string; + port?: number; + hostedZoneId?: string; +}; + +export type RdsVpcSecurityGroup = { + id?: string; + status?: string; +}; + +export type RdsSubnet = { + identifier?: string; + availabilityZone?: string; + status?: string; +}; + +export type RdsDbSubnetGroup = { + name?: string; + vpcId?: string; + status?: string; + subnets: RdsSubnet[]; +}; + +export type RdsInstance = { + identifier: string; + arn?: string; + resourceId?: string; + createdAt?: string; + status?: string; + engine?: string; + engineVersion?: string; + instanceClass?: string; + dbName?: string; + masterUsername?: string; + allocatedStorage?: number; + storageType?: string; + availabilityZone?: string; + multiAz?: boolean; + publiclyAccessible?: boolean; + iamDatabaseAuthenticationEnabled?: boolean; + preferredBackupWindow?: string; + preferredMaintenanceWindow?: string; + endpoint?: RdsEndpoint; + vpcSecurityGroups: RdsVpcSecurityGroup[]; + subnetGroup?: RdsDbSubnetGroup; + optionGroupName?: string; + autoMinorVersionUpgrade?: boolean; +}; + +export type RdsSnapshot = { + identifier: string; + instanceIdentifier?: string; + arn?: string; + status?: string; + engine?: string; + engineVersion?: string; + allocatedStorage?: number; + snapshotType?: string; + createdAt?: string; + port?: number; + availabilityZone?: string; + vpcId?: string; +}; + +function toRdsInstance(instance: DBInstance): RdsInstance { + return { + identifier: instance.DBInstanceIdentifier ?? "", + arn: instance.DBInstanceArn, + resourceId: instance.DbiResourceId, + createdAt: instance.InstanceCreateTime?.toISOString(), + status: instance.DBInstanceStatus, + engine: instance.Engine, + engineVersion: instance.EngineVersion, + instanceClass: instance.DBInstanceClass, + dbName: instance.DBName, + masterUsername: instance.MasterUsername, + allocatedStorage: instance.AllocatedStorage, + storageType: instance.StorageType, + availabilityZone: instance.AvailabilityZone, + multiAz: instance.MultiAZ, + publiclyAccessible: instance.PubliclyAccessible, + iamDatabaseAuthenticationEnabled: instance.IAMDatabaseAuthenticationEnabled, + preferredBackupWindow: instance.PreferredBackupWindow, + preferredMaintenanceWindow: instance.PreferredMaintenanceWindow, + endpoint: instance.Endpoint + ? { + address: instance.Endpoint.Address, + port: instance.Endpoint.Port, + hostedZoneId: instance.Endpoint.HostedZoneId, + } + : undefined, + vpcSecurityGroups: (instance.VpcSecurityGroups ?? []).map((group) => ({ + id: group.VpcSecurityGroupId, + status: group.Status, + })), + subnetGroup: instance.DBSubnetGroup + ? { + name: instance.DBSubnetGroup.DBSubnetGroupName, + vpcId: instance.DBSubnetGroup.VpcId, + status: instance.DBSubnetGroup.SubnetGroupStatus, + subnets: (instance.DBSubnetGroup.Subnets ?? []).map((subnet) => ({ + identifier: subnet.SubnetIdentifier, + availabilityZone: subnet.SubnetAvailabilityZone?.Name, + status: subnet.SubnetStatus, + })), + } + : undefined, + optionGroupName: instance.OptionGroupMemberships?.[0]?.OptionGroupName, + autoMinorVersionUpgrade: instance.AutoMinorVersionUpgrade, + }; +} + +function toRdsSnapshot(snapshot: DBSnapshot): RdsSnapshot { + return { + identifier: snapshot.DBSnapshotIdentifier ?? "", + instanceIdentifier: snapshot.DBInstanceIdentifier, + arn: snapshot.DBSnapshotArn, + status: snapshot.Status, + engine: snapshot.Engine, + engineVersion: snapshot.EngineVersion, + allocatedStorage: snapshot.AllocatedStorage, + snapshotType: snapshot.SnapshotType, + createdAt: snapshot.SnapshotCreateTime?.toISOString(), + port: snapshot.Port, + availabilityZone: snapshot.AvailabilityZone, + vpcId: snapshot.VpcId, + }; +} + +export function createRdsService(client: RDSClient = awsClients.rds) { + return { + async listInstances(): Promise { + const instances: RdsInstance[] = []; + let marker: string | undefined; + + do { + const res = await client.send( + new DescribeDBInstancesCommand({ Marker: marker }), + ); + instances.push(...(res.DBInstances ?? []).map(toRdsInstance)); + marker = res.Marker; + } while (marker); + + return instances; + }, + + async describeInstance(identifier: string): Promise { + const res = await client.send( + new DescribeDBInstancesCommand({ DBInstanceIdentifier: identifier }), + ); + return toRdsInstance(res.DBInstances?.[0] ?? {}); + }, + + async createInstance(input: CreateDBInstanceCommandInput): Promise { + const res = await client.send(new CreateDBInstanceCommand(input)); + return toRdsInstance(res.DBInstance ?? {}); + }, + + async modifyInstance(input: ModifyDBInstanceCommandInput): Promise { + const res = await client.send(new ModifyDBInstanceCommand(input)); + return toRdsInstance(res.DBInstance ?? {}); + }, + + async deleteInstance( + identifier: string, + skipFinalSnapshot = true, + finalDBSnapshotIdentifier?: string, + ): Promise { + await client.send( + new DeleteDBInstanceCommand({ + DBInstanceIdentifier: identifier, + SkipFinalSnapshot: skipFinalSnapshot, + ...(finalDBSnapshotIdentifier + ? { FinalDBSnapshotIdentifier: finalDBSnapshotIdentifier } + : {}), + }), + ); + }, + + async listSnapshots(instanceIdentifier?: string): Promise { + const snapshots: RdsSnapshot[] = []; + let marker: string | undefined; + + do { + const res = await client.send( + new DescribeDBSnapshotsCommand({ + DBInstanceIdentifier: instanceIdentifier, + Marker: marker, + }), + ); + snapshots.push(...(res.DBSnapshots ?? []).map(toRdsSnapshot)); + marker = res.Marker; + } while (marker); + + return snapshots; + }, + + async createSnapshot(instanceIdentifier: string, snapshotIdentifier: string): Promise { + const res = await client.send( + new CreateDBSnapshotCommand({ + DBInstanceIdentifier: instanceIdentifier, + DBSnapshotIdentifier: snapshotIdentifier, + }), + ); + return toRdsSnapshot(res.DBSnapshot ?? {}); + }, + + async listOrderableInstanceClasses(engine: string): Promise { + const classes = new Set(); + let marker: string | undefined; + + do { + const res = await client.send( + new DescribeOrderableDBInstanceOptionsCommand({ + Engine: engine, + Marker: marker, + }), + ); + for (const option of res.OrderableDBInstanceOptions ?? []) { + if (option.DBInstanceClass) { + classes.add(option.DBInstanceClass); + } + } + marker = res.Marker; + } while (marker); + + return Array.from(classes); + }, + }; +} + +export const rdsService = createRdsService(); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..543910d --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/packages/frontend/Dockerfile.dev b/packages/frontend/Dockerfile.dev new file mode 100644 index 0000000..114b85e --- /dev/null +++ b/packages/frontend/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +# pnpm resolves platform-specific optional deps (e.g. Vite's rolldown native +# binding) reliably; npm intermittently drops them (npm/cli#4828). +RUN corepack enable && corepack prepare pnpm@9.2.0 --activate +COPY package.json ./ +RUN pnpm install +CMD ["pnpm", "run", "dev"] diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js new file mode 100644 index 0000000..88c8fae --- /dev/null +++ b/packages/frontend/eslint.config.js @@ -0,0 +1,35 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; + +export default tseslint.config( + { ignores: ["dist", "node_modules"] }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/packages/frontend/index.html b/packages/frontend/index.html new file mode 100644 index 0000000..a900f4b --- /dev/null +++ b/packages/frontend/index.html @@ -0,0 +1,29 @@ + + + + + + + Floci UI + + + + + + + +
+ + + diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000..819f552 --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,53 @@ +{ + "name": "@floci/frontend", + "description": "Floci UI web console — the React + Vite frontend for exploring local multi-cloud runtimes.", + "private": true, + "version": "0.5.0", + "type": "module", + "scripts": { + "test:e2e": "playwright test", + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src tests --report-unused-disable-directives --max-warnings 0", + "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "date-fns": "^4.1.0", + "lucide-react": "^1.22.0", + "react": "^19.2.6", + "react-dom": "^19.2.0", + "react-router-dom": "^7.18.1", + "tailwind-merge": "^3.6.0", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "^26.1.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.2", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.7.0", + "jsdom": "^30.0.1", + "postcss": "^8.5.16", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1", + "vite": "^8.1.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts new file mode 100644 index 0000000..df2a98a --- /dev/null +++ b/packages/frontend/playwright.config.ts @@ -0,0 +1,19 @@ +import {defineConfig} from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + use: { + baseURL: 'http://127.0.0.1:4510', + channel: process.env.PLAYWRIGHT_CHANNEL, + viewport: {width: 1440, height: 1000}, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: 'pnpm exec vite --host 127.0.0.1 --port 4510 --strictPort', + url: 'http://127.0.0.1:4510', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx new file mode 100644 index 0000000..7596388 --- /dev/null +++ b/packages/frontend/src/App.tsx @@ -0,0 +1,26 @@ +import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom' +import {Layout} from '@/components/Layout' +import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage' +import {CloudExplorerPage} from '@/pages/CloudExplorerPage' +import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage' +import {DatabaseDataPage} from '@/pages/DatabaseDataPage' + +export default function App() { + return ( + + + }> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + + + ) +} diff --git a/packages/frontend/src/api/HttpClient.ts b/packages/frontend/src/api/HttpClient.ts new file mode 100644 index 0000000..529e96d --- /dev/null +++ b/packages/frontend/src/api/HttpClient.ts @@ -0,0 +1,442 @@ +import { emitApiRequest, type CloudProvider } from "@/telemetry"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface HttpClientConfig { + baseUrl?: string; + defaultHeaders?: Record; + cache?: RequestCache; + timeout?: number; +} + +export interface RequestTelemetry { + provider?: CloudProvider; + service?: string; +} + +export type EndpointRegistry = Map; + +export interface RequestOptions + extends Omit { + params?: Record; + body?: TBody; + rawBody?: BodyInit; + cache?: RequestCache; + timeout?: number; + baseUrlOverride?: string; + telemetry?: RequestTelemetry; +} + +export interface HttpResponse { + data: TData; + status: number; + headers: Headers; + ok: boolean; +} + +export class HttpError extends Error { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly body: unknown, + public readonly url: string, + ) { + super(httpErrorMessage(status, statusText, body, url)); + this.name = "HttpError"; + } +} + +export class NetworkError extends Error { + constructor( + message: string, + public readonly url: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = "NetworkError"; + } +} + +function httpErrorMessage( + status: number, + statusText: string, + body: unknown, + url: string, +): string { + const apiMessage = apiErrorMessage(body); + if (!apiMessage) return `HTTP ${status} - ${statusText} [${url}]`; + + const code = apiErrorCode(body); + const suffix = code ? ` (${code})` : ""; + return `${apiMessage}${suffix} [${url}]`; +} + +function apiErrorMessage(body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const value = (body as { message?: unknown; error?: unknown }).message ?? (body as { error?: unknown }).error; + if (typeof value !== "string" || !value.trim()) return null; + + const detail = (body as { detail?: unknown }).detail; + if (typeof detail === "string" && detail.trim() && detail.trim() !== value.trim()) { + return `${value.trim()}: ${detail.trim()}`; + } + return value.trim(); +} + +function apiErrorCode(body: unknown): string | null { + if (!body || typeof body !== "object") return null; + const value = (body as { code?: unknown }).code; + return typeof value === "string" && value.trim() ? value : null; +} + +export class TimeoutError extends Error { + constructor( + public readonly url: string, + public readonly timeoutMs: number, + ) { + super(`Request timed out after ${timeoutMs}ms [${url}]`); + this.name = "TimeoutError"; + } +} + +export class ResponseParseError extends Error { + constructor( + public readonly url: string, + public readonly cause?: unknown, + ) { + super(`Failed to parse response [${url}]`); + this.name = "ResponseParseError"; + } +} + +export type RequestInterceptor = ( + url: string, + init: RequestInit, +) => + | Promise<{ url: string; init: RequestInit }> + | { url: string; init: RequestInit }; + +export type ResponseInterceptor = ( + response: Response, +) => Promise | Response; + +export type EndpointDefaults = Omit, "body" | "params">; + +export type EndpointDefinition = { + path: string; + method: HttpMethod; + defaults?: Partial; + baseUrlOverride?: string; + telemetry?: RequestTelemetry; +}; + +export class HttpClient { + private readonly config: HttpClientConfig; + private readonly requestInterceptors: RequestInterceptor[] = []; + private readonly responseInterceptors: ResponseInterceptor[] = []; + private readonly endpointRegistry: EndpointRegistry; + + constructor( + config: HttpClientConfig = {}, + registry: EndpointRegistry = new Map(), + ) { + this.config = config; + this.endpointRegistry = registry; + } + + addRequestInterceptor(interceptor: RequestInterceptor): this { + this.requestInterceptors.push(interceptor); + return this; + } + + addResponseInterceptor(interceptor: ResponseInterceptor): this { + this.responseInterceptors.push(interceptor); + return this; + } + + async request( + method: HttpMethod, + endpoint: string, + options: RequestOptions = {}, + ): Promise> { + const started = performance.now(); + const { + params, + body, + rawBody, + timeout, + cache, + baseUrlOverride, + telemetry, + ...restOptions + } = options; + let finalUrl = endpoint; + let statusCode = 0; + + try { + const base = baseUrlOverride ?? this.config.baseUrl ?? ""; + const rawUrl = endpoint.startsWith("http") + ? endpoint + : `${base}${endpoint}`; + finalUrl = this.buildUrl(rawUrl, params); + + const headers: Record = { + Accept: "application/json", + ...this.config.defaultHeaders, + ...(body !== undefined && rawBody === undefined + ? { "Content-Type": "application/json" } + : {}), + ...(restOptions.headers as Record | undefined), + }; + + let init: RequestInit = { + ...restOptions, + method, + headers, + cache: cache ?? this.config.cache, + ...(rawBody !== undefined + ? { body: rawBody } + : body !== undefined + ? { body: JSON.stringify(body) } + : {}), + }; + + for (const interceptor of this.requestInterceptors) { + ({ url: finalUrl, init } = await interceptor(finalUrl, init)); + } + + let response = await this.fetchWithTimeout( + finalUrl, + init, + timeout ?? this.config.timeout, + ); + statusCode = response.status; + + for (const interceptor of this.responseInterceptors) { + response = await interceptor(response); + } + + if (!response.ok) { + throw await this.createHttpError(response, finalUrl); + } + + return { + data: await this.parseResponse(response, finalUrl), + status: response.status, + headers: response.headers, + ok: response.ok, + }; + } catch (error) { + throw this.normalizeError(error, finalUrl, timeout ?? this.config.timeout); + } finally { + if (telemetry?.service && statusCode > 0) { + emitApiRequest({ + provider: telemetry.provider ?? "aws", + service: telemetry.service, + method, + path: endpoint, + statusCode, + latencyMs: Math.round(performance.now() - started), + timestamp: Date.now(), + }); + } + } + } + + async call( + key: string, + options: RequestOptions = {}, + pathParams?: Record, + ): Promise> { + const endpoint = this.endpointRegistry.get(key); + if (!endpoint) throw new Error(`Endpoint "${key}" is not registered.`); + + let path = endpoint.path; + if (pathParams) { + for (const [param, value] of Object.entries(pathParams)) { + path = path.replace(`:${param}`, encodeURIComponent(value)); + } + } + + return this.request(endpoint.method, path, { + ...endpoint.defaults, + ...options, + baseUrlOverride: endpoint.baseUrlOverride, + telemetry: { + ...endpoint.telemetry, + ...options.telemetry, + }, + }); + } + + get(endpoint: string, options?: RequestOptions) { + return this.request("GET", endpoint, options); + } + + post( + endpoint: string, + body?: TBody, + options?: RequestOptions, + ) { + return this.request("POST", endpoint, { + ...options, + body, + }); + } + + put( + endpoint: string, + body?: TBody, + options?: RequestOptions, + ) { + return this.request("PUT", endpoint, { + ...options, + body, + }); + } + + patch( + endpoint: string, + body?: TBody, + options?: RequestOptions, + ) { + return this.request("PATCH", endpoint, { + ...options, + body, + }); + } + + delete(endpoint: string, options?: RequestOptions) { + return this.request("DELETE", endpoint, options); + } + + private async fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs?: number, + ): Promise { + if (!timeoutMs) return fetch(url, init); + + const timeoutController = new AbortController(); + let timedOut = false; + const timeoutId = window.setTimeout(() => { + timedOut = true; + timeoutController.abort(); + }, timeoutMs); + const signal = this.mergeSignals(init.signal, timeoutController.signal); + + try { + return await fetch(url, { + ...init, + signal, + }); + } catch (error) { + if (timedOut) throw new TimeoutError(url, timeoutMs); + throw error; + } finally { + window.clearTimeout(timeoutId); + } + } + + private mergeSignals( + requestSignal: AbortSignal | null | undefined, + timeoutSignal: AbortSignal, + ): AbortSignal { + if (!requestSignal) return timeoutSignal; + if (requestSignal.aborted) return requestSignal; + if (timeoutSignal.aborted) return timeoutSignal; + + const controller = new AbortController(); + const abort = () => controller.abort(); + + requestSignal.addEventListener("abort", abort, { once: true }); + timeoutSignal.addEventListener("abort", abort, { once: true }); + + return controller.signal; + } + + private async createHttpError( + response: Response, + url: string, + ): Promise { + const text = await response.text(); + let body: unknown; + + if (text.trim()) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } else { + body = undefined; + } + + return new HttpError(response.status, response.statusText, body, url); + } + + private async parseResponse( + response: Response, + url: string, + ): Promise { + if (response.status === 204) return undefined as TResponse; + + const contentLength = response.headers.get("content-length"); + if (contentLength === "0") return undefined as TResponse; + + const text = await response.text(); + if (!text.trim()) return undefined as TResponse; + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return text as TResponse; + } + + try { + return JSON.parse(text) as TResponse; + } catch (error) { + throw new ResponseParseError(url, error); + } + } + + private normalizeError( + error: unknown, + url: string, + timeoutMs?: number, + ): Error { + if ( + error instanceof HttpError || + error instanceof NetworkError || + error instanceof TimeoutError || + error instanceof ResponseParseError + ) { + return error; + } + + if (error instanceof DOMException && error.name === "AbortError") { + if (timeoutMs) return new TimeoutError(url, timeoutMs); + return new NetworkError("Request aborted", url, error); + } + + return new NetworkError( + error instanceof Error ? error.message : "Network error", + url, + error, + ); + } + + private buildUrl( + base: string, + params?: Record, + ): string { + if (!params) return base; + + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) query.append(key, String(value)); + } + + const qs = query.toString(); + return qs ? `${base}?${qs}` : base; + } +} diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts new file mode 100644 index 0000000..e8050ba --- /dev/null +++ b/packages/frontend/src/api/api.ts @@ -0,0 +1,965 @@ +import { EndpointRegistry, HttpClient } from "./HttpClient"; +import { ACCOUNT_HEADER, getAccountId } from "@/lib/accountStore"; + +export class AuthenticationRequiredError extends Error { + constructor() { + super("Authentication is required before calling the API."); + this.name = "AuthenticationRequiredError"; + } +} + +export const apiEndpointKeys = { + clouds: { + list: "clouds.list", + services: "clouds.services.list", + status: "clouds.status.get", + serviceStatus: "clouds.services.status.get", + schema: "clouds.services.schema.get", + resources: { + list: "clouds.services.resources.list", + get: "clouds.services.resources.get", + create: "clouds.services.resources.create", + update: "clouds.services.resources.update", + delete: "clouds.services.resources.delete", + invoke: "clouds.services.resources.invoke", + encrypt: "clouds.services.resources.encrypt", + decrypt: "clouds.services.resources.decrypt", + createApiGatewayRoute: "clouds.services.resources.apigateway.route.create", + }, + storage: { + objects: { + list: "clouds.services.storage.objects.list", + upload: "clouds.services.storage.objects.upload", + download: "clouds.services.storage.objects.download", + delete: "clouds.services.storage.objects.delete", + copy: "clouds.services.storage.objects.copy", + }, + }, + nosql: { + cosmos: { + containers: { + list: "clouds.services.nosql.cosmos.containers.list", + create: "clouds.services.nosql.cosmos.containers.create", + delete: "clouds.services.nosql.cosmos.containers.delete", + }, + items: { + list: "clouds.services.nosql.cosmos.items.list", + upsert: "clouds.services.nosql.cosmos.items.upsert", + delete: "clouds.services.nosql.cosmos.items.delete", + query: "clouds.services.nosql.cosmos.items.query", + }, + }, + items: { + list: "clouds.services.nosql.items.list", + put: "clouds.services.nosql.items.put", + }, + }, + database: { + sql: { + databases: "clouds.services.database.sql.databases.list", + tables: "clouds.services.database.sql.tables.list", + query: "clouds.services.database.sql.query", + }, + snapshots: { + list: "clouds.services.database.snapshots.list", + create: "clouds.services.database.snapshots.create", + }, + orderableClasses: { + list: "clouds.services.database.orderable-classes.list", + }, + }, + logs: { + query: "clouds.services.logs.query", + }, + k8s: { + nodegroups: { + list: "clouds.services.k8s.nodegroups.list", + create: "clouds.services.k8s.nodegroups.create", + delete: "clouds.services.k8s.nodegroups.delete", + }, + fargateProfiles: { + list: "clouds.services.k8s.fargate-profiles.list", + create: "clouds.services.k8s.fargate-profiles.create", + delete: "clouds.services.k8s.fargate-profiles.delete", + }, + }, + email: { + inbox: { + clear: "clouds.services.email.inbox.clear", + }, + }, + childCollections: { + list: "clouds.services.childCollections.list", + items: { + list: "clouds.services.childCollections.items.list", + }, + }, + configuration: { + environments: { + list: "clouds.services.configuration.environments.list", + create: "clouds.services.configuration.environments.create", + delete: "clouds.services.configuration.environments.delete", + deployments: { + start: "clouds.services.configuration.environments.deployments.start", + get: "clouds.services.configuration.environments.deployments.get", + }, + }, + configurationProfiles: { + list: "clouds.services.configuration.profiles.list", + create: "clouds.services.configuration.profiles.create", + delete: "clouds.services.configuration.profiles.delete", + hostedVersions: { + list: "clouds.services.configuration.profiles.hosted-versions.list", + get: "clouds.services.configuration.profiles.hosted-versions.get", + create: "clouds.services.configuration.profiles.hosted-versions.create", + delete: "clouds.services.configuration.profiles.hosted-versions.delete", + }, + }, + deploymentStrategies: { + list: "clouds.services.configuration.deployment-strategies.list", + create: "clouds.services.configuration.deployment-strategies.create", + delete: "clouds.services.configuration.deployment-strategies.delete", + }, + }, + }, + aws: { + eks: { + clusters: { + list: "aws.eks.clusters.list", + describe: "aws.eks.clusters.describe", + }, + nodegroups: { + list: "aws.eks.nodegroups.list", + describe: "aws.eks.nodegroups.describe", + create: "aws.eks.nodegroups.create", + delete: "aws.eks.nodegroups.delete", + }, + fargateProfiles: { + list: "aws.eks.fargate-profiles.list", + describe: "aws.eks.fargate-profiles.describe", + create: "aws.eks.fargate-profiles.create", + delete: "aws.eks.fargate-profiles.delete", + }, + }, + secretsmanager: { + secrets: { + list: "aws.secretsmanager.secrets.list", + create: "aws.secretsmanager.secrets.create", + describe: "aws.secretsmanager.secrets.describe", + delete: "aws.secretsmanager.secrets.delete", + value: { + get: "aws.secretsmanager.secrets.value.get", + put: "aws.secretsmanager.secrets.value.put", + }, + }, + }, + ec2: { + instances: { + list: "aws.ec2.instances.list", + describe: "aws.ec2.instances.describe", + create: "aws.ec2.instances.create", + start: "aws.ec2.instances.start", + stop: "aws.ec2.instances.stop", + reboot: "aws.ec2.instances.reboot", + terminate: "aws.ec2.instances.terminate", + tags: "aws.ec2.instances.tags", + createImage: "aws.ec2.instances.create-image", + console: "aws.ec2.instances.console", + }, + amis: { + list: "aws.ec2.amis.list", + deregister: "aws.ec2.amis.deregister", + }, + keyPairs: { + list: "aws.ec2.key-pairs.list", + create: "aws.ec2.key-pairs.create", + delete: "aws.ec2.key-pairs.delete", + }, + securityGroups: { + list: "aws.ec2.security-groups.list", + create: "aws.ec2.security-groups.create", + delete: "aws.ec2.security-groups.delete", + authorize: "aws.ec2.security-groups.authorize", + revoke: "aws.ec2.security-groups.revoke", + authorizeEgress: "aws.ec2.security-groups.authorize-egress", + revokeEgress: "aws.ec2.security-groups.revoke-egress", + }, + vpcs: { + list: "aws.ec2.vpcs.list", + create: "aws.ec2.vpcs.create", + delete: "aws.ec2.vpcs.delete", + getAttributes: "aws.ec2.vpcs.get-attributes", + modifyAttribute: "aws.ec2.vpcs.modify-attribute", + }, + subnets: { + list: "aws.ec2.subnets.list", + create: "aws.ec2.subnets.create", + delete: "aws.ec2.subnets.delete", + modifyAttribute: "aws.ec2.subnets.modify-attribute", + }, + internetGateways: { + list: "aws.ec2.igw.list", + create: "aws.ec2.igw.create", + attach: "aws.ec2.igw.attach", + detach: "aws.ec2.igw.detach", + delete: "aws.ec2.igw.delete", + }, + natGateways: { + list: "aws.ec2.nat.list", + create: "aws.ec2.nat.create", + delete: "aws.ec2.nat.delete", + }, + routeTables: { + list: "aws.ec2.rtb.list", + create: "aws.ec2.rtb.create", + delete: "aws.ec2.rtb.delete", + createRoute: "aws.ec2.rtb.route.create", + deleteRoute: "aws.ec2.rtb.route.delete", + associate: "aws.ec2.rtb.associate", + disassociate: "aws.ec2.rtb.disassociate", + }, + elasticIps: { + list: "aws.ec2.eip.list", + create: "aws.ec2.eip.create", + release: "aws.ec2.eip.release", + associate: "aws.ec2.eip.associate", + disassociate: "aws.ec2.eip.disassociate", + }, + availabilityZones: "aws.ec2.availability-zones", + instanceTypes: "aws.ec2.instance-types", + vpcWizard: "aws.ec2.vpc-wizard", + }, + }, +} as const; + +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; + +export const endpointRegistry: EndpointRegistry = new Map([ + // Cloud Proxy + [ + apiEndpointKeys.clouds.list, + { + path: "/clouds", + method: "GET", + telemetry: { provider: "system", service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.services, + { + path: "/clouds/:cloud/services", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.status, + { + path: "/clouds/:cloud/status", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.serviceStatus, + { + path: "/clouds/:cloud/services/:service/status", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.schema, + { + path: "/clouds/:cloud/services/:service/schema", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.list, + { + path: "/clouds/:cloud/services/:service/resources", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.invoke, + { + path: "/clouds/:cloud/services/:service/resources/:id/invoke", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, +], + [ + apiEndpointKeys.clouds.resources.createApiGatewayRoute, + { + path: "/clouds/:cloud/services/apigateway/resources/:id/routes", + method: "POST", + telemetry: { service: "apigateway" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.get, + { + path: "/clouds/:cloud/services/:service/resources/:id", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.encrypt, + { + path: "/clouds/:cloud/services/kms/resources/:id/encrypt", + method: "POST", + telemetry: { service: "kms" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.decrypt, + { + path: "/clouds/:cloud/services/kms/resources/:id/decrypt", + method: "POST", + telemetry: { service: "kms" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.create, + { + path: "/clouds/:cloud/services/:service/resources", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.update, + { + path: "/clouds/:cloud/services/:service/resources/:id", + method: "PATCH", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.delete, + { + path: "/clouds/:cloud/services/:service/resources/:id", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + +[ + apiEndpointKeys.clouds.resources.invoke, + { + path: "/clouds/:cloud/services/:service/resources/:id/invoke", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, +], + + [ + apiEndpointKeys.clouds.storage.objects.list, + { + path: "/clouds/:cloud/services/storage/resources/:id/objects", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.storage.objects.upload, + { + path: "/clouds/:cloud/services/storage/resources/:id/object", + method: "PUT", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.storage.objects.download, + { + path: "/clouds/:cloud/services/storage/resources/:id/object", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.storage.objects.delete, + { + path: "/clouds/:cloud/services/storage/resources/:id/object", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.storage.objects.copy, + { + path: "/clouds/:cloud/services/storage/resources/:id/object/copy", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.containers.list, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.containers.create, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.containers.delete, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.items.list, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.items.upsert, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.items.delete, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.cosmos.items.query, + { + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/query", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.databases, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/databases", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.tables, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/tables", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.query, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/query", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.logs.query, + { + path: "/clouds/:cloud/services/logs/resources/:id/query", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.snapshots.list, + { + path: "/clouds/:cloud/services/database/snapshots", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.snapshots.create, + { + path: "/clouds/:cloud/services/database/snapshots", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.orderableClasses.list, + { + path: "/clouds/:cloud/services/database/orderable-classes", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.items.list, + { + path: "/clouds/:cloud/services/nosql/resources/:id/items", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.nosql.items.put, + { + path: "/clouds/:cloud/services/nosql/resources/:id/items", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.k8s.nodegroups.list, + { + path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.k8s.nodegroups.create, + { + path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.k8s.nodegroups.delete, + { + path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups/:nodegroupId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.k8s.fargateProfiles.list, + { + path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.k8s.fargateProfiles.create, + { + path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.k8s.fargateProfiles.delete, + { + path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles/:profileId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.email.inbox.clear, + { + path: "/clouds/:cloud/services/email/inbox", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.environments.list, + { + path: "/clouds/:cloud/services/configuration/resources/:id/environments", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.environments.create, + { + path: "/clouds/:cloud/services/configuration/resources/:id/environments", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.environments.delete, + { + path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.environments.deployments.start, + { + path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.environments.deployments.get, + { + path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments/:deploymentNumber", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.list, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.create, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.delete, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.list, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.get, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.create, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.delete, + { + path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.deploymentStrategies.list, + { + path: "/clouds/:cloud/services/configuration/deployment-strategies", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.deploymentStrategies.create, + { + path: "/clouds/:cloud/services/configuration/deployment-strategies", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.configuration.deploymentStrategies.delete, + { + path: "/clouds/:cloud/services/configuration/deployment-strategies/:strategyId", + method: "DELETE", + telemetry: { service: "cloud-proxy" }, + }, + ], + + [ + apiEndpointKeys.clouds.childCollections.list, + { + path: "/clouds/:cloud/services/:service/resources/:id/collections", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.childCollections.items.list, + { + path: "/clouds/:cloud/services/:service/resources/:id/collections/:cid/items", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], + // AWS EKS + [ + apiEndpointKeys.aws.eks.clusters.list, + { + path: "/eks/clusters", + method: "GET", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.clusters.describe, + { + path: "/eks/clusters/:name", + method: "GET", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.nodegroups.list, + { + path: "/eks/clusters/:name/nodegroups", + method: "GET", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.nodegroups.describe, + { + path: "/eks/clusters/:name/nodegroups/:nodegroup", + method: "GET", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.nodegroups.create, + { + path: "/eks/clusters/:name/nodegroups", + method: "POST", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.nodegroups.delete, + { + path: "/eks/clusters/:name/nodegroups/:nodegroup", + method: "DELETE", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.fargateProfiles.list, + { + path: "/eks/clusters/:name/fargate-profiles", + method: "GET", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.fargateProfiles.describe, + { + path: "/eks/clusters/:name/fargate-profiles/:profile", + method: "GET", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.fargateProfiles.create, + { + path: "/eks/clusters/:name/fargate-profiles", + method: "POST", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + [ + apiEndpointKeys.aws.eks.fargateProfiles.delete, + { + path: "/eks/clusters/:name/fargate-profiles/:profile", + method: "DELETE", + telemetry: { provider: "aws", service: "eks" }, + }, + ], + + // AWS Secrets Manager + [ + apiEndpointKeys.aws.secretsmanager.secrets.list, + { + path: "/secretsmanager/secrets", + method: "GET", + telemetry: { provider: "aws", service: "secretsmanager" }, + }, + ], + [ + apiEndpointKeys.aws.secretsmanager.secrets.create, + { + path: "/secretsmanager/secrets", + method: "POST", + telemetry: { provider: "aws", service: "secretsmanager" }, + }, + ], + [ + apiEndpointKeys.aws.secretsmanager.secrets.describe, + { + path: "/secretsmanager/secret", + method: "GET", + telemetry: { provider: "aws", service: "secretsmanager" }, + }, + ], + [ + apiEndpointKeys.aws.secretsmanager.secrets.delete, + { + path: "/secretsmanager/secret", + method: "DELETE", + telemetry: { provider: "aws", service: "secretsmanager" }, + }, + ], + [ + apiEndpointKeys.aws.secretsmanager.secrets.value.get, + { + path: "/secretsmanager/secret/value", + method: "GET", + telemetry: { provider: "aws", service: "secretsmanager" }, + }, + ], + [ + apiEndpointKeys.aws.secretsmanager.secrets.value.put, + { + path: "/secretsmanager/secret/value", + method: "PUT", + telemetry: { provider: "aws", service: "secretsmanager" }, + }, + ], + + // AWS EC2 + [apiEndpointKeys.aws.ec2.instances.list, { path: "/ec2/instances", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.describe, { path: "/ec2/instances/:instanceId", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.create, { path: "/ec2/instances", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.start, { path: "/ec2/instances/:instanceId/start", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.stop, { path: "/ec2/instances/:instanceId/stop", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.tags, { path: "/ec2/instances/:instanceId/tags", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.keyPairs.create, { path: "/ec2/key-pairs", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.keyPairs.delete, { path: "/ec2/key-pairs/:name", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.list, { path: "/ec2/security-groups", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.create, { path: "/ec2/security-groups", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.delete, { path: "/ec2/security-groups/:groupId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.authorize, { path: "/ec2/security-groups/:groupId/ingress", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.revoke, { path: "/ec2/security-groups/:groupId/ingress", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.vpcs.list, { path: "/ec2/vpcs", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.vpcs.create, { path: "/ec2/vpcs", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.vpcs.delete, { path: "/ec2/vpcs/:vpcId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.vpcs.getAttributes, { path: "/ec2/vpcs/:vpcId/attributes", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.vpcs.modifyAttribute, { path: "/ec2/vpcs/:vpcId/attributes", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.subnets.list, { path: "/ec2/subnets", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.subnets.create, { path: "/ec2/subnets", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.subnets.delete, { path: "/ec2/subnets/:subnetId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.subnets.modifyAttribute, { path: "/ec2/subnets/:subnetId/attributes", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.internetGateways.list, { path: "/ec2/internet-gateways", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.internetGateways.create, { path: "/ec2/internet-gateways", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.internetGateways.attach, { path: "/ec2/internet-gateways/:igwId/attach", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.internetGateways.detach, { path: "/ec2/internet-gateways/:igwId/detach", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.internetGateways.delete, { path: "/ec2/internet-gateways/:igwId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.natGateways.list, { path: "/ec2/nat-gateways", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.natGateways.create, { path: "/ec2/nat-gateways", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.natGateways.delete, { path: "/ec2/nat-gateways/:natId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.list, { path: "/ec2/route-tables", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.create, { path: "/ec2/route-tables", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.delete, { path: "/ec2/route-tables/:rtbId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.createRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.deleteRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.associate, { path: "/ec2/route-tables/:rtbId/associations", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.routeTables.disassociate,{ path: "/ec2/route-table-associations/:associationId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.elasticIps.list, { path: "/ec2/elastic-ips", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.elasticIps.create, { path: "/ec2/elastic-ips", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.elasticIps.release, { path: "/ec2/elastic-ips/:allocationId/release", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.elasticIps.associate, { path: "/ec2/elastic-ips/:allocationId/associate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.elasticIps.disassociate, { path: "/ec2/elastic-ips/:allocationId/disassociate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.availabilityZones, { path: "/ec2/availability-zones", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.instanceTypes, { path: "/ec2/instance-types", method: "GET", telemetry: { provider: "aws", service: "ec2" } }], + [apiEndpointKeys.aws.ec2.vpcWizard, { path: "/ec2/vpc-wizard", method: "POST", telemetry: { provider: "aws", service: "ec2" } }], + +]); + +// ─── Client Factory ─────────────────────────────────────────────────────────── + +export function createApiClient( + getToken?: () => string | null | Promise, +) { + const client = new HttpClient( + { + baseUrl: API_BASE_URL, + defaultHeaders: { Accept: "application/json" }, + timeout: 10_000, + }, + endpointRegistry, + ); + + // Account scope — every request carries the active AWS account so Floci can + // isolate resources per account (the API maps this header to its SDK creds). + client.addRequestInterceptor((url, init) => { + const headers = (init.headers ?? {}) as Record; + headers[ACCOUNT_HEADER] = getAccountId(); + init.headers = headers; + return { url, init }; + }); + + // Auth interceptor — attaches Bearer token if available + if (getToken) { + client.addRequestInterceptor(async (url, init) => { + const token = await getToken(); + if (token) { + (init.headers as Record)["Authorization"] = + `Bearer ${token}`; + } + return { url, init }; + }); + } + + // Logging interceptor (dev only) + if (import.meta.env.DEV) { + client.addResponseInterceptor((res) => { + console.debug(`[HTTP] ${res.status} ${res.url}`); + return res; + }); + } + + // client.addResponseInterceptor((res) => { + // if (res.status === 401) { + // handleUnauthorizedApiResponse(); + // } + // return res; + // }); + + return client; +} + +export const apiClient = createApiClient(); diff --git a/packages/frontend/src/api/aws/ec2.api.test.ts b/packages/frontend/src/api/aws/ec2.api.test.ts new file mode 100644 index 0000000..8605bd0 --- /dev/null +++ b/packages/frontend/src/api/aws/ec2.api.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEc2SecurityGroup } from "./ec2.api"; + +describe("createEc2SecurityGroup", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ groupId: "sg-created" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); + }); + + it.each(["vpc-selected", undefined])( + "sends one JSON object through HttpClient with VPC %s", + async (vpcId) => { + const name = "web-servers"; + const description = 'Allow HTTP for "public" clients'; + + await expect(createEc2SecurityGroup(name, description, vpcId)).resolves.toEqual({ + groupId: "sg-created", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/ec2/security-groups"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + name, + description, + ...(vpcId === undefined ? {} : { vpcId }), + }); + expect(new Headers(init?.headers).get("content-type")).toBe("application/json"); + }, + ); +}); diff --git a/packages/frontend/src/api/aws/ec2.api.ts b/packages/frontend/src/api/aws/ec2.api.ts new file mode 100644 index 0000000..60fadd2 --- /dev/null +++ b/packages/frontend/src/api/aws/ec2.api.ts @@ -0,0 +1,730 @@ +import { apiClient, apiEndpointKeys } from "@/api/api"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type Ec2Tag = { + key: string; + value: string; +}; + +export type Ec2SecurityGroupRef = { + id?: string; + name?: string; +}; + +export type Ec2IpPermission = { + protocol: string; + fromPort: number | null; + toPort: number | null; + ipRanges: string[]; + ipv6Ranges: string[]; +}; + +export type Ec2SecurityGroup = { + groupId: string; + groupName: string; + description: string; + vpcId?: string; + inboundRules: Ec2IpPermission[]; + outboundRules: Ec2IpPermission[]; + tags: Ec2Tag[]; +}; + +export type Ec2Image = { + imageId: string; + name: string; + description?: string; + state?: string; + architecture?: string; + platform?: string; + virtualizationType?: string; + rootDeviceType?: string; + createdAt?: string; + ownerId?: string; + public?: boolean; + tags: Ec2Tag[]; +}; + +export type Ec2Instance = { + instanceId: string; + name: string; + state?: string; + instanceType?: string; + availabilityZone?: string; + publicIpAddress?: string; + privateIpAddress?: string; + vpcId?: string; + subnetId?: string; + imageId?: string; + keyName?: string; + launchTime?: string; + architecture?: string; + platform?: string; + securityGroups: Ec2SecurityGroupRef[]; + tags: Ec2Tag[]; +}; + +export type Ec2KeyPair = { + keyPairId: string; + keyName: string; + keyFingerprint?: string; + tags: Ec2Tag[]; +}; + +export type Ec2KeyPairMaterial = { + keyPairId: string; + keyName: string; + keyMaterial: string; +}; + +export type Ec2Vpc = { + vpcId: string; + cidrBlock: string; + state?: string; + isDefault: boolean; + tags: Ec2Tag[]; +}; + +export type Ec2Subnet = { + subnetId: string; + vpcId: string; + cidrBlock: string; + availabilityZone: string; + availableIpAddressCount?: number; + mapPublicIpOnLaunch: boolean; + state?: string; + tags: Ec2Tag[]; +}; + +export type Ec2AvailabilityZone = { + zoneName: string; + zoneId: string; + state: string; +}; + +export type Ec2InstanceType = { + instanceType: string; + vcpu: number; + memoryMiB: number; +}; + +export type Ec2VpcAttributes = { + enableDnsHostnames: boolean; + enableDnsSupport: boolean; +}; + +export type Ec2ConsoleOutput = { + instanceId: string; + output: string; + timestamp?: string; +}; + +export type RunEc2InstanceInput = { + name: string; + imageId: string; + instanceType: string; + keyName?: string; + subnetId?: string; + securityGroupIds?: string[]; + userData?: string; + iamInstanceProfileName?: string; + associatePublicIpAddress?: boolean; + rootVolumeSize?: number; + rootVolumeType?: string; +}; + +export type CreateAmiInput = { + name: string; + description?: string; + noReboot?: boolean; +}; + +export type IpPermissionInput = { + protocol: string; + fromPort: number; + toPort: number; + cidr: string; +}; + +export type SubnetGroup = { + name: string; + count: number; + isPublic: boolean; + az?: string; + prefix?: number; +}; + +export type VpcWizardInput = { + name: string; + cidrBlock: string; + subnetGroups: SubnetGroup[]; + natGateway: boolean; + availabilityZone?: string; +}; + +export type VpcWizardResult = { + vpcId: string; + igwId: string; + publicSubnetIds: string[]; + privateSubnetIds: string[]; + subnetGroups: Array<{ name: string; isPublic: boolean; subnetIds: string[] }>; + natGatewayId?: string; + eipAllocationId?: string; + publicRouteTableId: string; + privateRouteTableId?: string; +}; + +export type Ec2InternetGateway = { + internetGatewayId: string; + attachments: Array<{ vpcId: string; state: string }>; + tags: Ec2Tag[]; +}; + +export type Ec2NatGateway = { + natGatewayId: string; + subnetId: string; + vpcId: string; + state?: string; + publicIp?: string; + privateIp?: string; + eipAllocationId?: string; + tags: Ec2Tag[]; +}; + +export type Ec2Route = { + destinationCidrBlock?: string; + gatewayId?: string; + natGatewayId?: string; + vpcPeeringConnectionId?: string; + state?: string; + origin?: string; +}; + +export type Ec2RouteTableAssociation = { + associationId: string; + subnetId?: string; + isMain: boolean; +}; + +export type Ec2RouteTable = { + routeTableId: string; + vpcId: string; + routes: Ec2Route[]; + associations: Ec2RouteTableAssociation[]; + tags: Ec2Tag[]; +}; + +export type Ec2ElasticIp = { + allocationId: string; + publicIp: string; + domain?: string; + associationId?: string; + instanceId?: string; + networkInterfaceId?: string; + tags: Ec2Tag[]; +}; + +export type CreateNatGatewayInput = { + name?: string; + subnetId: string; + allocationId?: string; +}; + +export type CreateRouteInput = { + destinationCidrBlock: string; + gatewayId?: string; + natGatewayId?: string; + vpcPeeringConnectionId?: string; +}; + +export type ResourceSummary = { + id: string; + name: string; + status?: string; + metadata?: Record; +}; + +// ─── Functions ──────────────────────────────────────────────────────────────── + +export async function listEc2Instances(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.instances.list, { signal }); + return res.data; +} + +export async function describeEc2Instance(instanceId: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.instances.describe, + { signal }, + { instanceId }, + ); + return res.data; +} + +export async function runEc2Instance(input: RunEc2InstanceInput): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.instances.create, + { body: JSON.stringify(input), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function startEc2Instance(instanceId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.instances.start, + { body: "{}", headers: { "content-type": "application/json" } }, + { instanceId }, + ); +} + +export async function stopEc2Instance(instanceId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.instances.stop, + { body: "{}", headers: { "content-type": "application/json" } }, + { instanceId }, + ); +} + +export async function rebootEc2Instance(instanceId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.instances.reboot, + { body: "{}", headers: { "content-type": "application/json" } }, + { instanceId }, + ); +} + +export async function terminateEc2Instance(instanceId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.instances.terminate, + {}, + { instanceId }, + ); +} + +export async function updateEc2InstanceTags( + instanceId: string, + toAdd: Ec2Tag[], + toRemove: string[], +): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.instances.tags, + { body: JSON.stringify({ toAdd, toRemove }), headers: { "content-type": "application/json" } }, + { instanceId }, + ); +} + +export async function createEc2Ami(instanceId: string, input: CreateAmiInput): Promise<{ imageId: string }> { + const res = await apiClient.call<{ imageId: string }>( + apiEndpointKeys.aws.ec2.instances.createImage, + { body: JSON.stringify(input), headers: { "content-type": "application/json" } }, + { instanceId }, + ); + return res.data; +} + +export async function getEc2ConsoleOutput(instanceId: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.instances.console, + { signal }, + { instanceId }, + ); + return res.data; +} + +export async function listEc2Amis(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.amis.list, { signal }); + return res.data; +} + +export async function deregisterEc2Ami(imageId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.amis.deregister, {}, { imageId }); +} + +export async function listEc2KeyPairs(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.keyPairs.list, { signal }); + return res.data; +} + +export async function createEc2KeyPair(name: string): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.keyPairs.create, + { body: JSON.stringify({ name }), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function deleteEc2KeyPair(name: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.keyPairs.delete, {}, { name }); +} + +export async function listEc2SecurityGroups(vpcId?: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.securityGroups.list, + { params: { vpcId }, signal }, + ); + return res.data; +} + +export async function createEc2SecurityGroup( + name: string, + description: string, + vpcId?: string, +): Promise<{ groupId: string }> { + const res = await apiClient.call< + { groupId: string }, + { name: string; description: string; vpcId?: string } + >( + apiEndpointKeys.aws.ec2.securityGroups.create, + { body: { name, description, vpcId } }, + ); + return res.data; +} + +export async function deleteEc2SecurityGroup(groupId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.securityGroups.delete, {}, { groupId }); +} + +export async function authorizeEc2SecurityGroupIngress( + groupId: string, + permission: IpPermissionInput, +): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.securityGroups.authorize, + { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { groupId }, + ); +} + +export async function revokeEc2SecurityGroupIngress( + groupId: string, + permission: IpPermissionInput, +): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.securityGroups.revoke, + { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { groupId }, + ); +} + +export async function authorizeEc2SecurityGroupEgress( + groupId: string, + permission: IpPermissionInput, +): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, + { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { groupId }, + ); +} + +export async function revokeEc2SecurityGroupEgress( + groupId: string, + permission: IpPermissionInput, +): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, + { body: JSON.stringify(permission), headers: { "content-type": "application/json" } }, + { groupId }, + ); +} + +export async function listEc2AvailabilityZones(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.availabilityZones, { signal }); + return res.data; +} + +export async function listEc2InstanceTypes(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.instanceTypes, { signal }); + return res.data; +} + +export async function modifyEc2SubnetAttribute(subnetId: string, mapPublicIpOnLaunch: boolean): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.subnets.modifyAttribute, + { body: JSON.stringify({ mapPublicIpOnLaunch }), headers: { "content-type": "application/json" } }, + { subnetId }, + ); +} + +export async function getEc2VpcAttributes(vpcId: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.vpcs.getAttributes, + { signal }, + { vpcId }, + ); + return res.data; +} + +export async function modifyEc2VpcAttribute( + vpcId: string, + attribute: "enableDnsHostnames" | "enableDnsSupport", + value: boolean, +): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.vpcs.modifyAttribute, + { body: JSON.stringify({ attribute, value }), headers: { "content-type": "application/json" } }, + { vpcId }, + ); +} + +export async function associateEc2ElasticIp( + allocationId: string, + instanceId: string, +): Promise<{ associationId: string }> { + const res = await apiClient.call<{ associationId: string }>( + apiEndpointKeys.aws.ec2.elasticIps.associate, + { body: JSON.stringify({ instanceId }), headers: { "content-type": "application/json" } }, + { allocationId }, + ); + return res.data; +} + +export async function disassociateEc2ElasticIp(allocationId: string, associationId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.elasticIps.disassociate, + { body: JSON.stringify({ associationId }), headers: { "content-type": "application/json" } }, + { allocationId }, + ); +} + +export async function listEc2Vpcs(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.vpcs.list, { signal }); + return res.data; +} + +export async function createEc2Vpc(cidrBlock: string): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.vpcs.create, + { body: JSON.stringify({ cidrBlock }), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function deleteEc2Vpc(vpcId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.vpcs.delete, {}, { vpcId }); +} + +export async function listEc2Subnets(vpcId?: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.subnets.list, + { params: { vpcId }, signal }, + ); + return res.data; +} + +export async function createEc2Subnet( + vpcId: string, + cidrBlock: string, + availabilityZone?: string, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.subnets.create, + { body: JSON.stringify({ vpcId, cidrBlock, availabilityZone }), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function deleteEc2Subnet(subnetId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.subnets.delete, {}, { subnetId }); +} + +export async function listEc2InternetGateways(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.internetGateways.list, { signal }); + return res.data; +} + +export async function createEc2InternetGateway(name?: string): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.internetGateways.create, + { body: JSON.stringify({ name }), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function attachEc2InternetGateway(igwId: string, vpcId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.internetGateways.attach, + { body: JSON.stringify({ vpcId }), headers: { "content-type": "application/json" } }, + { igwId }, + ); +} + +export async function detachEc2InternetGateway(igwId: string, vpcId: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.internetGateways.detach, + { body: JSON.stringify({ vpcId }), headers: { "content-type": "application/json" } }, + { igwId }, + ); +} + +export async function deleteEc2InternetGateway(igwId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.internetGateways.delete, {}, { igwId }); +} + +export async function listEc2NatGateways(vpcId?: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.natGateways.list, + { params: { vpcId }, signal }, + ); + return res.data; +} + +export async function createEc2NatGateway(input: CreateNatGatewayInput): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.natGateways.create, + { body: JSON.stringify(input), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function deleteEc2NatGateway(natId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.natGateways.delete, {}, { natId }); +} + +export async function listEc2RouteTables(vpcId?: string, signal?: AbortSignal): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.routeTables.list, + { params: { vpcId }, signal }, + ); + return res.data; +} + +export async function createEc2RouteTable(vpcId: string, name?: string): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.routeTables.create, + { body: JSON.stringify({ vpcId, name }), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function deleteEc2RouteTable(rtbId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.routeTables.delete, {}, { rtbId }); +} + +export async function createEc2Route(rtbId: string, input: CreateRouteInput): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.routeTables.createRoute, + { body: JSON.stringify(input), headers: { "content-type": "application/json" } }, + { rtbId }, + ); +} + +export async function deleteEc2Route(rtbId: string, cidr: string): Promise { + await apiClient.call( + apiEndpointKeys.aws.ec2.routeTables.deleteRoute, + { params: { cidr } }, + { rtbId }, + ); +} + +export async function associateEc2RouteTable(rtbId: string, subnetId: string): Promise { + const res = await apiClient.call<{ associationId: string }>( + apiEndpointKeys.aws.ec2.routeTables.associate, + { body: JSON.stringify({ subnetId }), headers: { "content-type": "application/json" } }, + { rtbId }, + ); + return res.data.associationId; +} + +export async function disassociateEc2RouteTable(associationId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.routeTables.disassociate, {}, { associationId }); +} + +export async function listEc2ElasticIps(signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.aws.ec2.elasticIps.list, { signal }); + return res.data; +} + +export async function allocateEc2ElasticIp(name?: string): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.elasticIps.create, + { body: JSON.stringify({ name }), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function releaseEc2ElasticIp(allocationId: string): Promise { + await apiClient.call(apiEndpointKeys.aws.ec2.elasticIps.release, {}, { allocationId }); +} + +export async function createVpcWizard(input: VpcWizardInput): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.ec2.vpcWizard, + { body: JSON.stringify(input), headers: { "content-type": "application/json" } }, + ); + return res.data; +} + +export async function listEc2Resources(signal?: AbortSignal): Promise { + const instances = await listEc2Instances(signal); + return instances.map((instance) => ({ + id: instance.instanceId, + name: instance.name, + status: instance.state, + metadata: { + instanceType: instance.instanceType, + az: instance.availabilityZone, + publicIp: instance.publicIpAddress, + privateIp: instance.privateIpAddress, + }, + })); +} + +// ─── Client ─────────────────────────────────────────────────────────────────── + +export const ec2Client = { + listInstances: listEc2Instances, + describeInstance: describeEc2Instance, + runInstance: runEc2Instance, + startInstance: startEc2Instance, + stopInstance: stopEc2Instance, + rebootInstance: rebootEc2Instance, + terminateInstance: terminateEc2Instance, + updateInstanceTags: updateEc2InstanceTags, + createAmi: createEc2Ami, + getConsoleOutput: getEc2ConsoleOutput, + listAmis: listEc2Amis, + deregisterAmi: deregisterEc2Ami, + listResources: listEc2Resources, + listKeyPairs: listEc2KeyPairs, + createKeyPair: createEc2KeyPair, + deleteKeyPair: deleteEc2KeyPair, + listSecurityGroups: listEc2SecurityGroups, + createSecurityGroup: createEc2SecurityGroup, + deleteSecurityGroup: deleteEc2SecurityGroup, + authorizeSecurityGroupIngress: authorizeEc2SecurityGroupIngress, + revokeSecurityGroupIngress: revokeEc2SecurityGroupIngress, + authorizeSecurityGroupEgress: authorizeEc2SecurityGroupEgress, + revokeSecurityGroupEgress: revokeEc2SecurityGroupEgress, + listAvailabilityZones: listEc2AvailabilityZones, + listInstanceTypes: listEc2InstanceTypes, + modifySubnetAttribute: modifyEc2SubnetAttribute, + getVpcAttributes: getEc2VpcAttributes, + modifyVpcAttribute: modifyEc2VpcAttribute, + associateElasticIp: associateEc2ElasticIp, + disassociateElasticIp: disassociateEc2ElasticIp, + listVpcs: listEc2Vpcs, + createVpc: createEc2Vpc, + deleteVpc: deleteEc2Vpc, + listSubnets: listEc2Subnets, + createSubnet: createEc2Subnet, + deleteSubnet: deleteEc2Subnet, + listInternetGateways: listEc2InternetGateways, + createInternetGateway: createEc2InternetGateway, + attachInternetGateway: attachEc2InternetGateway, + detachInternetGateway: detachEc2InternetGateway, + deleteInternetGateway: deleteEc2InternetGateway, + listNatGateways: listEc2NatGateways, + createNatGateway: createEc2NatGateway, + deleteNatGateway: deleteEc2NatGateway, + listRouteTables: listEc2RouteTables, + createRouteTable: createEc2RouteTable, + deleteRouteTable: deleteEc2RouteTable, + createRoute: createEc2Route, + deleteRoute: deleteEc2Route, + associateRouteTable: associateEc2RouteTable, + disassociateRouteTable: disassociateEc2RouteTable, + listElasticIps: listEc2ElasticIps, + allocateElasticIp: allocateEc2ElasticIp, + releaseElasticIp: releaseEc2ElasticIp, + createVpcWizard, +}; diff --git a/packages/frontend/src/api/aws/ec2.mutations.ts b/packages/frontend/src/api/aws/ec2.mutations.ts new file mode 100644 index 0000000..20a477d --- /dev/null +++ b/packages/frontend/src/api/aws/ec2.mutations.ts @@ -0,0 +1,493 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + allocateEc2ElasticIp, + associateEc2ElasticIp, + associateEc2RouteTable, + attachEc2InternetGateway, + authorizeEc2SecurityGroupEgress, + authorizeEc2SecurityGroupIngress, + createEc2Ami, + createEc2InternetGateway, + createEc2KeyPair, + createEc2NatGateway, + createEc2Route, + createEc2RouteTable, + createEc2SecurityGroup, + createEc2Subnet, + createEc2Vpc, + deregisterEc2Ami, + deleteEc2InternetGateway, + deleteEc2KeyPair, + deleteEc2NatGateway, + deleteEc2Route, + deleteEc2RouteTable, + deleteEc2SecurityGroup, + deleteEc2Subnet, + deleteEc2Vpc, + detachEc2InternetGateway, + disassociateEc2ElasticIp, + disassociateEc2RouteTable, + modifyEc2SubnetAttribute, + modifyEc2VpcAttribute, + rebootEc2Instance, + releaseEc2ElasticIp, + revokeEc2SecurityGroupEgress, + revokeEc2SecurityGroupIngress, + runEc2Instance, + startEc2Instance, + stopEc2Instance, + terminateEc2Instance, + updateEc2InstanceTags, +} from "./ec2.api"; +import type { + CreateAmiInput, + CreateNatGatewayInput, + CreateRouteInput, + Ec2Tag, + IpPermissionInput, + RunEc2InstanceInput, +} from "./ec2.api"; +import { ec2QueryKeys } from "./ec2.queries"; + +// ─── Instance lifecycle ─────────────────────────────────────────────────────── + +export function useCreateEc2InstanceMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: RunEc2InstanceInput) => runEc2Instance(input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + }, + }); +} + +export function useStartEc2InstanceMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (instanceId: string) => startEc2Instance(instanceId), + onSuccess: (_data, instanceId) => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) }); + }, + }); +} + +export function useStopEc2InstanceMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (instanceId: string) => stopEc2Instance(instanceId), + onSuccess: (_data, instanceId) => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) }); + }, + }); +} + +export function useRebootEc2InstanceMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (instanceId: string) => rebootEc2Instance(instanceId), + onSuccess: (_data, instanceId) => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) }); + }, + }); +} + +export function useTerminateEc2InstanceMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (instanceId: string) => terminateEc2Instance(instanceId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + }, + }); +} + +export function useUpdateEc2InstanceTagsMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ instanceId, toAdd, toRemove }: { instanceId: string; toAdd: Ec2Tag[]; toRemove: string[] }) => + updateEc2InstanceTags(instanceId, toAdd, toRemove), + onSuccess: (_data, { instanceId }) => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + }, + }); +} + +// ─── AMIs ───────────────────────────────────────────────────────────────────── + +export function useCreateAmiMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ instanceId, input }: { instanceId: string; input: CreateAmiInput }) => + createEc2Ami(instanceId, input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.amis }); + }, + }); +} + +export function useDeregisterAmiMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (imageId: string) => deregisterEc2Ami(imageId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.amis }); + }, + }); +} + +// ─── Key pairs ──────────────────────────────────────────────────────────────── + +export function useCreateKeyPairMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => createEc2KeyPair(name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.keyPairs }); + }, + }); +} + +export function useDeleteKeyPairMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => deleteEc2KeyPair(name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.keyPairs }); + }, + }); +} + +// ─── Security groups ────────────────────────────────────────────────────────── + +export function useCreateSecurityGroupMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ name, description, vpcId }: { name: string; description: string; vpcId: string }) => + createEc2SecurityGroup(name, description, vpcId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + }, + }); +} + +export function useDeleteSecurityGroupMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (groupId: string) => deleteEc2SecurityGroup(groupId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + }, + }); +} + +export function useAuthorizeSecurityGroupIngressMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) => + authorizeEc2SecurityGroupIngress(groupId, permission), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + }, + }); +} + +export function useRevokeSecurityGroupIngressMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) => + revokeEc2SecurityGroupIngress(groupId, permission), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + }, + }); +} + +export function useAuthorizeSecurityGroupEgressMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) => + authorizeEc2SecurityGroupEgress(groupId, permission), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + }, + }); +} + +export function useRevokeSecurityGroupEgressMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) => + revokeEc2SecurityGroupEgress(groupId, permission), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + }, + }); +} + +// ─── VPCs ───────────────────────────────────────────────────────────────────── + +export function useCreateVpcMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (cidrBlock: string) => createEc2Vpc(cidrBlock), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcs }); + }, + }); +} + +export function useDeleteVpcMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (vpcId: string) => deleteEc2Vpc(vpcId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcs }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps }); + }, + }); +} + +// ─── Subnets ────────────────────────────────────────────────────────────────── + +export function useCreateSubnetMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + vpcId, + cidrBlock, + availabilityZone, + }: { + vpcId: string; + cidrBlock: string; + availabilityZone?: string; + }) => createEc2Subnet(vpcId, cidrBlock, availabilityZone), + onSuccess: (_data, { vpcId }) => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnets(vpcId) }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnets() }); + }, + }); +} + +export function useDeleteSubnetMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (subnetId: string) => deleteEc2Subnet(subnetId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll }); + }, + }); +} + +// ─── Internet Gateways ──────────────────────────────────────────────────────── + +export function useCreateInternetGatewayMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name?: string) => createEc2InternetGateway(name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways }); + }, + }); +} + +export function useAttachInternetGatewayMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ igwId, vpcId }: { igwId: string; vpcId: string }) => + attachEc2InternetGateway(igwId, vpcId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways }); + }, + }); +} + +export function useDetachInternetGatewayMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ igwId, vpcId }: { igwId: string; vpcId: string }) => + detachEc2InternetGateway(igwId, vpcId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways }); + }, + }); +} + +export function useDeleteInternetGatewayMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (igwId: string) => deleteEc2InternetGateway(igwId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + }, + }); +} + +// ─── NAT Gateways ───────────────────────────────────────────────────────────── + +export function useCreateNatGatewayMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateNatGatewayInput) => createEc2NatGateway(input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.natGatewaysAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps }); + }, + }); +} + +export function useDeleteNatGatewayMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (natId: string) => deleteEc2NatGateway(natId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.natGatewaysAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + }, + }); +} + +// ─── Route Tables ───────────────────────────────────────────────────────────── + +export function useCreateRouteTableMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ vpcId, name }: { vpcId: string; name?: string }) => + createEc2RouteTable(vpcId, name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + }, + }); +} + +export function useDeleteRouteTableMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (rtbId: string) => deleteEc2RouteTable(rtbId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll }); + }, + }); +} + +export function useCreateRouteMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ rtbId, input }: { rtbId: string; input: CreateRouteInput }) => + createEc2Route(rtbId, input), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + }, + }); +} + +export function useDeleteRouteMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ rtbId, cidr }: { rtbId: string; cidr: string }) => + deleteEc2Route(rtbId, cidr), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + }, + }); +} + +export function useAssociateRouteTableMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ rtbId, subnetId }: { rtbId: string; subnetId: string }) => + associateEc2RouteTable(rtbId, subnetId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll }); + }, + }); +} + +export function useDisassociateRouteTableMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (associationId: string) => disassociateEc2RouteTable(associationId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll }); + }, + }); +} + +// ─── Elastic IPs ────────────────────────────────────────────────────────────── + +export function useAllocateElasticIpMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name?: string) => allocateEc2ElasticIp(name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps }); + }, + }); +} + +export function useReleaseElasticIpMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (allocationId: string) => releaseEc2ElasticIp(allocationId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps }); + }, + }); +} + +export function useAssociateElasticIpMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ allocationId, instanceId }: { allocationId: string; instanceId: string }) => + associateEc2ElasticIp(allocationId, instanceId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + }, + }); +} + +export function useDisassociateElasticIpMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ allocationId, associationId }: { allocationId: string; associationId: string }) => + disassociateEc2ElasticIp(allocationId, associationId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps }); + void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances }); + }, + }); +} + +export function useModifySubnetAttributeMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ subnetId, mapPublicIpOnLaunch }: { subnetId: string; mapPublicIpOnLaunch: boolean }) => + modifyEc2SubnetAttribute(subnetId, mapPublicIpOnLaunch), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll }); + }, + }); +} + +export function useModifyVpcAttributeMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ vpcId, attribute, value }: { vpcId: string; attribute: "enableDnsHostnames" | "enableDnsSupport"; value: boolean }) => + modifyEc2VpcAttribute(vpcId, attribute, value), + onSuccess: (_data, { vpcId }) => { + void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcAttributes(vpcId) }); + }, + }); +} diff --git a/packages/frontend/src/api/aws/ec2.queries.ts b/packages/frontend/src/api/aws/ec2.queries.ts new file mode 100644 index 0000000..4289595 --- /dev/null +++ b/packages/frontend/src/api/aws/ec2.queries.ts @@ -0,0 +1,159 @@ +import { useQuery } from "@tanstack/react-query"; +import { ec2Client } from "./ec2.api"; + +export const ec2QueryKeys = { + instances: ["ec2", "instances"] as const, + instance: (instanceId: string | null) => ["ec2", "instance", instanceId] as const, + amis: ["ec2", "amis"] as const, + keyPairs: ["ec2", "key-pairs"] as const, + // Parameterized list queries — call with vpcId for scoped, omit for all + securityGroups: (vpcId?: string) => ["ec2", "security-groups", vpcId ?? "all"] as const, + subnets: (vpcId?: string) => ["ec2", "subnets", vpcId ?? "all"] as const, + natGateways: (vpcId?: string) => ["ec2", "nat-gateways", vpcId ?? "all"] as const, + routeTables: (vpcId?: string) => ["ec2", "route-tables", vpcId ?? "all"] as const, + // Root keys — use in mutations to invalidate all variants of a parameterized query + securityGroupsAll: ["ec2", "security-groups"] as const, + subnetsAll: ["ec2", "subnets"] as const, + natGatewaysAll: ["ec2", "nat-gateways"] as const, + routeTablesAll: ["ec2", "route-tables"] as const, + // Static list keys + vpcs: ["ec2", "vpcs"] as const, + consoleOutput: (instanceId: string | null) => ["ec2", "console", instanceId] as const, + internetGateways: ["ec2", "internet-gateways"] as const, + elasticIps: ["ec2", "elastic-ips"] as const, + availabilityZones: ["ec2", "availability-zones"] as const, + instanceTypes: ["ec2", "instance-types"] as const, + vpcAttributes: (vpcId: string) => ["ec2", "vpc-attributes", vpcId] as const, +}; + +export function useEc2InstancesQuery() { + return useQuery({ + queryKey: ec2QueryKeys.instances, + queryFn: ({ signal }) => ec2Client.listInstances(signal), + refetchInterval: 30_000, + }); +} + +export function useEc2InstanceQuery(instanceId: string | null) { + return useQuery({ + queryKey: ec2QueryKeys.instance(instanceId), + queryFn: ({ signal }) => ec2Client.describeInstance(instanceId!, signal), + enabled: Boolean(instanceId), + refetchInterval: 30_000, + }); +} + +export function useEc2AmisQuery() { + return useQuery({ + queryKey: ec2QueryKeys.amis, + queryFn: ({ signal }) => ec2Client.listAmis(signal), + refetchInterval: 30_000, + }); +} + +export function useEc2KeyPairsQuery(enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.keyPairs, + queryFn: ({ signal }) => ec2Client.listKeyPairs(signal), + enabled, + staleTime: 60_000, + }); +} + +export function useEc2SecurityGroupsQuery(vpcId?: string, enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.securityGroups(vpcId), + queryFn: ({ signal }) => ec2Client.listSecurityGroups(vpcId, signal), + enabled, + staleTime: 30_000, + }); +} + +export function useEc2VpcsQuery(enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.vpcs, + queryFn: ({ signal }) => ec2Client.listVpcs(signal), + enabled, + staleTime: 60_000, + }); +} + +export function useEc2SubnetsQuery(vpcId?: string, enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.subnets(vpcId), + queryFn: ({ signal }) => ec2Client.listSubnets(vpcId, signal), + enabled, + staleTime: 60_000, + }); +} + +export function useEc2ConsoleOutputQuery(instanceId: string | null) { + return useQuery({ + queryKey: ec2QueryKeys.consoleOutput(instanceId), + queryFn: ({ signal }) => ec2Client.getConsoleOutput(instanceId!, signal), + enabled: Boolean(instanceId), + staleTime: 0, + gcTime: 0, + }); +} + +export function useEc2InternetGatewaysQuery(enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.internetGateways, + queryFn: ({ signal }) => ec2Client.listInternetGateways(signal), + enabled, + staleTime: 60_000, + }); +} + +export function useEc2NatGatewaysQuery(vpcId?: string, enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.natGateways(vpcId), + queryFn: ({ signal }) => ec2Client.listNatGateways(vpcId, signal), + enabled, + staleTime: 30_000, + }); +} + +export function useEc2RouteTablesQuery(vpcId?: string, enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.routeTables(vpcId), + queryFn: ({ signal }) => ec2Client.listRouteTables(vpcId, signal), + enabled, + staleTime: 30_000, + }); +} + +export function useEc2ElasticIpsQuery(enabled = true) { + return useQuery({ + queryKey: ec2QueryKeys.elasticIps, + queryFn: ({ signal }) => ec2Client.listElasticIps(signal), + enabled, + staleTime: 60_000, + }); +} + +export function useEc2AvailabilityZonesQuery() { + return useQuery({ + queryKey: ec2QueryKeys.availabilityZones, + queryFn: ({ signal }) => ec2Client.listAvailabilityZones(signal), + staleTime: Infinity, + }); +} + +export function useEc2InstanceTypesQuery() { + return useQuery({ + queryKey: ec2QueryKeys.instanceTypes, + queryFn: ({ signal }) => ec2Client.listInstanceTypes(signal), + staleTime: Infinity, + }); +} + +export function useEc2VpcAttributesQuery(vpcId: string | undefined) { + return useQuery({ + queryKey: ec2QueryKeys.vpcAttributes(vpcId ?? ""), + queryFn: ({ signal }) => ec2Client.getVpcAttributes(vpcId!, signal), + enabled: Boolean(vpcId), + staleTime: 30_000, + }); +} diff --git a/packages/frontend/src/api/aws/eks.api.ts b/packages/frontend/src/api/aws/eks.api.ts new file mode 100644 index 0000000..1fdc323 --- /dev/null +++ b/packages/frontend/src/api/aws/eks.api.ts @@ -0,0 +1,260 @@ +import { apiClient, apiEndpointKeys } from "@/api/api"; +import type { ResourceSummary } from "@/api/types"; + +export type EksVpcConfig = { + subnetIds: string[]; + securityGroupIds: string[]; + clusterSecurityGroupId?: string; + vpcId?: string; + endpointPublicAccess?: boolean; + endpointPrivateAccess?: boolean; + publicAccessCidrs: string[]; +}; + +export type EksCluster = { + name: string; + arn?: string; + createdAt?: string; + version?: string; + endpoint?: string; + roleArn?: string; + status?: string; + platformVersion?: string; + certificateAuthority?: { + data?: string; + }; + resourcesVpcConfig?: EksVpcConfig; + tags: Record; + nodegroupCount?: number; + fargateProfileCount?: number; +}; + +export type EksNodegroup = { + name: string; + arn?: string; + clusterName: string; + version?: string; + releaseVersion?: string; + createdAt?: string; + modifiedAt?: string; + status?: string; + capacityType?: string; + instanceTypes: string[]; + subnets: string[]; + nodeRole?: string; + scalingConfig?: { + minSize?: number; + maxSize?: number; + desiredSize?: number; + }; + labels: Record; + tags: Record; +}; + +export type EksFargateProfileSelector = { + namespace?: string; + labels: Record; +}; + +export type EksFargateProfile = { + name: string; + arn?: string; + clusterName: string; + createdAt?: string; + status?: string; + podExecutionRoleArn?: string; + subnets: string[]; + selectors: EksFargateProfileSelector[]; + tags: Record; +}; + +export type CreateEksNodegroupInput = { + name: string; + nodeRole: string; + subnets: string[]; + instanceTypes?: string[]; + scalingConfig?: { + minSize?: number; + maxSize?: number; + desiredSize?: number; + }; + labels?: Record; + tags?: Record; +}; + +export type CreateEksFargateProfileInput = { + name: string; + podExecutionRoleArn: string; + subnets?: string[]; + selectors: { + namespace?: string; + labels?: Record; + }[]; + tags?: Record; +}; + +export async function listEksClusters( + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.clusters.list, + { signal }, + ); + + return res.data; +} + +export async function describeEksCluster( + name: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.clusters.describe, + { signal }, + { name }, + ); + + return res.data; +} + +export async function listEksNodegroups( + clusterName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.nodegroups.list, + { signal }, + { name: clusterName }, + ); + + return res.data; +} + +export async function describeEksNodegroup( + clusterName: string, + nodegroupName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.nodegroups.describe, + { signal }, + { name: clusterName, nodegroup: nodegroupName }, + ); + + return res.data; +} + +export async function createEksNodegroup( + clusterName: string, + input: CreateEksNodegroupInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.nodegroups.create, + { body: input }, + { name: clusterName }, + ); + + return res.data; +} + +export async function deleteEksNodegroup( + clusterName: string, + nodegroupName: string, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.nodegroups.delete, + {}, + { name: clusterName, nodegroup: nodegroupName }, + ); + + return res.data; +} + +export async function listEksFargateProfiles( + clusterName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.fargateProfiles.list, + { signal }, + { name: clusterName }, + ); + + return res.data; +} + +export async function describeEksFargateProfile( + clusterName: string, + profileName: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.fargateProfiles.describe, + { signal }, + { name: clusterName, profile: profileName }, + ); + + return res.data; +} + +export async function createEksFargateProfile( + clusterName: string, + input: CreateEksFargateProfileInput, +): Promise { + const res = await apiClient.call< + EksFargateProfile, + CreateEksFargateProfileInput + >( + apiEndpointKeys.aws.eks.fargateProfiles.create, + { body: input }, + { name: clusterName }, + ); + + return res.data; +} + +export async function deleteEksFargateProfile( + clusterName: string, + profileName: string, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.eks.fargateProfiles.delete, + {}, + { name: clusterName, profile: profileName }, + ); + + return res.data; +} + +export async function listEksResources( + signal?: AbortSignal, +): Promise { + const clusters = await listEksClusters(signal); + + return clusters.map((cluster) => ({ + id: cluster.arn ?? cluster.name, + name: cluster.name, + status: cluster.status, + metadata: { + version: cluster.version, + platformVersion: cluster.platformVersion, + nodegroups: cluster.nodegroupCount ?? 0, + fargateProfiles: cluster.fargateProfileCount ?? 0, + vpcId: cluster.resourcesVpcConfig?.vpcId, + }, + })); +} + +export const eksClient = { + listClusters: listEksClusters, + describeCluster: describeEksCluster, + listNodegroups: listEksNodegroups, + describeNodegroup: describeEksNodegroup, + createNodegroup: createEksNodegroup, + deleteNodegroup: deleteEksNodegroup, + listFargateProfiles: listEksFargateProfiles, + describeFargateProfile: describeEksFargateProfile, + createFargateProfile: createEksFargateProfile, + deleteFargateProfile: deleteEksFargateProfile, + listResources: listEksResources, +}; diff --git a/packages/frontend/src/api/aws/eks.mutations.ts b/packages/frontend/src/api/aws/eks.mutations.ts new file mode 100644 index 0000000..fdcf16b --- /dev/null +++ b/packages/frontend/src/api/aws/eks.mutations.ts @@ -0,0 +1,87 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + eksClient, + type CreateEksFargateProfileInput, + type CreateEksNodegroupInput, +} from "./eks.api"; +import { eksQueryKeys } from "./eks.queries"; + +export type CreateEksNodegroupMutationInput = { + clusterName: string; + nodegroup: CreateEksNodegroupInput; +}; + +export type DeleteEksNodegroupMutationInput = { + clusterName: string; + nodegroupName: string; +}; + +export type CreateEksFargateProfileMutationInput = { + clusterName: string; + profile: CreateEksFargateProfileInput; +}; + +export type DeleteEksFargateProfileMutationInput = { + clusterName: string; + profileName: string; +}; + +export function useCreateEksNodegroupMutation() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ clusterName, nodegroup }: CreateEksNodegroupMutationInput) => + eksClient.createNodegroup(clusterName, nodegroup), + onSuccess: (_, input) => { + void qc.invalidateQueries({ + queryKey: eksQueryKeys.nodegroups(input.clusterName), + }); + void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters }); + }, + }); +} + +export function useDeleteEksNodegroupMutation() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ clusterName, nodegroupName }: DeleteEksNodegroupMutationInput) => + eksClient.deleteNodegroup(clusterName, nodegroupName), + onSuccess: (_, input) => { + void qc.invalidateQueries({ + queryKey: eksQueryKeys.nodegroups(input.clusterName), + }); + void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters }); + }, + }); +} + +export function useCreateEksFargateProfileMutation() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ clusterName, profile }: CreateEksFargateProfileMutationInput) => + eksClient.createFargateProfile(clusterName, profile), + onSuccess: (_, input) => { + void qc.invalidateQueries({ + queryKey: eksQueryKeys.fargateProfiles(input.clusterName), + }); + void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters }); + }, + }); +} + +export function useDeleteEksFargateProfileMutation() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ clusterName, profileName }: DeleteEksFargateProfileMutationInput) => + eksClient.deleteFargateProfile(clusterName, profileName), + onSuccess: (_, input) => { + void qc.invalidateQueries({ + queryKey: eksQueryKeys.fargateProfiles(input.clusterName), + }); + void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters }); + }, + }); +} diff --git a/packages/frontend/src/api/aws/eks.queries.ts b/packages/frontend/src/api/aws/eks.queries.ts new file mode 100644 index 0000000..a22aa27 --- /dev/null +++ b/packages/frontend/src/api/aws/eks.queries.ts @@ -0,0 +1,76 @@ +import { useQuery } from "@tanstack/react-query"; +import { eksClient } from "./eks.api"; + +export const eksQueryKeys = { + clusters: ["eks", "clusters"] as const, + cluster: (name: string | null) => ["eks", "cluster", name] as const, + nodegroups: (clusterName: string | null) => + ["eks", "nodegroups", clusterName] as const, + nodegroup: (clusterName: string | null, nodegroupName: string | null) => + ["eks", "nodegroup", clusterName, nodegroupName] as const, + fargateProfiles: (clusterName: string | null) => + ["eks", "fargate-profiles", clusterName] as const, + fargateProfile: (clusterName: string | null, profileName: string | null) => + ["eks", "fargate-profile", clusterName, profileName] as const, +}; + +export function useEksClustersQuery() { + return useQuery({ + queryKey: eksQueryKeys.clusters, + queryFn: ({ signal }) => eksClient.listClusters(signal), + refetchInterval: 30_000, + }); +} + +export function useEksClusterQuery(name: string | null) { + return useQuery({ + queryKey: eksQueryKeys.cluster(name), + queryFn: ({ signal }) => eksClient.describeCluster(name!, signal), + enabled: Boolean(name), + refetchInterval: 30_000, + }); +} + +export function useEksNodegroupsQuery(clusterName: string | null) { + return useQuery({ + queryKey: eksQueryKeys.nodegroups(clusterName), + queryFn: ({ signal }) => eksClient.listNodegroups(clusterName!, signal), + enabled: Boolean(clusterName), + refetchInterval: 30_000, + }); +} + +export function useEksNodegroupQuery( + clusterName: string | null, + nodegroupName: string | null, +) { + return useQuery({ + queryKey: eksQueryKeys.nodegroup(clusterName, nodegroupName), + queryFn: ({ signal }) => + eksClient.describeNodegroup(clusterName!, nodegroupName!, signal), + enabled: Boolean(clusterName && nodegroupName), + refetchInterval: 30_000, + }); +} + +export function useEksFargateProfilesQuery(clusterName: string | null) { + return useQuery({ + queryKey: eksQueryKeys.fargateProfiles(clusterName), + queryFn: ({ signal }) => eksClient.listFargateProfiles(clusterName!, signal), + enabled: Boolean(clusterName), + refetchInterval: 30_000, + }); +} + +export function useEksFargateProfileQuery( + clusterName: string | null, + profileName: string | null, +) { + return useQuery({ + queryKey: eksQueryKeys.fargateProfile(clusterName, profileName), + queryFn: ({ signal }) => + eksClient.describeFargateProfile(clusterName!, profileName!, signal), + enabled: Boolean(clusterName && profileName), + refetchInterval: 30_000, + }); +} diff --git a/packages/frontend/src/api/aws/secretsmanager.api.ts b/packages/frontend/src/api/aws/secretsmanager.api.ts new file mode 100644 index 0000000..4f784aa --- /dev/null +++ b/packages/frontend/src/api/aws/secretsmanager.api.ts @@ -0,0 +1,141 @@ +import { apiClient, apiEndpointKeys } from "@/api/api"; +import type { ResourceSummary } from "@/api/types"; + +export interface SecretTag { + key: string; + value: string; +} + +export interface SecretSummary { + name: string; + arn?: string; + description?: string; + rotationEnabled: boolean; + kmsKeyId?: string; + lastChangedDate?: string; + lastAccessedDate?: string; + createdDate?: string; + tags: SecretTag[]; +} + +export interface SecretDetail extends SecretSummary { + deletedDate?: string; + versionIds: string[]; +} + +export interface SecretValue { + name: string; + arn?: string; + versionId?: string; + secretString?: string; + secretBinary?: string; + createdDate?: string; +} + +export async function listSecrets( + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.secretsmanager.secrets.list, + { signal }, + ); + + return res.data; +} + +export async function describeSecret( + id: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.secretsmanager.secrets.describe, + { signal, params: { id } }, + ); + + return res.data; +} + +export async function getSecretValue( + id: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.aws.secretsmanager.secrets.value.get, + { signal, params: { id } }, + ); + + return res.data; +} + +export async function createSecret( + name: string, + secretString: string, + description?: string, + signal?: AbortSignal, +): Promise<{ name: string; arn?: string; versionId?: string }> { + const res = await apiClient.call< + { name: string; arn?: string; versionId?: string }, + { name: string; secretString: string; description?: string } + >( + apiEndpointKeys.aws.secretsmanager.secrets.create, + { signal, body: { name, secretString, description } }, + ); + + return res.data; +} + +export async function putSecretValue( + id: string, + secretString: string, + signal?: AbortSignal, +): Promise<{ arn?: string; versionId?: string }> { + const res = await apiClient.call< + { arn?: string; versionId?: string }, + { id: string; secretString: string } + >( + apiEndpointKeys.aws.secretsmanager.secrets.value.put, + { signal, body: { id, secretString } }, + ); + + return res.data; +} + +export async function deleteSecret( + id: string, + force = false, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.aws.secretsmanager.secrets.delete, + { signal, params: { id, force: force ? "true" : undefined } }, + ); +} + +export async function listSecretResources( + signal?: AbortSignal, +): Promise { + const secrets = await listSecrets(signal); + + return secrets.map((s) => ({ + id: s.arn ?? s.name, + name: s.name, + status: s.rotationEnabled ? "rotation enabled" : "available", + description: s.description, + metadata: { + lastChangedDate: s.lastChangedDate, + lastAccessedDate: s.lastAccessedDate, + createdDate: s.createdDate, + tagCount: s.tags.length, + }, + })); +} + +export const secretsManagerClient = { + listSecrets, + describeSecret, + getSecretValue, + createSecret, + putSecretValue, + deleteSecret, + listResources: listSecretResources, +}; diff --git a/packages/frontend/src/api/aws/secretsmanager.mutations.ts b/packages/frontend/src/api/aws/secretsmanager.mutations.ts new file mode 100644 index 0000000..44c2f27 --- /dev/null +++ b/packages/frontend/src/api/aws/secretsmanager.mutations.ts @@ -0,0 +1,89 @@ +import { + useMutation, + useQueryClient, + type UseMutationOptions, +} from "@tanstack/react-query"; +import { secretsManagerClient } from "./secretsmanager.api"; +import { secretsManagerQueryKeys } from "./secretsmanager.queries"; + +export type CreateSecretMutationInput = { + name: string; + secretString: string; + description?: string; +}; + +export type PutSecretValueMutationInput = { + id: string; + secretString: string; +}; + +export type DeleteSecretMutationInput = { + id: string; + force?: boolean; +}; + +export function useCreateSecretMutation( + options?: UseMutationOptions< + { name: string; arn?: string; versionId?: string }, + Error, + CreateSecretMutationInput + >, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, secretString, description }) => + secretsManagerClient.createSecret(name, secretString, description), + ...options, + onSuccess: (...args) => { + void queryClient.invalidateQueries({ + queryKey: secretsManagerQueryKeys.list, + }); + options?.onSuccess?.(...args); + }, + }); +} + +export function usePutSecretValueMutation( + options?: UseMutationOptions< + { arn?: string; versionId?: string }, + Error, + PutSecretValueMutationInput + >, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, secretString }) => + secretsManagerClient.putSecretValue(id, secretString), + ...options, + onSuccess: (...args) => { + const [, { id }] = args; + void queryClient.invalidateQueries({ + queryKey: secretsManagerQueryKeys.value(id), + }); + void queryClient.invalidateQueries({ + queryKey: secretsManagerQueryKeys.detail(id), + }); + options?.onSuccess?.(...args); + }, + }); +} + +export function useDeleteSecretMutation( + options?: UseMutationOptions, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, force }) => + secretsManagerClient.deleteSecret(id, force ?? false), + ...options, + onSuccess: (...args) => { + void queryClient.invalidateQueries({ + queryKey: secretsManagerQueryKeys.list, + }); + options?.onSuccess?.(...args); + }, + }); +} diff --git a/packages/frontend/src/api/aws/secretsmanager.queries.ts b/packages/frontend/src/api/aws/secretsmanager.queries.ts new file mode 100644 index 0000000..804058d --- /dev/null +++ b/packages/frontend/src/api/aws/secretsmanager.queries.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import { secretsManagerClient } from "./secretsmanager.api"; + +export const secretsManagerQueryKeys = { + list: ["secretsmanager", "secrets"] as const, + detail: (id: string | null) => ["secretsmanager", "detail", id] as const, + value: (id: string | null) => ["secretsmanager", "value", id] as const, +}; + +export function useSecretsQuery(enabled = true) { + return useQuery({ + queryKey: secretsManagerQueryKeys.list, + queryFn: ({ signal }) => secretsManagerClient.listSecrets(signal), + enabled, + }); +} + +export function useSecretDetailQuery(id: string | null) { + return useQuery({ + queryKey: secretsManagerQueryKeys.detail(id), + queryFn: ({ signal }) => secretsManagerClient.describeSecret(id!, signal), + enabled: Boolean(id), + }); +} + +export function useSecretValueQuery(id: string | null, enabled = false) { + return useQuery({ + queryKey: secretsManagerQueryKeys.value(id), + queryFn: ({ signal }) => secretsManagerClient.getSecretValue(id!, signal), + enabled: Boolean(id) && enabled, + // Keep the plaintext out of the long-lived cache: always refetch on reveal + // and drop it the moment the query loses its observer so a hidden value + // cannot be read back later. + gcTime: 0, + staleTime: 0, + }); +} diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts new file mode 100644 index 0000000..3ce7db0 --- /dev/null +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -0,0 +1,983 @@ +import { API_BASE_URL, apiClient, apiEndpointKeys } from "./api"; +import type { + CloudDescriptor, + CloudProvider, + CloudServiceDescriptor, + CloudServiceStatus, + CloudServiceType, + CloudStatus, +} from "@/types/cloud"; +import type { + AppConfigConfigurationProfile, + AppConfigDeployment, + AppConfigDeploymentStrategy, + AppConfigEnvironment, + AppConfigHostedConfigurationVersion, + ChildCollection, + ChildItem, + CloudResource, + CollectionPage, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateDatabaseSnapshotInput, + CreateKubernetesFargateProfileInput, + CreateKubernetesNodegroupInput, + DatabaseSnapshot, + KubernetesFargateProfile, + KubernetesNodegroup, + LogsInsightsQueryInput, + LogsInsightsQueryResult, + NoSqlItem, + SqlCredentials, + SqlDatabase, + SqlEngine, + SqlQueryResult, + SqlTable, + StorageObjectList, +} from "@/types/resource"; +import type { ServiceSchema } from "@/types/schema"; +import { getAccountId } from "@/lib/accountStore"; + +type CloudPathParams = Record; + +const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000; +const SQL_DATA_TIMEOUT_MS = 45_000; +/** Floci completes Insights queries instantly by default, but bounded server-side polling can take longer. */ +const LOGS_QUERY_TIMEOUT_MS = 20_000; +/** Cold starts pull a container image; measured at ~60s on a first invoke. */ +const INVOKE_TIMEOUT_MS = 120_000; + +export async function listClouds( + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.list, + { signal }, + ); + return res.data; +} + +export async function listCloudServices( + cloud: CloudProvider, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.services, + requestOptions(cloud, "cloud-proxy", { signal }), + { cloud }, + ); + return res.data; +} + +export async function getCloudStatus( + cloud: CloudProvider, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.status, + requestOptions(cloud, "cloud-proxy", { signal }), + { cloud }, + ); + return res.data; +} + +export async function getCloudServiceStatus( + cloud: CloudProvider, + service: CloudServiceType, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.serviceStatus, + requestOptions(cloud, service, { signal }), + { cloud, service }, + ); + return res.data; +} + +export async function getServiceSchema( + cloud: CloudProvider, + service: CloudServiceType, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.schema, + requestOptions(cloud, service, { signal }), + { cloud, service }, + ); + return res.data; +} + +export async function listCloudResources( + cloud: CloudProvider, + service: CloudServiceType, + search?: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.list, + requestOptions(cloud, service, { signal, params: { search } }), + { cloud, service }, + ); + return res.data; +} + +export async function getCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.get, + requestOptions(cloud, service, { signal }), + { cloud, service, id }, + ); + return res.data; +} + +export async function createCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + values: Record, + signal?: AbortSignal, +): Promise { + const timeout = + (cloud === "azure" || cloud === "aws") && service === "database" + ? DATABASE_MUTATION_TIMEOUT_MS + : undefined; + const res = await apiClient.call>( + apiEndpointKeys.clouds.resources.create, + requestOptions(cloud, service, { signal, body: values, timeout }), + { cloud, service }, + ); + return res.data; +} + +export async function updateCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, + values: Record, + signal?: AbortSignal, +): Promise { + const timeout = + (cloud === "azure" || cloud === "aws") && service === "database" + ? DATABASE_MUTATION_TIMEOUT_MS + : undefined; + const res = await apiClient.call>( + apiEndpointKeys.clouds.resources.update, + requestOptions(cloud, service, { signal, body: values, timeout }), + { cloud, service, id }, + ); + return res.data; +} + +export async function deleteCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.delete, + requestOptions(cloud, service, { signal }), + { cloud, service, id }, + ); +} + +export async function clearEmailInbox( + cloud: CloudProvider, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.email.inbox.clear, + requestOptions(cloud, "email", { signal }), + { cloud }, + ); +} +export interface ServerlessInvokeResult { + statusCode: number; + payload: string; + functionError?: string; + logResult?: string; + executionDuration?: number; +} + +export interface CreateApiGatewayLambdaRouteInput { + path: string; + method: string; + stage: string; + functionName: string; +} + +export interface ApiGatewayLambdaRoute { + resourceId: string; + deploymentId: string; + invokeUrl: string; +} + +export async function createApiGatewayLambdaRoute( + cloud: CloudProvider, + apiId: string, + input: CreateApiGatewayLambdaRouteInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.createApiGatewayRoute, + requestOptions(cloud, "apigateway", { signal, body: input }), + { cloud, id: apiId }, + ); + return res.data; +} + +export type KmsEncryptionAlgorithm = + | "SYMMETRIC_DEFAULT" + | "RSAES_OAEP_SHA_1" + | "RSAES_OAEP_SHA_256"; + +export interface KmsEncryptRequest { + plaintextBase64: string; + encryptionAlgorithm: KmsEncryptionAlgorithm; + encryptionContext?: Record; +} + +export interface KmsEncryptResponse { + ciphertextBlobBase64: string; + keyId: string; + encryptionAlgorithm: KmsEncryptionAlgorithm; +} + +export interface KmsDecryptRequest { + ciphertextBlobBase64: string; + encryptionAlgorithm: KmsEncryptionAlgorithm; + encryptionContext?: Record; +} + +export interface KmsDecryptResponse { + plaintextBase64: string; + keyId: string; + encryptionAlgorithm: KmsEncryptionAlgorithm; +} + +export async function invokeCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, + payload: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.invoke, + requestOptions(cloud, service, { + signal, + body: { payload }, + // A cold invoke can exceed a minute while the runtime pulls and starts the + // function container, so the 10s default would abort a request that is + // working. The user-visible alternative is a timeout error for a function + // that in fact ran. + timeout: INVOKE_TIMEOUT_MS, + }), + { cloud, service, id }, + ); + return res.data; +} + +export async function encryptKmsResource( + cloud: CloudProvider, + id: string, + body: KmsEncryptRequest, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.encrypt, + requestOptions(cloud, "kms", { signal, body }), + { cloud, id }, + ); + return res.data; +} + +export async function decryptKmsResource( + cloud: CloudProvider, + id: string, + body: KmsDecryptRequest, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.decrypt, + requestOptions(cloud, "kms", { signal, body }), + { cloud, id }, + ); + return res.data; +} + +export async function listStorageObjects( + cloud: CloudProvider, + resourceId: string, + prefix?: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.storage.objects.list, + requestOptions(cloud, "storage", { signal, params: { prefix } }), + storagePathParams(cloud, resourceId), + ); + return res.data; +} + +export async function uploadStorageObject( + cloud: CloudProvider, + resourceId: string, + key: string, + file: File | Blob, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.storage.objects.upload, + requestOptions(cloud, "storage", { + signal, + rawBody: file, + params: { key }, + headers: { "Content-Type": file.type || "application/octet-stream" }, + }), + storagePathParams(cloud, resourceId), + ); +} + +export function storageObjectDownloadUrl( + cloud: CloudProvider, + resourceId: string, + key: string, +): string { + const path = `/clouds/${encodeURIComponent( + cloud, + )}/services/storage/resources/${encodeURIComponent(resourceId)}/object`; + // This URL is opened directly by the browser (anchor/img), so it bypasses the + // request interceptor — pass the account as a query param the API also reads. + return `${API_BASE_URL}${path}?key=${encodeURIComponent( + key, + )}&account=${encodeURIComponent(getAccountId())}`; +} + +export async function deleteStorageObject( + cloud: CloudProvider, + resourceId: string, + key: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.storage.objects.delete, + requestOptions(cloud, "storage", { signal, params: { key } }), + storagePathParams(cloud, resourceId), + ); +} + +export async function copyStorageObject( + cloud: CloudProvider, + srcResourceId: string, + srcKey: string, + destKey: string, + destResourceId?: string, + signal?: AbortSignal, +): Promise { + await apiClient.call>( + apiEndpointKeys.clouds.storage.objects.copy, + requestOptions(cloud, "storage", { + signal, + body: { srcKey, destKey, ...(destResourceId ? { destResourceId } : {}) }, + }), + storagePathParams(cloud, srcResourceId), + ); +} + +export async function listCosmosContainers( + cloud: CloudProvider, + databaseId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.nosql.cosmos.containers.list, + requestOptions(cloud, "nosql", { signal }), + databasePathParams(cloud, databaseId), + ); + return res.data; +} + +export async function createCosmosContainer( + cloud: CloudProvider, + databaseId: string, + values: Record, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.nosql.cosmos.containers.create, + requestOptions(cloud, "nosql", { signal, body: values }), + databasePathParams(cloud, databaseId), + ); + return res.data; +} + +export async function deleteCosmosContainer( + cloud: CloudProvider, + databaseId: string, + containerId: string, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.nosql.cosmos.containers.delete, + requestOptions(cloud, "nosql", { signal }), + { ...databasePathParams(cloud, databaseId), containerId }, + ); +} + + + +export async function listCosmosItems( + cloud: CloudProvider, + databaseId: string, + containerId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.nosql.cosmos.items.list, + requestOptions(cloud, "nosql", { signal }), + { ...databasePathParams(cloud, databaseId), containerId }, + ); + return res.data; +} + +export async function upsertCosmosItem( + cloud: CloudProvider, + databaseId: string, + containerId: string, + document: Record, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.nosql.cosmos.items.upsert, + requestOptions(cloud, "nosql", { signal, body: document }), + { ...databasePathParams(cloud, databaseId), containerId }, + ); + return res.data; +} + +export async function deleteCosmosItem( + cloud: CloudProvider, + databaseId: string, + containerId: string, + itemId: string, + partitionKey?: string | null, + signal?: AbortSignal, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.nosql.cosmos.items.delete, + requestOptions(cloud, "nosql", { + signal, + params: partitionKey ? { partitionKey } : undefined, + }), + { ...databasePathParams(cloud, databaseId), containerId, itemId }, + ); +} + +export async function queryCosmosItems( + cloud: CloudProvider, + databaseId: string, + containerId: string, + query: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.nosql.cosmos.items.query, + requestOptions(cloud, "nosql", { signal, body: { query } }), + { ...databasePathParams(cloud, databaseId), containerId }, + ); + return res.data; +} + +export async function listChildCollections( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + cursor?: string, + signal?: AbortSignal, +): Promise> { + const res = await apiClient.call>( + apiEndpointKeys.clouds.childCollections.list, + requestOptions(cloud, service, { signal, params: cursor ? { cursor } : undefined }), + { cloud, service, id: resourceId }, + ); + return res.data; +} + +export async function listCollectionItems( + cloud: CloudProvider, + service: CloudServiceType, + resourceId: string, + collectionId: string, + cursor?: string, + signal?: AbortSignal, +): Promise> { + const res = await apiClient.call>( + apiEndpointKeys.clouds.childCollections.items.list, + requestOptions(cloud, service, { signal, params: cursor ? { cursor } : undefined }), + { cloud, service, id: resourceId, cid: collectionId }, + ); + return res.data; +} + +export async function listSqlDatabases( + cloud: CloudProvider, + serverId: string, + engine: SqlEngine, + credentials: SqlCredentials, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.sql.databases, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, engine }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function listSqlTables( + cloud: CloudProvider, + serverId: string, + engine: SqlEngine, + database: string, + credentials: SqlCredentials, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + SqlTable[], + SqlCredentials & { database: string; engine: SqlEngine } + >( + apiEndpointKeys.clouds.database.sql.tables, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, database, engine }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function querySql( + cloud: CloudProvider, + serverId: string, + engine: SqlEngine, + database: string, + credentials: SqlCredentials, + query: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + SqlQueryResult, + SqlCredentials & { database: string; engine: SqlEngine; query: string } + >( + apiEndpointKeys.clouds.database.sql.query, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, database, engine, query }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function queryLogs( + cloud: CloudProvider, + logGroupName: string, + input: LogsInsightsQueryInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.logs.query, + requestOptions(cloud, "logs", { signal, body: input, timeout: LOGS_QUERY_TIMEOUT_MS }), + { cloud, id: logGroupName }, + ); + return res.data; +} + +export async function listDatabaseSnapshots( + cloud: CloudProvider, + instanceIdentifier?: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.snapshots.list, + requestOptions(cloud, "database", { + signal, + params: instanceIdentifier ? { instanceIdentifier } : undefined, + }), + { cloud }, + ); + return res.data; +} + +export async function createDatabaseSnapshot( + cloud: CloudProvider, + input: CreateDatabaseSnapshotInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.snapshots.create, + requestOptions(cloud, "database", { signal, body: input }), + { cloud }, + ); + return res.data; +} + +export async function listDatabaseOrderableClasses( + cloud: CloudProvider, + engine?: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.orderableClasses.list, + requestOptions(cloud, "database", { + signal, + params: engine ? { engine } : undefined, + }), + { cloud }, + ); + return res.data; +} + +export async function listNoSqlItems( + cloud: CloudProvider, + resourceId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.nosql.items.list, + requestOptions(cloud, "nosql", { signal }), + { cloud, id: resourceId }, + ); + return res.data; +} + +export async function putNoSqlItem( + cloud: CloudProvider, + resourceId: string, + document: Record, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.nosql.items.put, + requestOptions(cloud, "nosql", { signal, body: document }), + { cloud, id: resourceId }, + ); + return res.data; +} + +export async function listKubernetesNodegroups( + cloud: CloudProvider, + clusterId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.k8s.nodegroups.list, + requestOptions(cloud, "k8s", {signal}), + {cloud, id: clusterId}, + ) + return res.data +} + +export async function createKubernetesNodegroup( + cloud: CloudProvider, + clusterId: string, + input: CreateKubernetesNodegroupInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.k8s.nodegroups.create, + requestOptions(cloud, "k8s", {body: input}), + {cloud, id: clusterId}, + ) + return res.data +} + +export async function deleteKubernetesNodegroup( + cloud: CloudProvider, + clusterId: string, + nodegroupId: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.k8s.nodegroups.delete, + requestOptions(cloud, "k8s"), + {cloud, id: clusterId, nodegroupId}, + ) +} + +export async function listKubernetesFargateProfiles( + cloud: CloudProvider, + clusterId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.k8s.fargateProfiles.list, + requestOptions(cloud, "k8s", {signal}), + {cloud, id: clusterId}, + ) + return res.data +} + +export async function createKubernetesFargateProfile( + cloud: CloudProvider, + clusterId: string, + input: CreateKubernetesFargateProfileInput, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.k8s.fargateProfiles.create, + requestOptions(cloud, "k8s", {body: input}), + {cloud, id: clusterId}, + ) + return res.data +} + +export async function deleteKubernetesFargateProfile( + cloud: CloudProvider, + clusterId: string, + profileId: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.k8s.fargateProfiles.delete, + requestOptions(cloud, "k8s"), + {cloud, id: clusterId, profileId}, + ) +} + +export async function listAppConfigEnvironments( + cloud: CloudProvider, + applicationId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.configuration.environments.list, + requestOptions(cloud, "configuration", {signal}), + {cloud, id: applicationId}, + ); + return res.data; +} + +export async function createAppConfigEnvironment( + cloud: CloudProvider, + applicationId: string, + values: Record, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.configuration.environments.create, + requestOptions(cloud, "configuration", {body: values}), + {cloud, id: applicationId}, + ); + return res.data; +} + +export async function deleteAppConfigEnvironment( + cloud: CloudProvider, + applicationId: string, + environmentId: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.configuration.environments.delete, + requestOptions(cloud, "configuration"), + {cloud, id: applicationId, environmentId}, + ); +} + +export async function listAppConfigConfigurationProfiles( + cloud: CloudProvider, + applicationId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.configuration.configurationProfiles.list, + requestOptions(cloud, "configuration", {signal}), + {cloud, id: applicationId}, + ); + return res.data; +} + +export async function createAppConfigConfigurationProfile( + cloud: CloudProvider, + applicationId: string, + values: Record, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.configuration.configurationProfiles.create, + requestOptions(cloud, "configuration", {body: values}), + {cloud, id: applicationId}, + ); + return res.data; +} + +export async function deleteAppConfigConfigurationProfile( + cloud: CloudProvider, + applicationId: string, + profileId: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.configuration.configurationProfiles.delete, + requestOptions(cloud, "configuration"), + {cloud, id: applicationId, profileId}, + ); +} + +export async function listAppConfigHostedConfigurationVersions( + cloud: CloudProvider, + applicationId: string, + profileId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.list, + requestOptions(cloud, "configuration", {signal}), + {cloud, id: applicationId, profileId}, + ); + return res.data; +} + +export async function getAppConfigHostedConfigurationVersion( + cloud: CloudProvider, + applicationId: string, + profileId: string, + versionNumber: number, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.get, + requestOptions(cloud, "configuration", {signal}), + {cloud, id: applicationId, profileId, versionNumber: String(versionNumber)}, + ); + return res.data; +} + +export async function createAppConfigHostedConfigurationVersion( + cloud: CloudProvider, + applicationId: string, + profileId: string, + values: Record, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.create, + requestOptions(cloud, "configuration", {body: values}), + {cloud, id: applicationId, profileId}, + ); + return res.data; +} + +export async function deleteAppConfigHostedConfigurationVersion( + cloud: CloudProvider, + applicationId: string, + profileId: string, + versionNumber: number, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.delete, + requestOptions(cloud, "configuration"), + {cloud, id: applicationId, profileId, versionNumber: String(versionNumber)}, + ); +} + +export async function listAppConfigDeploymentStrategies( + cloud: CloudProvider, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.configuration.deploymentStrategies.list, + requestOptions(cloud, "configuration", {signal}), + {cloud}, + ); + return res.data; +} + +export async function createAppConfigDeploymentStrategy( + cloud: CloudProvider, + values: Record, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.configuration.deploymentStrategies.create, + requestOptions(cloud, "configuration", {body: values}), + {cloud}, + ); + return res.data; +} + +export async function deleteAppConfigDeploymentStrategy( + cloud: CloudProvider, + strategyId: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.configuration.deploymentStrategies.delete, + requestOptions(cloud, "configuration"), + {cloud, strategyId}, + ); +} + +export async function startAppConfigDeployment( + cloud: CloudProvider, + applicationId: string, + environmentId: string, + values: Record, +): Promise { + const res = await apiClient.call>( + apiEndpointKeys.clouds.configuration.environments.deployments.start, + requestOptions(cloud, "configuration", {body: values}), + {cloud, id: applicationId, environmentId}, + ); + return res.data; +} + +export async function getAppConfigDeployment( + cloud: CloudProvider, + applicationId: string, + environmentId: string, + deploymentNumber: number, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.configuration.environments.deployments.get, + requestOptions(cloud, "configuration", {signal}), + {cloud, id: applicationId, environmentId, deploymentNumber: String(deploymentNumber)}, + ); + return res.data; +} + +function requestOptions( + cloud: CloudProvider, + service: string, + options: { + signal?: AbortSignal; + params?: Record; + body?: TBody; + rawBody?: BodyInit; + headers?: HeadersInit; + /** Overrides the client default; needed for calls that can legitimately run long. */ + timeout?: number; + } = {}, +) { + return { + ...options, + telemetry: { provider: cloud, service }, + }; +} + +function storagePathParams( + cloud: CloudProvider, + resourceId: string, +): CloudPathParams { + return { cloud, id: resourceId }; +} + +function databasePathParams( + cloud: CloudProvider, + databaseId: string, +): CloudPathParams { + return { cloud, id: databaseId }; +} diff --git a/packages/frontend/src/api/floci-client.ts b/packages/frontend/src/api/floci-client.ts new file mode 100644 index 0000000..67fe1dc --- /dev/null +++ b/packages/frontend/src/api/floci-client.ts @@ -0,0 +1,63 @@ +import { apiClient } from "./api"; +import type { HttpMethod } from "./HttpClient"; +import { + subscribeApiRequests, + type ApiRequestEvent, +} from "@/telemetry"; + +export type FlociRequestEvent = ApiRequestEvent; + +export function subscribeRequests( + cb: (event: FlociRequestEvent) => void, +): () => void { + return subscribeApiRequests(cb); +} + +export function apiGet( + path: string, + service: string, + signal?: AbortSignal, +): Promise { + return apiRequest("GET", path, service, undefined, signal); +} + +export function apiPost( + path: string, + service: string, + body: unknown, + signal?: AbortSignal, +): Promise { + return apiRequest("POST", path, service, body, signal); +} + +export function apiPut( + path: string, + service: string, + body: unknown, + signal?: AbortSignal, +): Promise { + return apiRequest("PUT", path, service, body, signal); +} + +export function apiDelete( + path: string, + service: string, + signal?: AbortSignal, +): Promise { + return apiRequest("DELETE", path, service, undefined, signal); +} + +async function apiRequest( + method: HttpMethod, + path: string, + service: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + const res = await apiClient.request(method, path, { + signal, + ...(body === undefined ? {} : { body }), + telemetry: { provider: "aws", service }, + }); + return res.data; +} diff --git a/packages/frontend/src/api/queries/cloudQueries.ts b/packages/frontend/src/api/queries/cloudQueries.ts new file mode 100644 index 0000000..ac37b35 --- /dev/null +++ b/packages/frontend/src/api/queries/cloudQueries.ts @@ -0,0 +1,87 @@ +import {useQuery} from '@tanstack/react-query' +import { + getCloudServiceStatus, + getCloudStatus, + listCloudResources, + listCloudServices, + listClouds, +} from '@/api/cloudProxyClient' +import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud' + +/** + * Shared cloud queries. + * + * These live under api/ rather than in a feature folder because the app shell + * (nav, connection indicator) needs them as much as the console home does. + * Key strings are unchanged so existing caches still share. + */ +export const cloudQueryKeys = { + clouds: ['clouds'] as const, + services: (cloud: CloudProvider) => ['cloud-services', cloud] as const, + status: (cloud: CloudProvider) => ['cloud-status', cloud] as const, + serviceStatus: (cloud: CloudProvider, service: CloudServiceType) => + ['cloud-service-status', cloud, service] as const, + resources: (cloud: CloudProvider, service: CloudServiceType) => + ['cloud-console-resources', cloud, service] as const, +} + +export function useCloudsQuery() { + return useQuery({ + queryKey: cloudQueryKeys.clouds, + queryFn: ({signal}) => listClouds(signal), + }) +} + +export function useCloudServicesQuery(cloud: CloudProvider) { + return useQuery({ + queryKey: cloudQueryKeys.services(cloud), + queryFn: ({signal}) => listCloudServices(cloud, signal), + // The catalog only changes when the API restarts, so this is effectively + // free on every navigation while still driving the whole nav. + staleTime: 60_000, + }) +} + +export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInterval?: number} = {}) { + return useQuery({ + queryKey: cloudQueryKeys.status(cloud), + queryFn: ({signal}) => getCloudStatus(cloud, signal), + refetchInterval: options.refetchInterval ?? 10_000, + }) +} + +/** Health of the single service being viewed, rather than of the whole cloud. */ +export function useCloudServiceStatusQuery( + cloud: CloudProvider, + service: CloudServiceType, + options: {enabled?: boolean} = {}, +) { + return useQuery({ + queryKey: cloudQueryKeys.serviceStatus(cloud, service), + queryFn: ({signal}) => getCloudServiceStatus(cloud, service, signal), + refetchInterval: 10_000, + enabled: options.enabled ?? true, + }) +} + +export function useCloudConsoleResourcesQuery({ + cloud, + service, + services, + status, +}: { + cloud: CloudProvider + service: CloudServiceType + services?: CloudServiceDescriptor[] + status?: CloudStatus +}) { + return useQuery({ + queryKey: cloudQueryKeys.resources(cloud, service), + queryFn: ({signal}) => listCloudResources(cloud, service, undefined, signal), + enabled: hasAvailableService(services, service) && status?.runtime === 'reachable', + }) +} + +function hasAvailableService(services: CloudServiceDescriptor[] | undefined, service: CloudServiceType): boolean { + return services?.some((item) => item.service === service && item.availability === 'available') ?? false +} diff --git a/packages/frontend/src/api/requestEvents.ts b/packages/frontend/src/api/requestEvents.ts new file mode 100644 index 0000000..8f60112 --- /dev/null +++ b/packages/frontend/src/api/requestEvents.ts @@ -0,0 +1,6 @@ +export { + apiRequestEventBus, + emitApiRequest, + subscribeApiRequests, +} from "@/telemetry/requestEventBus"; +export type { ApiRequestEvent, CloudProvider } from "@/telemetry/types"; diff --git a/packages/frontend/src/api/types.ts b/packages/frontend/src/api/types.ts new file mode 100644 index 0000000..f8b14b6 --- /dev/null +++ b/packages/frontend/src/api/types.ts @@ -0,0 +1,114 @@ +export type ServiceStatus = 'healthy' | 'degraded' | 'unavailable' | 'unknown' + +export type ServiceName = + | 's3' + | 'sqs' + | 'dynamodb' + | 'sns' + | 'lambda' + | 'eks' + | 'secretsmanager' + | 'cognito' + | 'rds' + | 'elasticache' + | 'iam' + | 'ssm' + | 'kms' + | 'cloudwatch' + +export interface ServiceInfo { + name: ServiceName + displayName: string + status: ServiceStatus + requestCount: number + errorCount: number + latencyP50Ms?: number + lastActivity?: string +} + +export interface HealthReport { + status: ServiceStatus + services: ServiceInfo[] + checkedAt: string + version?: string +} + +export interface ResourceSummary { + id: string + name: string + status?: string + description?: string + metadata?: Record +} + +export interface ServiceResourceSnapshot { + service: ServiceName + displayName: string + implemented: boolean + status: ServiceStatus + count?: number + latencyMs?: number + error?: string +} + +export interface ConsoleOverview { + checkedAt: string + health: HealthReport + resources: ServiceResourceSnapshot[] + logGroupCount: number + alarmCount: number + metricCount: number + totalResourceCount: number +} + +export interface CWLogGroup { + name: string + arn?: string + retentionInDays?: number + createdAt: number + storedBytes: number + metricFilterCount: number +} + +export interface CWLogStream { + name: string + createdAt?: number + firstEventAt?: number + lastEventAt?: number + lastIngestionAt?: number + storedBytes: number +} + +export interface CWLogEvent { + id: string + timestamp: number + message: string + ingestionTime?: number +} + +export interface CWAlarm { + alarmName: string + stateValue: 'ALARM' | 'OK' | 'INSUFFICIENT_DATA' + stateReason?: string + metricName?: string + namespace?: string + threshold?: number +} + +export interface CWMetric { + id: string + namespace: string + metricName: string + dimensions: Array<{ name: string; value: string }> +} + +export class FlociError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly endpoint?: string + ) { + super(message) + this.name = 'FlociError' + } +} diff --git a/packages/frontend/src/assets/floci-black.svg b/packages/frontend/src/assets/floci-black.svg new file mode 100644 index 0000000..6d26ba3 --- /dev/null +++ b/packages/frontend/src/assets/floci-black.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/frontend/src/assets/floci-mark-black.svg b/packages/frontend/src/assets/floci-mark-black.svg new file mode 100644 index 0000000..7284550 --- /dev/null +++ b/packages/frontend/src/assets/floci-mark-black.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/frontend/src/assets/floci-mark-white.svg b/packages/frontend/src/assets/floci-mark-white.svg new file mode 100644 index 0000000..8e2ef6b --- /dev/null +++ b/packages/frontend/src/assets/floci-mark-white.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/frontend/src/assets/floci-white.svg b/packages/frontend/src/assets/floci-white.svg new file mode 100644 index 0000000..70762bd --- /dev/null +++ b/packages/frontend/src/assets/floci-white.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/src/components/AccountSwitcher.tsx b/packages/frontend/src/components/AccountSwitcher.tsx new file mode 100644 index 0000000..619c754 --- /dev/null +++ b/packages/frontend/src/components/AccountSwitcher.tsx @@ -0,0 +1,129 @@ +import {useEffect, useRef, useState} from 'react' +import {useQueryClient} from '@tanstack/react-query' +import {Check, ChevronDown, UserRound} from 'lucide-react' +import { + DEFAULT_ACCOUNT_ID, + isAccountId, + setAccountId, + useAccountId, + useAccountRecents, +} from '@/lib/accountStore' + +function formatAccount(id: string): string { + // 000000000000 -> 0000-0000-0000, matching how AWS groups account ids. + return id.replace(/(\d{4})(\d{4})(\d{4})/, '$1-$2-$3') +} + +export function AccountSwitcher() { + const accountId = useAccountId() + const recents = useAccountRecents() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [draft, setDraft] = useState('') + const [error, setError] = useState(null) + const containerRef = useRef(null) + + useEffect(() => { + if (!open) return + const onClick = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false) + } + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onClick) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onClick) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + function applyAccount(id: string) { + if (!setAccountId(id)) { + setError('Account id must be exactly 12 digits.') + return + } + setDraft('') + setError(null) + setOpen(false) + // Resources are account-scoped, so anything cached under the previous + // account must not leak into the new view. + void queryClient.invalidateQueries() + } + + function submitDraft() { + const trimmed = draft.trim() + if (!isAccountId(trimmed)) { + setError('Account id must be exactly 12 digits.') + return + } + applyAccount(trimmed) + } + + return ( +
+ + + {open && ( +
+
Switch account
+
+ {recents.map((id) => ( + + ))} +
+
{ + event.preventDefault() + submitDraft() + }} + > + { + setDraft(event.target.value.replace(/\D/g, '')) + setError(null) + }} + /> + +
+ {error &&
{error}
} +
+ )} +
+ ) +} diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx new file mode 100644 index 0000000..d25e537 --- /dev/null +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx @@ -0,0 +1,50 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {render, screen, waitFor} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {expect, test, vi} from 'vitest' +import {ApiGatewayRoutePanel} from './ApiGatewayRoutePanel' +import type {CloudResource} from '@/types/resource' + +const apiMocks = vi.hoisted(() => ({ + createApiGatewayLambdaRoute: vi.fn(), + listCloudResources: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => apiMocks) + +const api: CloudResource = { + id: 'api-123', name: 'orders', cloud: 'aws', service: 'apigateway', type: 'rest-api', + region: null, createdAt: null, metadata: {}, +} + +const lambda: CloudResource = { + id: 'orders-handler', name: 'orders-handler', cloud: 'aws', service: 'serverless', type: 'lambda', + region: null, createdAt: null, metadata: {}, +} + +test('creates and displays a deployed Lambda proxy route', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({ + resourceId: 'resource-123', deploymentId: 'deployment-123', invokeUrl: 'http://localhost:4566/execute-api/api-123/dev/orders', + }) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render( + + + , + ) + + await user.clear(screen.getByPlaceholderText('/orders')) + await user.type(screen.getByPlaceholderText('/orders'), '/orders') + await user.selectOptions(screen.getAllByRole('combobox')[1]!, 'orders-handler') + await user.click(screen.getByRole('button', {name: 'Create and deploy route'})) + + await waitFor(() => { + expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', { + path: '/orders', method: 'GET', stage: 'dev', functionName: 'orders-handler', + }) + }) + expect(await screen.findByText('Route deployed')).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx new file mode 100644 index 0000000..a53c186 --- /dev/null +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -0,0 +1,104 @@ +import {useEffect, useState} from 'react' +import {Link2, Loader2, Rocket} from 'lucide-react' +import {useMutation, useQuery} from '@tanstack/react-query' +import {createApiGatewayLambdaRoute, listCloudResources} from '@/api/cloudProxyClient' +import type {CloudProvider} from '@/types/cloud' +import type {CloudResource} from '@/types/resource' + +interface ApiGatewayRoutePanelProps { + cloud: CloudProvider + resource?: CloudResource + runtimeReachable: boolean +} + +export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) { + const [path, setPath] = useState('/hello') + const [method, setMethod] = useState('GET') + const [stage, setStage] = useState('dev') + const [functionName, setFunctionName] = useState('') + const functionsQuery = useQuery({ + queryKey: ['cloud-resources', cloud, 'serverless'], + queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal), + enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setPath('/hello') + setMethod('GET') + setStage('dev') + setFunctionName('') + }, [resource?.id]) + + const routeMutation = useMutation({ + mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}), + }) + + if (!resource || resource.service !== 'apigateway') { + return ( +
+
+

Select a REST API

+

Select an API Gateway REST API to connect a route to a Lambda function.

+
+
+ ) + } + + const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? [] + const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending + + return ( +
+
+
+

REST API Route

+

Connect Lambda

+

Creates and deploys one Lambda proxy route to the selected REST API.

+
+
+
+
{ + event.preventDefault() + if (canCreate) routeMutation.mutate() + }}> + + + + + +
+ {routeMutation.isError &&

{routeMutation.error instanceof Error ? routeMutation.error.message : 'Unable to create API route.'}

} + {routeMutation.data && ( +
+ Route deployed + {routeMutation.data.invokeUrl} +
+ )} +
+
+ ) +} diff --git a/packages/frontend/src/components/AppConfigPanel.test.tsx b/packages/frontend/src/components/AppConfigPanel.test.tsx new file mode 100644 index 0000000..fe510e2 --- /dev/null +++ b/packages/frontend/src/components/AppConfigPanel.test.tsx @@ -0,0 +1,90 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {act, render, screen, waitFor} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {AppConfigPanel} from './AppConfigPanel' +import {DEFAULT_ACCOUNT_ID, getAccountId, setAccountId} from '@/lib/accountStore' +import type {CloudResource} from '@/types/resource' + +const cloudProxyMocks = vi.hoisted(() => ({ + listAppConfigEnvironments: vi.fn(), + listAppConfigConfigurationProfiles: vi.fn(), + listAppConfigDeploymentStrategies: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => ({ + createAppConfigConfigurationProfile: vi.fn(), + createAppConfigDeploymentStrategy: vi.fn(), + createAppConfigEnvironment: vi.fn(), + createAppConfigHostedConfigurationVersion: vi.fn(), + deleteAppConfigConfigurationProfile: vi.fn(), + deleteAppConfigDeploymentStrategy: vi.fn(), + deleteAppConfigEnvironment: vi.fn(), + deleteAppConfigHostedConfigurationVersion: vi.fn(), + getAppConfigDeployment: vi.fn(), + getAppConfigHostedConfigurationVersion: vi.fn(), + listAppConfigConfigurationProfiles: cloudProxyMocks.listAppConfigConfigurationProfiles, + listAppConfigDeploymentStrategies: cloudProxyMocks.listAppConfigDeploymentStrategies, + listAppConfigEnvironments: cloudProxyMocks.listAppConfigEnvironments, + listAppConfigHostedConfigurationVersions: vi.fn(), + startAppConfigDeployment: vi.fn(), +})) + +const application: CloudResource = { + id: 'app-1', + name: 'orders', + cloud: 'aws', + service: 'configuration', + type: 'appconfig-application', + region: 'us-east-1', + createdAt: null, + metadata: {}, +} + +describe('AppConfigPanel account isolation', () => { + beforeEach(() => { + setAccountId(DEFAULT_ACCOUNT_ID) + cloudProxyMocks.listAppConfigEnvironments.mockReset() + cloudProxyMocks.listAppConfigConfigurationProfiles.mockReset() + cloudProxyMocks.listAppConfigDeploymentStrategies.mockReset() + cloudProxyMocks.listAppConfigConfigurationProfiles.mockResolvedValue([]) + cloudProxyMocks.listAppConfigDeploymentStrategies.mockResolvedValue([]) + cloudProxyMocks.listAppConfigEnvironments.mockImplementation(async () => [{ + id: `environment-${getAccountId()}`, + applicationId: application.id, + name: `environment-${getAccountId()}`, + description: null, + state: 'READY_FOR_DEPLOYMENT', + }]) + }) + + test('loads a fresh cache and resets account-scoped form state after an account switch', async () => { + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + const user = userEvent.setup() + + render( + + + , + ) + + expect(await screen.findAllByText(`environment-${DEFAULT_ACCOUNT_ID}`)).toHaveLength(2) + const environmentName = screen.getByPlaceholderText('Environment name') + await user.type(environmentName, 'private-draft') + expect(environmentName).toHaveValue('private-draft') + + await act(async () => { + setAccountId('111111111111') + }) + + expect(await screen.findAllByText('environment-111111111111')).toHaveLength(2) + expect(screen.queryAllByText(`environment-${DEFAULT_ACCOUNT_ID}`)).toHaveLength(0) + expect(screen.getByPlaceholderText('Environment name')).toHaveValue('') + + await waitFor(() => { + expect(cloudProxyMocks.listAppConfigEnvironments).toHaveBeenCalledTimes(2) + }) + expect(queryClient.getQueryData(['appconfig-environments', DEFAULT_ACCOUNT_ID, 'aws', application.id])).toBeDefined() + expect(queryClient.getQueryData(['appconfig-environments', '111111111111', 'aws', application.id])).toBeDefined() + }) +}) diff --git a/packages/frontend/src/components/AppConfigPanel.tsx b/packages/frontend/src/components/AppConfigPanel.tsx new file mode 100644 index 0000000..16c753b --- /dev/null +++ b/packages/frontend/src/components/AppConfigPanel.tsx @@ -0,0 +1,548 @@ +import {FormEvent, useEffect, useMemo, useState} from 'react' +import {Plus, RefreshCw, Rocket, SlidersHorizontal, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import { + createAppConfigConfigurationProfile, + createAppConfigDeploymentStrategy, + createAppConfigEnvironment, + createAppConfigHostedConfigurationVersion, + deleteAppConfigConfigurationProfile, + deleteAppConfigDeploymentStrategy, + deleteAppConfigEnvironment, + deleteAppConfigHostedConfigurationVersion, + getAppConfigDeployment, + getAppConfigHostedConfigurationVersion, + listAppConfigConfigurationProfiles, + listAppConfigDeploymentStrategies, + listAppConfigEnvironments, + listAppConfigHostedConfigurationVersions, + startAppConfigDeployment, +} from '@/api/cloudProxyClient' +import type {CloudProvider} from '@/types/cloud' +import type {AppConfigDeployment, CloudResource} from '@/types/resource' +import {useAccountId} from '@/lib/accountStore' + +const TERMINAL_DEPLOYMENT_STATES = new Set(['COMPLETE', 'ROLLED_BACK', 'STOPPED']) +const PROFILE_TYPES = ['AWS.Freeform', 'AWS.AppConfig.FeatureFlags'] + +interface AppConfigPanelProps { + cloud: CloudProvider + resource?: CloudResource + runtimeReachable: boolean +} + +export function AppConfigPanel({cloud, resource, runtimeReachable}: AppConfigPanelProps) { + const accountId = useAccountId() + + return ( + + ) +} + +interface AccountScopedAppConfigPanelProps extends AppConfigPanelProps { + accountId: string +} + +function AccountScopedAppConfigPanel({accountId, cloud, resource, runtimeReachable}: AccountScopedAppConfigPanelProps) { + const qc = useQueryClient() + const applicationId = resource?.id + const [selectedProfileId, setSelectedProfileId] = useState() + const [selectedVersionNumber, setSelectedVersionNumber] = useState() + const [environmentName, setEnvironmentName] = useState('') + const [profileName, setProfileName] = useState('') + const [profileType, setProfileType] = useState(PROFILE_TYPES[0]) + const [versionContent, setVersionContent] = useState('') + const [versionContentType, setVersionContentType] = useState('application/json') + const [versionDescription, setVersionDescription] = useState('') + const [strategyName, setStrategyName] = useState('') + const [strategyBake, setStrategyBake] = useState('') + const [deployEnvironmentId, setDeployEnvironmentId] = useState('') + const [deployProfileId, setDeployProfileId] = useState('') + const [deployVersion, setDeployVersion] = useState('') + const [deployStrategyId, setDeployStrategyId] = useState('') + const [confirmProfile, setConfirmProfile] = useState(null) + const [confirmVersion, setConfirmVersion] = useState(null) + const [confirmStrategy, setConfirmStrategy] = useState(null) + const [confirmEnvironment, setConfirmEnvironment] = useState(null) + const [activeDeployment, setActiveDeployment] = useState() + + const environmentsKey = useMemo( + () => ['appconfig-environments', accountId, cloud, applicationId], + [accountId, cloud, applicationId], + ) + const profilesKey = useMemo( + () => ['appconfig-profiles', accountId, cloud, applicationId], + [accountId, cloud, applicationId], + ) + const versionsKey = useMemo( + () => ['appconfig-versions', accountId, cloud, applicationId, selectedProfileId], + [accountId, cloud, applicationId, selectedProfileId], + ) + + const environmentsQuery = useQuery({ + queryKey: environmentsKey, + queryFn: ({signal}) => listAppConfigEnvironments(cloud, applicationId ?? '', signal), + enabled: Boolean(applicationId) && runtimeReachable, + }) + const profilesQuery = useQuery({ + queryKey: profilesKey, + queryFn: ({signal}) => listAppConfigConfigurationProfiles(cloud, applicationId ?? '', signal), + enabled: Boolean(applicationId) && runtimeReachable, + }) + const versionsQuery = useQuery({ + queryKey: versionsKey, + queryFn: ({signal}) => listAppConfigHostedConfigurationVersions(cloud, applicationId ?? '', selectedProfileId ?? '', signal), + enabled: Boolean(applicationId && selectedProfileId) && runtimeReachable, + }) + const deployVersionsQuery = useQuery({ + queryKey: ['appconfig-versions', accountId, cloud, applicationId, deployProfileId], + queryFn: ({signal}) => listAppConfigHostedConfigurationVersions(cloud, applicationId ?? '', deployProfileId ?? '', signal), + enabled: Boolean(applicationId && deployProfileId) && runtimeReachable, + }) + const strategiesQuery = useQuery({ + queryKey: ['appconfig-strategies', accountId, cloud], + queryFn: ({signal}) => listAppConfigDeploymentStrategies(cloud, signal), + enabled: runtimeReachable, + }) + const versionContentQuery = useQuery({ + queryKey: ['appconfig-version-content', accountId, cloud, applicationId, selectedProfileId, selectedVersionNumber], + queryFn: ({signal}) => getAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', selectedVersionNumber ?? 0, signal), + enabled: Boolean(applicationId && selectedProfileId && selectedVersionNumber) && runtimeReachable, + }) + const deploymentQuery = useQuery({ + queryKey: ['appconfig-deployment', accountId, cloud, applicationId, activeDeployment?.environmentId, activeDeployment?.deploymentNumber], + queryFn: ({signal}) => getAppConfigDeployment(cloud, applicationId ?? '', activeDeployment?.environmentId ?? '', activeDeployment?.deploymentNumber ?? 0, signal), + enabled: Boolean(applicationId && activeDeployment) && runtimeReachable, + refetchInterval: (query) => (TERMINAL_DEPLOYMENT_STATES.has(query.state.data?.state ?? '') ? false : 2000), + }) + + const createEnvironmentMut = useMutation({ + mutationFn: () => createAppConfigEnvironment(cloud, applicationId ?? '', {name: environmentName}), + onSuccess: () => { + setEnvironmentName('') + void qc.invalidateQueries({queryKey: environmentsKey}) + }, + }) + const deleteEnvironmentMut = useMutation({ + mutationFn: (environmentId: string) => deleteAppConfigEnvironment(cloud, applicationId ?? '', environmentId), + onSuccess: (_, environmentId) => { + if (deployEnvironmentId === environmentId) setDeployEnvironmentId('') + if (activeDeployment?.environmentId === environmentId) setActiveDeployment(undefined) + setConfirmEnvironment(null) + void qc.invalidateQueries({queryKey: environmentsKey}) + }, + }) + const createProfileMut = useMutation({ + mutationFn: () => createAppConfigConfigurationProfile(cloud, applicationId ?? '', { + name: profileName, + locationUri: 'hosted', + type: profileType, + }), + onSuccess: (profile) => { + setProfileName('') + setSelectedProfileId(profile.id) + void qc.invalidateQueries({queryKey: profilesKey}) + }, + }) + const deleteProfileMut = useMutation({ + mutationFn: (profileId: string) => deleteAppConfigConfigurationProfile(cloud, applicationId ?? '', profileId), + onSuccess: (_, profileId) => { + if (selectedProfileId === profileId) setSelectedProfileId(undefined) + if (deployProfileId === profileId) { + setDeployProfileId('') + setDeployVersion('') + } + setConfirmProfile(null) + void qc.invalidateQueries({queryKey: profilesKey}) + }, + }) + const createVersionMut = useMutation({ + mutationFn: () => createAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', { + content: versionContent, + contentType: versionContentType, + ...(versionDescription.trim() ? {description: versionDescription} : {}), + }), + onSuccess: (version) => { + setSelectedVersionNumber(version.versionNumber) + setVersionContent('') + setVersionDescription('') + void qc.invalidateQueries({queryKey: versionsKey}) + void qc.invalidateQueries({queryKey: ['appconfig-versions', accountId, cloud, applicationId, deployProfileId]}) + }, + }) + const deleteVersionMut = useMutation({ + mutationFn: (versionNumber: number) => deleteAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', versionNumber), + onSuccess: (_, versionNumber) => { + if (selectedVersionNumber === versionNumber) setSelectedVersionNumber(undefined) + if (deployVersion === String(versionNumber)) setDeployVersion('') + setConfirmVersion(null) + void qc.invalidateQueries({queryKey: versionsKey}) + }, + }) + const createStrategyMut = useMutation({ + mutationFn: () => createAppConfigDeploymentStrategy(cloud, { + name: strategyName, + deploymentDurationInMinutes: 0, + growthFactor: 100, + ...(strategyBake.trim() ? {finalBakeTimeInMinutes: Number(strategyBake)} : {}), + }), + onSuccess: () => { + setStrategyName('') + setStrategyBake('') + void qc.invalidateQueries({queryKey: ['appconfig-strategies', accountId, cloud]}) + }, + }) + const deleteStrategyMut = useMutation({ + mutationFn: (strategyId: string) => deleteAppConfigDeploymentStrategy(cloud, strategyId), + onSuccess: (_, strategyId) => { + if (deployStrategyId === strategyId) setDeployStrategyId('') + setConfirmStrategy(null) + void qc.invalidateQueries({queryKey: ['appconfig-strategies', accountId, cloud]}) + }, + }) + const startDeploymentMut = useMutation({ + mutationFn: () => startAppConfigDeployment(cloud, applicationId ?? '', deployEnvironmentId, { + configurationProfileId: deployProfileId, + configurationVersion: deployVersion, + deploymentStrategyId: deployStrategyId, + }), + onSuccess: (deployment) => setActiveDeployment(deployment), + }) + + useEffect(() => { + setSelectedProfileId(undefined) + setSelectedVersionNumber(undefined) + setActiveDeployment(undefined) + setDeployEnvironmentId('') + setDeployProfileId('') + setDeployVersion('') + setDeployStrategyId('') + }, [cloud, applicationId]) + + useEffect(() => { + setSelectedVersionNumber(undefined) + }, [selectedProfileId]) + + if (cloud !== 'aws') return null + + if (!applicationId) { + return ( +
+
+

Select an application

+

Environments, configuration profiles, hosted versions, and deployments are loaded after an application is selected.

+
+
+ ) + } + + const environments = environmentsQuery.data ?? [] + const profiles = profilesQuery.data ?? [] + const versions = versionsQuery.data ?? [] + const strategies = strategiesQuery.data ?? [] + const deployment = deploymentQuery.data ?? activeDeployment + const canDeploy = Boolean(deployEnvironmentId && deployProfileId && deployVersion && deployStrategyId) && runtimeReachable + + function submitEnvironment(event: FormEvent) { + event.preventDefault() + if (environmentName.trim()) createEnvironmentMut.mutate() + } + + function submitProfile(event: FormEvent) { + event.preventDefault() + if (profileName.trim()) createProfileMut.mutate() + } + + function submitVersion(event: FormEvent) { + event.preventDefault() + if (versionContent.trim()) createVersionMut.mutate() + } + + function submitStrategy(event: FormEvent) { + event.preventDefault() + if (strategyName.trim()) createStrategyMut.mutate() + } + + return ( +
+
+ +
+ setEnvironmentName(event.target.value)} placeholder="Environment name" disabled={!runtimeReachable}/> + +
+ {createEnvironmentMut.error instanceof Error &&
{createEnvironmentMut.error.message}
} + {environmentsQuery.error instanceof Error &&
{environmentsQuery.error.message}
} + {deleteEnvironmentMut.error instanceof Error &&
{deleteEnvironmentMut.error.message}
} +
+ {environmentsQuery.isLoading &&
Loading environments
} + {!environmentsQuery.isLoading && environments.length === 0 &&
No environments
} + {environments.map((environment) => ( + + ))} +
+
+ +
+ +
+ setProfileName(event.target.value)} placeholder="Profile name" disabled={!runtimeReachable}/> + + +
+ {createProfileMut.error instanceof Error &&
{createProfileMut.error.message}
} + {profilesQuery.error instanceof Error &&
{profilesQuery.error.message}
} + {deleteProfileMut.error instanceof Error &&
{deleteProfileMut.error.message}
} +
+ {profilesQuery.isLoading &&
Loading profiles
} + {!profilesQuery.isLoading && profiles.length === 0 &&
No configuration profiles
} + {profiles.map((profile) => ( + + ))} +
+
+ +
+ profile.id === selectedProfileId)?.name ?? 'Select profile'} + detail={`${versions.length} versions`} + /> +
+ setVersionContentType(event.target.value)} placeholder="Content type" disabled={!runtimeReachable}/> + setVersionDescription(event.target.value)} placeholder="Description (optional)" disabled={!runtimeReachable}/> + +
+