first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
packages/*/node_modules
local
data
+11
View File
@@ -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
+5
View File
@@ -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
+75
View File
@@ -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/
+35
View File
@@ -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"
]
}
+220
View File
@@ -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/<service>Schema.ts` — export a per-cloud `<cloud><Service>Schema()`
returning a `ServiceSchema`. Model: `src/cloud-spi/storageSchema.ts`. Use `path` on a
column to surface a `metadata.*` field.
3. `src/adapter-<cloud>/<Cloud><Service>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 === '<x>'` 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.
+130
View File
@@ -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
+233
View File
@@ -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
```
<type>[optional scope]: <description>
```
- **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).
+21
View File
@@ -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.
+15
View File
@@ -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)
+41
View File
@@ -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
+602
View File
@@ -0,0 +1,602 @@
<p align="center">
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
</p>
<p align="center">
<strong>Any Cloud. Locally.</strong><br />
A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators.
</p>
<p align="center">
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
</p>
<p align="center">
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
</p>
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.
<p align="center">
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
</p>
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
<details>
<summary><strong>Storage</strong></summary>
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.
</details>
<details>
<summary><strong>k8s Engine</strong></summary>
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`.
</details>
<details>
<summary><strong>Database</strong></summary>
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.
</details>
<details>
<summary><strong>Compute</strong></summary>
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.
</details>
<details>
<summary><strong>Networking</strong></summary>
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.
</details>
<details>
<summary><strong>Identity</strong></summary>
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.
</details>
<details>
<summary><strong>API Gateway</strong></summary>
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.
</details>
<details>
<summary><strong>Email / SES Mailbox</strong></summary>
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="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",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.
</details>
<details>
<summary><strong>Serverless</strong></summary>
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.
</details>
<details>
<summary><strong>Containers</strong></summary>
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.
</details>
<details>
<summary><strong>Secrets Manager</strong></summary>
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.
</details>
## 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.
+1963
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -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
+25
View File
@@ -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"]
+23
View File
@@ -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"]
+200
View File
@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
<defs>
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<style>
.st0 {
fill: #151515;
}
.st1 {
fill: #5559A7;
}
.st2 {
isolation: isolate;
}
.st3 {
fill: #151515;
}
.st4 {
fill: #262425;
}
#logoBuild {
transform-box: fill-box;
transform-origin: center;
animation: logoBreath 5s ease-in-out 2.7s infinite;
}
.logo-piece {
opacity: 0;
animation: reveal .5s ease forwards;
}
.cloud-a { animation-delay: .55s; }
.cloud-b { animation-delay: .75s; }
.word-a { animation-delay: 1.72s; }
.word-b { animation-delay: 1.92s; }
.word-c { animation-delay: 2.12s; }
.word-d { animation-delay: 2.32s; }
.guide-path {
fill: none;
opacity: 1;
stroke-opacity: .9;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
vector-effect: non-scaling-stroke;
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
guideFade .42s ease forwards;
}
.guide-dark {
stroke: #151515;
stroke-width: 2.6;
}
.guide-word {
stroke: #262425;
stroke-width: 2;
}
.guide-cloud-a { animation-delay: .08s, 1.25s; }
.guide-cloud-b { animation-delay: .24s, 1.45s; }
.guide-word-a { animation-delay: 1.18s, 2.28s; }
.guide-word-b { animation-delay: 1.36s, 2.48s; }
.guide-word-c { animation-delay: 1.54s, 2.68s; }
.guide-word-d { animation-delay: 1.72s, 2.88s; }
.dot-pop {
opacity: 0;
filter: url(#logoGlow);
transform: scale(.15);
transform-box: fill-box;
transform-origin: center;
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
softPulse 3.2s ease-in-out 2.2s infinite;
}
.dot-cloud { animation-delay: 1.45s, 3.1s; }
.dot-i { animation-delay: 2.62s, 3.45s; }
.cloud-shadow {
opacity: 0;
animation: shadowIn .8s ease 1.2s forwards;
}
.shine {
opacity: 0;
mix-blend-mode: screen;
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
@keyframes guideFade {
from { opacity: 1; stroke-opacity: .9; }
to { opacity: 0; stroke-opacity: 0; }
}
@keyframes reveal {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dotPop {
0% { opacity: 0; transform: scale(.15); }
62% { opacity: 1; transform: scale(1.18); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes softPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes shadowIn {
from { opacity: 0; transform: translateY(4px) scale(.94); }
to { opacity: .14; transform: translateY(0) scale(1); }
}
@keyframes shineSweep {
0%, 18% { opacity: 0; transform: translateX(-220px); }
30% { opacity: .7; }
54% { opacity: 0; transform: translateX(560px); }
100% { opacity: 0; transform: translateX(560px); }
}
@keyframes logoBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.008); }
}
@media (prefers-reduced-motion: reduce) {
#logoBuild,
#logoBuild *,
.shine {
animation: none !important;
opacity: 1;
stroke-dashoffset: 0;
transform: none;
}
.guide-path {
opacity: 0;
}
.shine {
opacity: 0;
}
}
</style>
</defs>
<g id="logoBuild">
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
<g id="finalWordmark" class="st2">
<g class="st2">
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
</g>
<g id="finalMark">
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
</g>
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
<g id="drawGuides">
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+200
View File
@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
<defs>
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<style>
.st0 {
fill: #FFFFFF;
}
.st1 {
fill: #5559A7;
}
.st2 {
isolation: isolate;
}
.st3 {
fill: #FFFFFF;
}
.st4 {
fill: #FFFFFF;
}
#logoBuild {
transform-box: fill-box;
transform-origin: center;
animation: logoBreath 5s ease-in-out 2.7s infinite;
}
.logo-piece {
opacity: 0;
animation: reveal .5s ease forwards;
}
.cloud-a { animation-delay: .55s; }
.cloud-b { animation-delay: .75s; }
.word-a { animation-delay: 1.72s; }
.word-b { animation-delay: 1.92s; }
.word-c { animation-delay: 2.12s; }
.word-d { animation-delay: 2.32s; }
.guide-path {
fill: none;
opacity: 1;
stroke-opacity: .9;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
vector-effect: non-scaling-stroke;
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
guideFade .42s ease forwards;
}
.guide-dark {
stroke: #FFFFFF;
stroke-width: 2.6;
}
.guide-word {
stroke: #FFFFFF;
stroke-width: 2;
}
.guide-cloud-a { animation-delay: .08s, 1.25s; }
.guide-cloud-b { animation-delay: .24s, 1.45s; }
.guide-word-a { animation-delay: 1.18s, 2.28s; }
.guide-word-b { animation-delay: 1.36s, 2.48s; }
.guide-word-c { animation-delay: 1.54s, 2.68s; }
.guide-word-d { animation-delay: 1.72s, 2.88s; }
.dot-pop {
opacity: 0;
filter: url(#logoGlow);
transform: scale(.15);
transform-box: fill-box;
transform-origin: center;
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
softPulse 3.2s ease-in-out 2.2s infinite;
}
.dot-cloud { animation-delay: 1.45s, 3.1s; }
.dot-i { animation-delay: 2.62s, 3.45s; }
.cloud-shadow {
opacity: 0;
animation: shadowIn .8s ease 1.2s forwards;
}
.shine {
opacity: 0;
mix-blend-mode: screen;
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
@keyframes guideFade {
from { opacity: 1; stroke-opacity: .9; }
to { opacity: 0; stroke-opacity: 0; }
}
@keyframes reveal {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dotPop {
0% { opacity: 0; transform: scale(.15); }
62% { opacity: 1; transform: scale(1.18); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes softPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes shadowIn {
from { opacity: 0; transform: translateY(4px) scale(.94); }
to { opacity: .14; transform: translateY(0) scale(1); }
}
@keyframes shineSweep {
0%, 18% { opacity: 0; transform: translateX(-220px); }
30% { opacity: .7; }
54% { opacity: 0; transform: translateX(560px); }
100% { opacity: 0; transform: translateX(560px); }
}
@keyframes logoBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.008); }
}
@media (prefers-reduced-motion: reduce) {
#logoBuild,
#logoBuild *,
.shine {
animation: none !important;
opacity: 1;
stroke-dashoffset: 0;
transform: none;
}
.guide-path {
opacity: 0;
}
.shine {
opacity: 0;
}
}
</style>
</defs>
<g id="logoBuild">
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
<g id="finalWordmark" class="st2">
<g class="st2">
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
</g>
<g id="finalMark">
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
</g>
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
<g id="drawGuides">
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

+58
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
FROM alpine:3.22
RUN apk add --no-cache curl
+9
View File
@@ -0,0 +1,9 @@
{
"app": "floci-demo",
"version": "1.0.0",
"environment": "local",
"features": {
"notifications": true,
"logging": true
}
}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Floci Static Assets</title>
</head>
<body>
<h1>Hello from Floci S3</h1>
</body>
</html>
+23
View File
@@ -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 ==="
+118
View File
@@ -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 ==="
+20
View File
@@ -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'"
}
}
+12
View File
@@ -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"]
+5
View File
@@ -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"]
+44
View File
@@ -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"
}
}
+58
View File
@@ -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<CloudProvider, string> = {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'))
}
@@ -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'],
})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
}
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
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
}
@@ -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<string, () => 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',
})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.appConfig.send(new DeleteApplicationCommand({ApplicationId: id}))
}
async listAppConfigEnvironments(applicationId: string): Promise<AppConfigEnvironment[]> {
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<AppConfigEnvironment> {
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<void> {
await this.appConfig.send(new DeleteEnvironmentCommand({ApplicationId: applicationId, EnvironmentId: environmentId}))
}
async listAppConfigConfigurationProfiles(applicationId: string): Promise<AppConfigConfigurationProfile[]> {
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<AppConfigConfigurationProfile> {
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<void> {
await this.appConfig.send(new DeleteConfigurationProfileCommand({ApplicationId: applicationId, ConfigurationProfileId: profileId}))
}
async listAppConfigHostedConfigurationVersions(applicationId: string, profileId: string): Promise<AppConfigHostedConfigurationVersion[]> {
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<AppConfigHostedConfigurationVersion | null> {
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<AppConfigHostedConfigurationVersion> {
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<void> {
await this.appConfig.send(new DeleteHostedConfigurationVersionCommand({
ApplicationId: applicationId,
ConfigurationProfileId: profileId,
VersionNumber: versionNumber,
}))
}
async listAppConfigDeploymentStrategies(): Promise<AppConfigDeploymentStrategy[]> {
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<AppConfigDeploymentStrategy> {
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<void> {
await this.appConfig.send(new DeleteDeploymentStrategyCommand({DeploymentStrategyId: strategyId}))
}
async startAppConfigDeployment(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment> {
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<AppConfigDeployment | null> {
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<void> {
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
}
@@ -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'],
})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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)
}
@@ -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<Ec2Instance[]>
describeInstance: (id: string) => Promise<Ec2Instance>
terminateInstance: (id: string) => Promise<void>
listAmis: () => Promise<[]>
runInstance: (input: RunInstanceInput) => Promise<Ec2Instance>
}> = {}) {
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')
})
})
@@ -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<Ec2Instance[]>
describeInstance(instanceId: string): Promise<Ec2Instance>
terminateInstance(instanceId: string): Promise<void>
listAmis(): Promise<Ec2Image[]>
runInstance(input: RunInstanceInput): Promise<Ec2Instance>
}
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<CloudResource[]> {
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<CloudResource | null> {
try {
return instanceToResource(await this.service_.describeInstance(id))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
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
}
@@ -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<unknown>
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)
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<CloudResource> {
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<void> {
await this.rdsService_.deleteInstance(id)
}
async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> {
const normalizedIdentifier = stringValue(instanceIdentifier) || undefined
return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot)
}
async createDatabaseSnapshot(input: CreateDatabaseSnapshotInput): Promise<DatabaseSnapshot> {
const sourceIdentifier = instanceIdentifier(input.instanceIdentifier)
const snapshotIdentifier = databaseSnapshotIdentifier(input.snapshotIdentifier)
return snapshotToDatabaseSnapshot(
await this.rdsService_.createSnapshot(sourceIdentifier, snapshotIdentifier),
)
}
async listDatabaseOrderableInstanceClasses(engine?: string): Promise<string[]> {
const normalizedEngine = stringValue(engine) || 'postgres'
return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine)
}
private async toResource(instance: RdsInstance): Promise<CloudResource> {
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<Array<{key: string; value: string}>> {
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
}
@@ -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<unknown>): 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'],
})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
try {
return await this.describe(id)
} catch (error) {
if (isResourceNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
await this.dynamodb.send(new DeleteTableCommand({TableName: id}))
}
async listNoSqlItems(resourceId: string): Promise<NoSqlItem[]> {
const table = await this.describeTable(resourceId)
const keyNames = keyAttributes(table).map(({name}) => name)
const items: NoSqlItem[] = []
let exclusiveStartKey: Record<string, AttributeValue> | 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<string, unknown>): Promise<NoSqlItem> {
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<string[]> {
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<CloudResource> {
return toResource(await this.describeTable(tableName))
}
private async describeTable(tableName: string): Promise<TableDescription> {
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<string, unknown>, keys: KeyAttribute[]): Record<string, AttributeValue> {
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<string, AttributeValue>
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<string, unknown>): Record<string, unknown> {
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<string, unknown>)
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
}
@@ -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')
})
})
@@ -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<CloudResource[]> {
const clusters = await this.eks.listClusters()
return filterBySearch(clusters.map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
try {
return toResource(await this.eks.describeCluster(id))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('EKS cluster creation is not supported from the dynamic Cloud Explorer.')
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('EKS cluster deletion is not supported from the dynamic Cloud Explorer.')
}
async listKubernetesNodegroups(clusterId: string): Promise<KubernetesNodegroup[]> {
return (await this.eks.listNodegroups(clusterId)).map(toNodegroup)
}
async createKubernetesNodegroup(
clusterId: string,
input: CreateKubernetesNodegroupInput,
): Promise<KubernetesNodegroup> {
validateNodegroupInput(input)
return toNodegroup(await this.eks.createNodegroup(clusterId, input))
}
async deleteKubernetesNodegroup(clusterId: string, nodegroupId: string): Promise<void> {
await this.eks.deleteNodegroup(clusterId, nodegroupId)
}
async listKubernetesFargateProfiles(clusterId: string): Promise<KubernetesFargateProfile[]> {
return (await this.eks.listFargateProfiles(clusterId)).map(toFargateProfile)
}
async createKubernetesFargateProfile(
clusterId: string,
input: CreateKubernetesFargateProfileInput,
): Promise<KubernetesFargateProfile> {
validateFargateProfileInput(input)
return toFargateProfile(await this.eks.createFargateProfile(clusterId, input))
}
async deleteKubernetesFargateProfile(clusterId: string, profileId: string): Promise<void> {
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')
}
}
@@ -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<unknown>
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<string | undefined> = []
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)
}
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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
}
@@ -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'],
})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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'
}
@@ -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<string, unknown>
/** Minimal KMSClient stub that records the commands it was sent. */
function stubKms(handler: (command: object) => SendResult | Promise<SendResult>) {
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<string, SendResult> = {}) {
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)
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
return this.describe(id)
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
await this.kms.send(
new ScheduleKeyDeletionCommand({KeyId: id, PendingWindowInDays: PENDING_DELETION_WINDOW_DAYS}),
)
}
async updateTags(id: string, tags: Record<string, string | null>): Promise<void> {
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<KmsEncryptResult> {
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<KmsDecryptResult> {
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<string[]> {
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<KeyMetadata> {
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<CloudResource | null> {
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<TagLookup> {
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<string, string> | 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/<uuid>` — 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<T extends readonly string[]>(
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
}
@@ -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<string, unknown>
function stubElb(handler: (command: object) => SendResult | Promise<SendResult>) {
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: <State><Code>provisioning</Code></State>
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 <NextMarker></NextMarker>, 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 <Code> 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)
})
})
@@ -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** (`<State><Code>provisioning</Code></State>`). 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 `<NextMarker></NextMarker>` 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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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 `<Code>` 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<T extends readonly string[]>(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]
}
@@ -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<string, (input: Record<string, unknown>) => unknown>) {
const calls: Array<{command: string; input: Record<string, unknown>}> = []
const client = {
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
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()
})
})
@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function toRow(fields: Array<{field?: string; value?: string}>): Record<string, string> {
const row: Record<string, string> = {}
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<CloudResource[]> {
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<CloudResource | null> {
const group = await this.describeExact(id)
return group ? toResource(group) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
await this.client.send(new DeleteLogGroupCommand({logGroupName: id}))
}
async queryLogs(logGroupName: string, input: LogsInsightsQueryInput): Promise<LogsInsightsQueryResult> {
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<LogGroupShape | null> {
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<CollectionPage<ChildCollection>> {
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<ChildCollection> {
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<void> {
await this.client.send(new DeleteLogStreamCommand({logGroupName: resourceId, logStreamName: collectionId}))
}
async listItems(resourceId: string, collectionId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
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,
},
}
}
@@ -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<Ec2Vpc[]>
}
/**
* 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<CloudResource[]> {
const vpcs = await this.service_.listVpcs()
return filterBySearch(vpcs.map(vpcToResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
// 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<CloudResource> {
throw new NotSupportedError('Use the Networking panel to create VPCs and networking resources.')
}
async delete(_id: string): Promise<void> {
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))
}
@@ -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<string, unknown>
/** Minimal SSMClient stub that records the commands it was sent. */
function stubSsm(handler: (command: object) => SendResult | Promise<SendResult>) {
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, unknown>, 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)
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.ssm.send(new DeleteParameterCommand({Name: id}))
}
async updateTags(id: string, tags: Record<string, string | null>): Promise<void> {
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<ParameterMetadata[]> {
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<ParameterMetadata[]> {
const res = await this.ssm.send(new DescribeParametersCommand(input))
return res.Parameters ?? []
}
private async toResource(parameter: ParameterMetadata): Promise<CloudResource> {
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<TagLookup> {
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<T extends readonly string[]>(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]
}
@@ -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)
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
// 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
}
@@ -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<string, unknown>
/** Minimal LambdaClient stub that records the commands it was sent. */
function stubLambda(handler: (command: object) => SendResult | Promise<SendResult>) {
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, unknown>, 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('')
})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id }));
}
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
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;
}
@@ -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: '<strong>Hello</strong>'},
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: '<strong>Hello</strong>',
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'],
})
})
})
@@ -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<Response>
/**
* 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<CloudResource[]> {
const messages = await this.fetchInbox()
const resources = messages.map(messageToResource)
return filterBySearch(resources, query.search)
}
async get(id: string): Promise<CloudResource | null> {
const messages = await this.fetchInbox(id)
const message = messages.find((entry) => entry.Id === id)
return message ? messageToResource(message) : null
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('Sending email from the mailbox is not implemented yet')
}
async delete(_id: string): Promise<void> {
throw new NotSupportedError('Deleting individual captured emails is not supported by Floci SES')
}
async clearEmailInbox(): Promise<void> {
await this.request('/_aws/ses', {method: 'DELETE'})
}
private async fetchInbox(id?: string): Promise<FlociSesMessage[]> {
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<Response> {
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') : []
}
@@ -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<string, string>
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'})
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
const url = await this.queueUrl(id)
if (!url) return null
return toResource(id, url, await this.attributesFor(url))
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<string, string> = {}
// 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<void> {
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<void> {
await this.sqs.send(new ListQueuesCommand({}))
}
private async queueUrl(name: string): Promise<string | null> {
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<QueueAttributes> {
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'
}
@@ -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<string, unknown>
function stubSfn(handler: (command: object) => SendResult | Promise<SendResult>) {
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, unknown>, 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)
})
})
@@ -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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
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
}
@@ -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'])
})
})
@@ -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<CloudResource[]> {
const res = await this.s3.send(new ListBucketsCommand({}))
const buckets = filterBucketsBySearch(res.Buckets ?? [], query.search)
return Promise.all(buckets.map(async (bucket): Promise<CloudResource> => {
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<CloudResource | null> {
const resources = await this.list()
return resources.find((resource) => resource.id === id) ?? null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
await this.s3.send(new DeleteBucketCommand({Bucket: id}))
}
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
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<void> {
await this.s3.send(new PutObjectCommand({Bucket: resourceId, Key: key, Body: body, ContentType: contentType}))
}
async getObject(resourceId: string, key: string): Promise<StorageObjectDownload> {
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<void> {
await this.s3.send(new DeleteObjectCommand({Bucket: resourceId, Key: key}))
}
async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise<void> {
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)
}
@@ -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<string, unknown> = {}): 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)
})
})
+183
View File
@@ -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)
}
@@ -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<string>((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<string>((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')
})
})
+132
View File
@@ -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}`
}
@@ -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<string, string> | 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'])
})
})
@@ -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<T> {
value?: T[]
}
interface AgentPoolProfile {
name?: string
count?: number
vmSize?: string
osType?: string
mode?: string
}
interface AksCluster {
id?: string
name?: string
location?: string
tags?: Record<string, string>
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<CloudResource[]> {
const subscription = await this.subscription()
const body = await this.json<ArmList<AksCluster>>(
`/subscriptions/${subscription}/providers/Microsoft.ContainerService/managedClusters?api-version=${API_VERSION}`,
)
return filterBySearch((body?.value ?? []).map(toResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
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<AksCluster>(
`${path}?api-version=${API_VERSION}`,
{},
{emptyOnNotFound: true},
)
return cluster ? toResource(cluster) : null
}
async create(_input: CreateResourceInput): Promise<CloudResource> {
throw new NotSupportedError('AKS cluster creation is not supported from the dynamic Cloud Explorer.')
}
async delete(_id: string): Promise<void> {
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<string> {
if (this.subscriptionId) return this.subscriptionId
const body = await this.json<ArmList<{subscriptionId?: string}>>('/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<T>(path: string, init: RequestInit = {}, options = {}): Promise<T | null> {
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))
}
@@ -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<string, unknown> = {}) {
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, unknown>, 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}`)
})
}
})
})
@@ -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<T> {
value?: T[]
}
interface AzureVm {
id?: string
name?: string
location?: string
tags?: Record<string, string>
properties?: {
hardwareProfile?: {vmSize?: string}
storageProfile?: {
imageReference?: {publisher?: string; offer?: string; sku?: string; version?: string}
osDisk?: Record<string, unknown>
}
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<CloudResource[]> {
const subscription = await this.subscription()
const body = await this.json<ArmList<AzureVm>>(
`/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<CloudResource | null> {
const {resourceGroup, name} = parseId(id)
const path = await this.vmPath(resourceGroup, name)
const machine = await this.json<AzureVm>(`${path}?api-version=${API_VERSION}`, {}, {emptyOnNotFound: true})
if (!machine) return null
return toResource(machine, await this.powerState(machine.id ?? ''))
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<keyof typeof AZURE_VM_IMAGES>,
'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<void> {
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<void> {
await this.power(id, 'start')
}
/** ARM calls it powerOff; `deallocate` also exists but releases the compute. */
async stop(id: string): Promise<void> {
await this.power(id, 'powerOff')
}
async reboot(id: string): Promise<void> {
await this.power(id, 'restart')
}
private async power(id: string, action: 'start' | 'powerOff' | 'restart'): Promise<void> {
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<string> {
if (this.subscriptionId) return this.subscriptionId
const body = await this.json<ArmList<{subscriptionId?: string}>>('/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<string> {
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<string> {
const subscription = await this.subscription()
const body = await this.json<ArmList<{name?: string}>>(
`/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<PowerLookup> {
if (!resourceId) return {state: null}
try {
const view = await this.json<AzureInstanceView>(`${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<T>(path: string, init: RequestInit = {}, options = {}): Promise<T | null> {
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<T extends readonly string[]>(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<T extends readonly string[]>(
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]
}
@@ -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<CloudResource[]> {
calls.push(`${engine}:list`)
return [resource]
},
async get(): Promise<CloudResource> {
return resource
},
async create(_input: CreateResourceInput): Promise<CloudResource> {
calls.push(`${engine}:create`)
return resource
},
async delete(): Promise<void> {},
async listSqlDatabases(_serverId: string, _connection: SqlConnectionInput): Promise<SqlDatabase[]> {
return []
},
async listSqlTables(_serverId: string, _connection: SqlConnectionInput): Promise<SqlTable[]> {
return []
},
async querySql(_serverId: string, _connection: SqlConnectionInput, _query: string): Promise<SqlQueryResult> {
calls.push(`${engine}:query`)
return {resultSets: [], rowsAffected: [], durationMs: 0}
},
}
}
@@ -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<CloudResource[]>
get(id: string): Promise<CloudResource | null>
create(input: CreateResourceInput): Promise<CloudResource>
delete(id: string): Promise<void>
listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]>
listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]>
querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult>
}
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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
return input.values.engine === 'postgresql'
? this.postgres.create(input)
: this.sql.create(input)
}
delete(id: string): Promise<void> {
return isPostgresServerResourceId(id)
? this.postgres.delete(id)
: this.sql.delete(id)
}
listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
return this.dataAdapter(serverId, connection).listSqlDatabases(serverId, connection)
}
listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
return this.dataAdapter(serverId, connection).listSqlTables(serverId, connection)
}
querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
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
}
}
@@ -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<string, string> = {}) {
return secretRecord(name, null, tags)
}
function secretRecord(name: string, version: string | null, tags: Record<string, string> = {}) {
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<string, unknown>,
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'},
})
},
}
}
@@ -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<string, string>
}
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<CloudResource[]> {
const records: KeyVaultSecretRecord[] = []
const visited = new Set<string>()
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<KeyVaultSecretListResponse>(
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<CloudResource | null> {
const body = await this.keyVaultJson<KeyVaultSecretRecord>(
`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`,
{method: 'GET'},
{emptyOnNotFound: true},
)
return body ? toSecretResource(body, id) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<KeyVaultSecretRecord>(
`/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<void> {
await this.client.fetch(
this.keyVaultPath(`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`),
{method: 'DELETE', headers: keyVaultHeaders()},
{includeStorageApiVersion: false},
)
}
private async keyVaultJson<T>(
path: string,
init: RequestInit,
options?: {emptyOnNotFound?: boolean},
): Promise<T | null> {
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<string, string> {
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))
}
@@ -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<string, unknown>, 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'}})
},
}
}
@@ -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<T> {
_count?: number
Databases?: T[]
DocumentCollections?: T[]
Documents?: T[]
}
type CosmosRecord = Record<string, unknown>
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<CloudResource[]> {
const body = await this.cosmosJson<CosmosListResponse<CosmosRecord>>('/dbs', {method: 'GET'}, {emptyOnNotFound: true})
const databases = body?.Databases ?? []
return filterBySearch(databases.map(toDatabaseResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
const body = await this.cosmosJson<CosmosRecord>(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true})
return body ? toDatabaseResource(body) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<CosmosRecord>('/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<void> {
await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'})
}
async listCosmosContainers(databaseId: string): Promise<CosmosContainer[]> {
const body = await this.cosmosJson<CosmosListResponse<CosmosRecord>>(
`/dbs/${encodeSegment(databaseId)}/colls`,
{method: 'GET'},
{emptyOnNotFound: true},
)
return (body?.DocumentCollections ?? []).map((container) => toContainer(databaseId, container))
}
async createCosmosContainer(databaseId: string, input: CreateResourceInput): Promise<CosmosContainer> {
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<CosmosRecord>(`/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<void> {
await this.cosmosFetch(`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`, {method: 'DELETE'})
}
async listCosmosItems(databaseId: string, containerId: string): Promise<CosmosItem[]> {
const [container, body] = await Promise.all([
this.getCosmosContainer(databaseId, containerId),
this.cosmosJson<CosmosListResponse<CosmosRecord>>(
`/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<string, unknown>): Promise<CosmosItem> {
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<CosmosRecord>(
`/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<void> {
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<CosmosQueryResult> {
const sql = query.trim() || 'SELECT * FROM c'
const body = await this.cosmosJson<CosmosListResponse<Record<string, unknown> | 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<CosmosRecord | null> {
return this.cosmosJson<CosmosRecord>(
`/dbs/${encodeSegment(databaseId)}/colls/${encodeSegment(containerId)}`,
{method: 'GET'},
{emptyOnNotFound: true},
)
}
private cosmosFetch(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise<Response | null> {
const request: RequestInit = {
...init,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
...(init.headers ?? {}),
},
}
return this.fetchWithFallbacks(path, request, options)
}
private async cosmosJson<T>(path: string, init: RequestInit, options?: {emptyOnNotFound?: boolean}): Promise<T | null> {
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<Response | null> {
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<string, string> {
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<string, unknown> {
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)
}
@@ -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<unknown>): Promise<number> {
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<Record<string, unknown>>, rowsAffected: number[] = []) {
return {
resultSets: [{
columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'text'})),
rows,
truncated: false,
}],
rowsAffected,
durationMs: 1,
}
}
function testClient(responses: Record<string, unknown>, 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'}})
},
}
}
@@ -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<string, string>
sku?: Record<string, unknown>
properties?: Record<string, unknown>
}
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<CloudResource[]> {
const body = await this.postgresJson<AzurePostgresListResponse>(postgresServersPath(), {method: 'GET'}, true)
return filterBySearch((body?.value ?? []).map(toPostgresResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
const serverName = postgresServerName(id)
const body = await this.postgresJson<AzurePostgresRecord>(postgresServerPath(serverName), {method: 'GET'}, true)
return body ? toPostgresResource(body) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<AzurePostgresRecord>(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<void> {
await this.postgresFetch(postgresServerPath(postgresServerName(id)), {method: 'DELETE'})
}
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
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<SqlTable[]> {
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<SqlQueryResult> {
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<Response | null> {
return this.client.fetch(withApiVersion(path, API_VERSION), {
...init,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
...(init.headers ?? {}),
},
}, {emptyOnNotFound})
}
private async postgresJson<T>(path: string, init: RequestInit, emptyOnNotFound = false): Promise<T | null> {
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<SqlDataConnection> {
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
}
@@ -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<Response | null>,
): 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')
})
})
@@ -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<string, unknown>
files?: Record<string, string>
}
}
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<CloudResource[]> {
const body = await this.azureJson<AzureFunctionListResponse | AzureFunctionRecord[]>(
'/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<CloudResource | null> {
const body = await this.azureJson<AzureFunctionRecord>(
`/functions/${encodeURIComponent(id)}`,
{ method: 'GET' },
{ emptyOnNotFound: true },
)
return body ? toFunctionResource(body) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<AzureFunctionRecord>(
'/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<void> {
await this.client.fetch(
`/functions/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
{ emptyOnNotFound: true },
)
}
async invoke(id: string, payload: string): Promise<ServerlessInvokeResult> {
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<T>(
path: string,
init: RequestInit,
options?: { emptyOnNotFound?: boolean },
): Promise<T | null> {
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, unknown>): 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<string, unknown>).type
return typeof type === 'string' && type.toLowerCase().endsWith('trigger')
})
if (!trigger || typeof trigger !== 'object') return undefined
const type = (trigger as Record<string, unknown>).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))
}
@@ -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<Response | null>,
): AzureRuntimeClient {
return {
endpoint: 'http://localhost:4577',
accountName: 'devstoreaccount1',
fetch: handler,
}
}
@@ -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<CloudResource[]> {
const body = await this.azureJson<ServiceBusNamespaceListResponse>(
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<CloudResource | null> {
const body = await this.azureJson<ServiceBusNamespaceRecord>(
namespaceResourcePath(this.client, id),
{method: 'GET'},
{emptyOnNotFound: true},
)
return body ? toNamespaceResource(body) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<ServiceBusNamespaceRecord>(
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<void> {
await this.client.fetch(namespaceResourcePath(this.client, id), {method: 'DELETE'})
}
private async azureJson<T>(
path: string,
init: RequestInit,
options?: {emptyOnNotFound?: boolean},
): Promise<T | null> {
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')
}
@@ -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<unknown>): Promise<number> {
const failure = await operation().then(() => null, (err: unknown) => err)
expect(failure).not.toBeNull()
return toHttpError(failure).status
}
function sqlResult(rows: Array<Record<string, unknown>>, rowsAffected: number[] = []) {
return {
resultSets: [{
columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'nvarchar'})),
rows,
truncated: false,
}],
rowsAffected,
durationMs: 1,
}
}
function testClient(responses: Record<string, unknown>, 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'}})
},
}
}
@@ -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<string, string>
properties?: Record<string, unknown>
}
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<CloudResource[]> {
const body = await this.sqlJson<AzureSqlListResponse>(sqlServersPath(), {method: 'GET'}, true)
return filterBySearch((body?.value ?? []).map(toSqlServerResource), query.search)
}
async get(id: string): Promise<CloudResource | null> {
const serverName = sqlServerName(id)
const body = await this.sqlJson<AzureSqlRecord>(sqlServerPath(serverName), {method: 'GET'}, true)
return body ? toSqlServerResource(body) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<AzureSqlRecord>(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<void> {
await this.sqlFetch(sqlServerPath(sqlServerName(id)), {method: 'DELETE'})
}
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
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<SqlTable[]> {
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<SqlQueryResult> {
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<Response | null> {
return this.client.fetch(withApiVersion(path, API_VERSION), {
...init,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
...(init.headers ?? {}),
},
}, {emptyOnNotFound})
}
private async sqlJson<T>(path: string, init: RequestInit, emptyOnNotFound = false): Promise<T | null> {
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<SqlDataConnection> {
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
}
@@ -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(`
<?xml version="1.0" encoding="utf-8"?>
<EnumerationResults>
<Blobs>
<Blob>
<Name>test/.floci-folder</Name>
<Properties>
<Last-Modified>Wed, 20 May 2026 04:16:27 GMT</Last-Modified>
<Content-Length>0</Content-Length>
</Properties>
</Blob>
<Blob>
<Name>background.png</Name>
<Properties>
<Last-Modified>Wed, 20 May 2026 04:18:00 GMT</Last-Modified>
<Content-Length>120</Content-Length>
</Properties>
</Blob>
</Blobs>
</EnumerationResults>
`, {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
},
}
}
@@ -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<CloudResource[]> {
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<CloudResource | null> {
const resources = await this.list()
return resources.find((resource) => resource.id === id) ?? null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
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<void> {
await this.client.fetch(`${containerPath(this.client, id)}?restype=container`, {method: 'DELETE'})
}
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
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<void> {
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<StorageObjectDownload> {
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<void> {
await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(key)}`, {method: 'DELETE'})
}
async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise<void> {
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(/<Container>\s*<Name>([^<]+)<\/Name>[\s\S]*?(?:<Last-Modified>([^<]+)<\/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<string>()
const prefixes: StorageObject[] = [...xml.matchAll(/<BlobPrefix>\s*<Name>([^<]+)<\/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(/<Blob>\s*<Name>([^<]+)<\/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(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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)
}
@@ -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'
}
@@ -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<SqlQueryResult>
}
export class MssqlDataClient implements SqlDataClient {
async query(connection: SqlDataConnection, query: string): Promise<SqlQueryResult> {
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<Record<string, unknown>>(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<Record<string, unknown>>): 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'
}
@@ -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<SqlQueryResult> {
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<Record<string, unknown>>(query)
phase = 'result'
const results = (Array.isArray(rawResult) ? rawResult : [rawResult]) as QueryResult<Record<string, unknown>>[]
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<Record<string, unknown>>): 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,
}
}
@@ -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<string, string>): 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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: 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)
}
@@ -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'},
})
})
})

Some files were not shown because too many files have changed in this diff Show More