diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 1cebd4b..55a5cee 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -102,9 +102,17 @@ jobs: - name: Assert the UI is served run: | - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:4500/) - echo "UI HTTP $code" - test "$code" = "200" + for i in $(seq 1 20); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:4500/ || true) + echo "[$i] UI HTTP $code" + if [ "$code" = "200" ]; then + echo "UI reachable." + exit 0 + fi + sleep 3 + done + echo "::error::UI did not become reachable in time" + exit 1 - name: Dump container logs on failure if: failure() diff --git a/AGENTS.md b/AGENTS.md index 6472fcb..1b64103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,13 +59,21 @@ for all new work; the legacy routes survive only for deep EC2 panels and Secrets ### The multi-cloud SPI (the part you will use most) -- `packages/api/src/cloud-spi/types.ts` — `CloudProvider` (`aws|azure|gcp`), - `CloudServiceType` (`storage|k8s|database|serverless|compute|networking|…`), - the `CloudServiceAdapter` interface, and `ServiceSchema`. +- `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 @@ -74,8 +82,9 @@ 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 (`CLOUD_SERVICE_ITEMS`, `CLOUD_SERVICE_ICONS`) -- `packages/frontend/src/pages/CloudExplorerPage.tsx` — `normalizeService()` route handling +- `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` @@ -111,29 +120,40 @@ Requires a running Floci core (`:4566`) — see `README.md` / `docker compose` ( This is the canonical pattern (also referenced by the open service-coverage issues). -**Backend (`packages/api`):** +**Backend (`packages/api`) — this is the whole change:** -1. `src/cloud-spi/Schema.ts` — export `SchemaFor(cloud)` returning a - `ServiceSchema`. Model: `src/cloud-spi/storageSchema.ts`. -2. Add the literal to `CloudServiceType` in `src/cloud-spi/types.ts` (once per new category). +1. Add one row to `SERVICE_CATALOG` in `src/cloud-spi/serviceCatalog.ts` (only for a new + category). `CloudServiceType`, the route guard, and the nav metadata all derive from it. +2. `src/cloud-spi/Schema.ts` — export a per-cloud `Schema()` + returning a `ServiceSchema`. Model: `src/cloud-spi/storageSchema.ts`. Use `path` on a + column to surface a `metadata.*` field. 3. `src/adapter-/Adapter.ts implements CloudServiceAdapter` with a `.test.ts` alongside. Model: `src/adapter-aws/AwsStorageAdapter.ts`. AWS adapters use AWS - SDK v3 against `FLOCI_ENDPOINT`; Azure/GCP adapters call the local runtime over HTTP - (`adapter-azure/azure.ts`, `adapter-gcp/gcp.ts`). -4. Register `new Adapter()` in `src/cloudProxy.ts`. -5. Add a `services.push({...})` entry + `schema()` fallback in `service/CloudProxyService.ts`, - and extend `isServiceType()` in `routes/clouds.ts`. The generic `/api/clouds/...` routes - then work with no new handler. + 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`. -**Frontend (`packages/frontend`):** +That is it. `services()` derives availability from the registry, `schema()` serves only +registered adapters, and the generic `/api/clouds/...` routes need no new handler. -1. Extend `CloudServiceType` in `src/types/cloud.ts` (and `types/schema.ts` if new shapes). -2. Add a nav entry + icon and per-cloud gating in `components/Layout.tsx`. -3. Handle the literal in `normalizeService()` in `pages/CloudExplorerPage.tsx`. -4. `DynamicResourceView` renders it generically. Only add a `service === ''` panel for - deep UX (models: `ComputePanel`, `NetworkingPanel`, `CosmosNoSqlPanel`). +**Frontend (`packages/frontend`): normally nothing.** -Rule: copy an existing adapter + schema before introducing a new shape. +The nav, Console Home, and Cloud Explorer render `GET /clouds/:cloud/services`. Optional: +add an `iconKey` to `components/serviceIcons.ts` (an unknown key falls back to a generic +icon, so this is cosmetic), and add a `service === ''` panel in `DynamicResourceView` +only for deep UX (models: `ComputePanel`, `NetworkingPanel`, `CosmosNoSqlPanel`). + +Rules: + +- Copy an existing adapter + schema before introducing a new shape. +- Throw the typed errors in `src/cloud-spi/errors.ts`, never a bare `Error` — + `routes/clouds.ts` maps them to HTTP and no longer matches on message text. +- Never advertise a capability the adapter cannot perform. `src/cloudProxy.test.ts` + fails the build if a capability marked `available` has no adapter method, or if + anything not `available` lacks a `reason`. Use `descriptorOverride()` when the + adapter exists but the local runtime does not implement it. +- Regenerate the README table when navigation changes: + `cd packages/api && bun run scripts/service-matrix.ts`. --- @@ -185,7 +205,10 @@ and push the multi-arch `floci/floci-ui` image. Treat release workflows as criti - 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 (`cloudProxy.ts`) or wire the nav (`Layout.tsx`) +- 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 --- diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e251a..12b2cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- AWS Lambda invoke, including the tailed execution log and handler errors. +- Per-service status: `GET /api/clouds/:cloud/services/:service/status` and + `GET /api/clouds/:cloud/status?services=all`, with an `errorCode` that distinguishes a + runtime that does not implement a service from one that cannot be reached. +- `packages/api/scripts/service-matrix.ts` generates the README coverage table from the + service catalog and adapter registry. +- Grouped sidebar sections, a loading skeleton, and a tooltip explaining why a service is + unavailable. + +### Changed + +- Service availability is derived from one catalog plus the adapter registry and served to + the frontend. Registering an adapter is now the only step needed for a service to appear; + the sidebar, Console Home, and Cloud Explorer no longer hardcode it. +- Adapters throw typed errors that are mapped to HTTP in one place, so AWS SDK failures + return 400/403/404/409/429 instead of a blanket 502. +- Both GCP adapters share a runtime client that reports Google's error message instead of a + bare HTTP status. + +### Fixed + +- AWS Lambda creation failed against the runtime: inline code was sent as raw text where a + deployment archive is required, so "create" could never succeed. +- Azure serverless was advertised as available even though the Floci-AZ runtime answers 501 + NotImplemented; it now reports `coming_soon` with that reason. GCP Cloud Functions invoke + is likewise advertised as `coming_soon` — contrary to the 0.2.0 note below, it was never + implemented. +- AWS networking advertised create and delete as available while the adapter threw, and its + `get()` always returned null so inspect never worked. +- A schema was served for services with no registered adapter, so the UI rendered a table + that then failed on every request. +- GCP runtime status reported "reachable" whenever the port was open, because the probe + ignored the HTTP status. Cloud status was also inferred solely from the storage adapter. +- Table columns bound to `metadata` fields — including Serverless "Runtime" and + "Last Updated" — rendered blank on every row. +- An unknown service slug silently redirected to Storage instead of reporting it. + ## [0.2.0] - 2026-07-08 ### Added diff --git a/README.md b/README.md index 1c4bc79..a7b58d2 100644 --- a/README.md +++ b/README.md @@ -42,26 +42,42 @@ Open [http://localhost:4500](http://localhost:4500). ## What The UI Actually Exposes Today -This table is the source of truth for the current UI surface. +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: -| Surface | AWS | Azure | GCP | Notes | +```bash +cd packages/api && bun run scripts/service-matrix.ts +``` + +| Group | Service | AWS | Azure | GCP | |---|---|---|---|---| -| Console Home | Yes | Yes | Yes | Cloud-aware overview page with runtime status and service cards. | -| Cloud Explorer / Storage | Yes | Yes | Yes | Unified storage view with resource table, inspector, object browser, and schema-driven actions. | -| Cloud Explorer / k8s Engine | Yes | Placeholder | Placeholder | AWS EKS list/inspect is wired. | -| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect and Azure Cosmos DB NoSQL workflows. | -| Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. | -| Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. | -| Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. | -| Dedicated page / Secrets Manager | Yes | No | No | AWS-only page outside Cloud Explorer. | - -Visible placeholders in the current sidebar: - -- Queue -- Function -- Azure compute, networking, and k8s -- GCP non-storage services -- IAM, KMS, Cognito, Systems Manager, ElastiCache +| Compute | Compute | Yes (list, inspect, create, delete) | No | No | +| Compute | EKS / AKS / GKE | Yes (list, inspect) | No | Yes (list, create, inspect, delete) | +| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | +| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | +| Databases | Database | Yes (list, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | +| Networking | Networking | Yes (list) | No | No | +| Security | Secrets Manager / Key Vault | Yes (legacy page) | Yes (list, create, delete, inspect) | No | + +Console Home is available for all three clouds. + +Runtime gaps — an adapter exists but the local runtime does not implement it: + +- Azure Serverless: the Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint. + +Services marked `No` render as a disabled sidebar row whose tooltip carries the +server-supplied reason. Adding one is a catalog row in +`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change. + +

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

+ +Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled +`AKS` for this provider, and every unavailable service carries a reason — Serverless +reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions, +even though an adapter is registered. ## Current Capability Snapshot @@ -149,14 +165,17 @@ Current gaps: AWS only, through the unified shell plus an AWS-specific networking panel. -- VPC list and inspect. -- VPC creation and delete. -- VPC wizard. -- Subnets, security groups, internet gateways, NAT gateways, route tables, and Elastic IP workflows. +- 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. @@ -164,15 +183,19 @@ Current gaps:
Serverless -AWS only in the current navigation. +AWS and GCP, both through the unified shell. -- Lambda-oriented unified schema is wired through the Cloud Explorer serverless service. -- The backend already exposes serverless through the Cloud Proxy API. +- 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 not yet exposed in the left navigation. -- No GCP serverless adapter in the UI surface. +- 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.
@@ -392,11 +415,27 @@ 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: diff --git a/bun.lock b/bun.lock index 76e7b48..9d6d689 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@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-sqs": "^3.1090.0", "dotenv": "^17.4.2", "hono": "^4.12.27", }, @@ -25,7 +26,7 @@ }, "packages/frontend": { "name": "@floci/frontend", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", @@ -73,6 +74,8 @@ "@aws-sdk/client-secrets-manager": ["@aws-sdk/client-secrets-manager@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/credential-provider-node": "^3.972.63", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-lgrJFeDMdFz7ly0Jn7sTs2wNODJrvq8s1oiiCJFfCiRmMgS2IKk7b73zCC9y0ofKHtp+P+AbqnqUsMabXijmMw=="], + "@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.1104.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/credential-provider-node": "^3.972.78", "@aws-sdk/middleware-sdk-sqs": "^3.972.39", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-JT/X9bqJQ8yRuQ3E8QMyrewIvBHpllxhpvBnruw3j/702lH4XwgVmyEEa+9axVz2VayPPSmt02jindNg3b5aSw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.28", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw=="], "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A=="], @@ -97,6 +100,8 @@ "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.59", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw=="], + "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.39", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dlKLmJg1dLQVfFXUPS+f+SqXrRGHDVumY4gjM8sCLGao+zkDXEu8TObTyiaheKjT20ruok9Xs8oLocNItKQ0fw=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="], @@ -533,6 +538,26 @@ "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], + "@aws-sdk/client-sqs/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.78", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-ini": "^3.973.12", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng=="], + + "@aws-sdk/client-sqs/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], + + "@aws-sdk/client-sqs/@smithy/core": ["@smithy/core@3.31.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg=="], + + "@aws-sdk/client-sqs/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.13", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A=="], + + "@aws-sdk/client-sqs/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.13", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw=="], + + "@aws-sdk/client-sqs/@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + + "@aws-sdk/middleware-sdk-sqs/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], + + "@aws-sdk/middleware-sdk-sqs/@smithy/core": ["@smithy/core@3.31.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg=="], + + "@aws-sdk/middleware-sdk-sqs/@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + "@babel/helper-compilation-targets/browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -543,6 +568,24 @@ "bun-types/@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], + "@aws-sdk/client-sqs/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="], + + "@aws-sdk/client-sqs/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.12", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-login": "^3.972.74", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.11", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/token-providers": "3.1103.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.16", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w=="], + "@babel/helper-compilation-targets/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="], "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], @@ -552,5 +595,27 @@ "@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1103.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], + + "@aws-sdk/client-sqs/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.6.12", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ=="], } } diff --git a/docs/images/floci-ui-console-azure.png b/docs/images/floci-ui-console-azure.png new file mode 100644 index 0000000..9bd2d00 Binary files /dev/null and b/docs/images/floci-ui-console-azure.png differ diff --git a/docs/images/floci-ui-console.png b/docs/images/floci-ui-console.png index a3e6092..0810e44 100644 Binary files a/docs/images/floci-ui-console.png and b/docs/images/floci-ui-console.png differ diff --git a/packages/api/scripts/service-matrix.ts b/packages/api/scripts/service-matrix.ts new file mode 100644 index 0000000..992e407 --- /dev/null +++ b/packages/api/scripts/service-matrix.ts @@ -0,0 +1,58 @@ +/** + * Print the service-coverage matrix as markdown, derived from the service + * catalog and the adapter registry. + * + * The README table used to be hand-maintained and drifted from the code. Paste + * this output into the README whenever navigation changes: + * + * bun run scripts/service-matrix.ts + * + * Reads the registry only — no runtime calls — so it works offline. + */ + +import {createCloudAdapterRegistry} from '../src/cloudProxy' +import {SERVICE_CATALOG_ENTRIES, displayNameFor} from '../src/cloud-spi/serviceCatalog' +import type {CloudProvider} from '../src/cloud-spi/types' + +const CLOUDS: CloudProvider[] = ['aws', 'azure', 'gcp'] +const CLOUD_LABELS: Record = {aws: 'AWS', azure: 'Azure', gcp: 'GCP'} + +const registry = createCloudAdapterRegistry() + +function cell(cloud: CloudProvider, service: string): string { + const entry = SERVICE_CATALOG_ENTRIES.find((candidate) => candidate.service === service) + const legacy = entry?.legacyAvailability?.[cloud] + if (legacy === 'available') return 'Yes (legacy page)' + + const adapter = registry.get(cloud, service as never) + if (!adapter) return 'No' + + const override = adapter.descriptorOverride?.() + if (override?.availability === 'coming_soon') return 'Runtime gap' + + const actions = adapter.schema().actions + return `Yes (${actions.join(', ')})` +} + +const rows = SERVICE_CATALOG_ENTRIES.map((entry) => { + const names = new Set(CLOUDS.map((cloud) => displayNameFor(entry, cloud))) + // Show per-cloud names inline when they differ, e.g. EKS / AKS / GKE. + const label = names.size === 1 ? entry.displayName : [...names].join(' / ') + return `| ${entry.group} | ${label} | ${CLOUDS.map((cloud) => cell(cloud, entry.service)).join(' | ')} |` +}) + +console.log(`| Group | Service | ${CLOUDS.map((cloud) => CLOUD_LABELS[cloud]).join(' | ')} |`) +console.log(`|---|---|${CLOUDS.map(() => '---').join('|')}|`) +console.log(rows.join('\n')) + +const runtimeGaps = CLOUDS.flatMap((cloud) => + SERVICE_CATALOG_ENTRIES.flatMap((entry) => { + const override = registry.get(cloud, entry.service as never)?.descriptorOverride?.() + return override?.reason ? [`- ${CLOUD_LABELS[cloud]} ${displayNameFor(entry, cloud)}: ${override.reason}`] : [] + }), +) + +if (runtimeGaps.length > 0) { + console.log('\nRuntime gaps:\n') + console.log(runtimeGaps.join('\n')) +} diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts index 1940eda..9fdaf29 100644 --- a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts @@ -1,3 +1,4 @@ +import {NotSupportedError} from '../cloud-spi/errors' import {ListTagsForResourceCommand, type RDSClient} from '@aws-sdk/client-rds' import {rds as defaultRds} from '../aws' import {awsDatabaseSchema} from '../cloud-spi/databaseSchema' @@ -41,11 +42,11 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter { } async create(_input: CreateResourceInput): Promise { - throw new Error('Database creation is not supported from the dynamic Cloud Explorer.') + throw new NotSupportedError('Database creation is not supported from the dynamic Cloud Explorer.') } async delete(_id: string): Promise { - throw new Error('Database deletion is not supported from the dynamic Cloud Explorer.') + throw new NotSupportedError('Database deletion is not supported from the dynamic Cloud Explorer.') } private async toResource(instance: RdsInstance): Promise { diff --git a/packages/api/src/adapter-aws/AwsEksAdapter.ts b/packages/api/src/adapter-aws/AwsEksAdapter.ts index 230acd1..ab9e307 100644 --- a/packages/api/src/adapter-aws/AwsEksAdapter.ts +++ b/packages/api/src/adapter-aws/AwsEksAdapter.ts @@ -1,3 +1,4 @@ +import {NotSupportedError} from '../cloud-spi/errors' import {awsEksSchema} from '../cloud-spi/eksSchema' import type { CloudResource, @@ -35,11 +36,11 @@ export class AwsEksAdapter implements CloudServiceAdapter { } async create(_input: CreateResourceInput): Promise { - throw new Error('EKS cluster creation is not supported from the dynamic Cloud Explorer.') + throw new NotSupportedError('EKS cluster creation is not supported from the dynamic Cloud Explorer.') } async delete(_id: string): Promise { - throw new Error('EKS cluster deletion is not supported from the dynamic Cloud Explorer.') + throw new NotSupportedError('EKS cluster deletion is not supported from the dynamic Cloud Explorer.') } } diff --git a/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts b/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts index 8200921..ade2d5b 100644 --- a/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts +++ b/packages/api/src/adapter-aws/AwsNetworkingAdapter.ts @@ -1,3 +1,4 @@ +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' @@ -29,16 +30,21 @@ export class AwsNetworkingAdapter implements CloudServiceAdapter { return filterBySearch(vpcs.map(vpcToResource), query.search) } - async get(_id: string): Promise { - return null + async get(id: string): Promise { + // listVpcs is the only read the EC2 service exposes here, and a local + // emulator's VPC count is small enough that filtering it is cheaper than + // adding a describe path through the legacy service layer. + const vpcs = await this.service_.listVpcs() + const match = vpcs.find((vpc) => vpc.vpcId === id) + return match ? vpcToResource(match) : null } async create(_input: CreateResourceInput): Promise { - throw new Error('Use the Networking panel to create VPCs and networking resources.') + throw new NotSupportedError('Use the Networking panel to create VPCs and networking resources.') } async delete(_id: string): Promise { - throw new Error('Use the Networking panel to delete networking resources.') + throw new NotSupportedError('Use the Networking panel to delete networking resources.') } } diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts new file mode 100644 index 0000000..96c9ec6 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.test.ts @@ -0,0 +1,198 @@ +import {describe, expect, test} from 'bun:test' +import { + CreateFunctionCommand, + DeleteFunctionCommand, + GetFunctionCommand, + InvokeCommand, + type LambdaClient, + ListFunctionsCommand, +} from '@aws-sdk/client-lambda' +import {AwsServerlessAdapter} from './AwsServerlessAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +/** Minimal LambdaClient stub that records the commands it was sent. */ +function stubLambda(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as LambdaClient + return {client, sent} +} + +const listPayload = { + Functions: [ + { + FunctionName: 'hello', + FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:hello', + Runtime: 'nodejs20.x', + Handler: 'index.handler', + LastModified: '2026-07-01T10:00:00.000+0000', + State: 'Active', + MemorySize: 128, + Timeout: 3, + }, + ], +} + +describe('AwsServerlessAdapter', () => { + test('identifies itself as the AWS serverless adapter', () => { + const {client} = stubLambda(() => ({})) + const adapter = new AwsServerlessAdapter(client) + + expect(adapter.cloud).toBe('aws') + expect(adapter.service).toBe('serverless') + expect(adapter.schema().displayName).toBe('AWS Lambda') + }) + + test('lists functions and exposes runtime detail under metadata', async () => { + const {client, sent} = stubLambda(() => listPayload) + const [resource] = await new AwsServerlessAdapter(client).list() + + expect(sent[0]).toBeInstanceOf(ListFunctionsCommand) + expect(resource).toMatchObject({ + id: 'hello', + name: 'hello', + cloud: 'aws', + service: 'serverless', + type: 'lambda', + status: 'Active', + }) + // The serverless schema reads these through metadata paths. + expect(resource?.metadata.runtime).toBe('nodejs20.x') + expect(resource?.metadata.lastModified).toBe('2026-07-01T10:00:00.000+0000') + }) + + test('filters the list by search term', async () => { + const {client} = stubLambda(() => listPayload) + const adapter = new AwsServerlessAdapter(client) + + await expect(adapter.list({search: 'hell'})).resolves.toHaveLength(1) + await expect(adapter.list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('returns null when a function does not exist', async () => { + const {client} = stubLambda(() => { + throw Object.assign(new Error('ResourceNotFoundException'), {$metadata: {httpStatusCode: 404}}) + }) + await expect(new AwsServerlessAdapter(client).get('missing')).resolves.toBeNull() + }) + + test('rethrows a non-404 failure from get', async () => { + const {client} = stubLambda(() => { + throw Object.assign(new Error('AccessDenied'), {$metadata: {httpStatusCode: 403}}) + }) + await expect(new AwsServerlessAdapter(client).get('hello')).rejects.toThrow('AccessDenied') + }) + + test('inspects a function through GetFunction', async () => { + const {client, sent} = stubLambda(() => ({ + Configuration: {FunctionName: 'hello', Runtime: 'nodejs20.x', Role: 'arn:aws:iam::000000000000:role/lambda'}, + })) + const resource = await new AwsServerlessAdapter(client).get('hello') + + expect(sent[0]).toBeInstanceOf(GetFunctionCommand) + expect(resource?.id).toBe('hello') + expect(resource?.metadata.role).toBe('arn:aws:iam::000000000000:role/lambda') + }) + + test('requires the fields the schema marks required', async () => { + const {client} = stubLambda(() => ({})) + const adapter = new AwsServerlessAdapter(client) + + const cases: Array<[Record, string]> = [ + [{}, 'functionName is required'], + [{functionName: 'a'}, 'runtime is required'], + [{functionName: 'a', runtime: 'nodejs20.x'}, 'handler is required'], + [{functionName: 'a', runtime: 'nodejs20.x', handler: 'index.handler'}, 'role is required'], + ] + + for (const [values, message] of cases) { + await expect(adapter.create({values})).rejects.toThrow(new ValidationError(message)) + } + }) + + test('creates a function with a default inline handler', async () => { + const {client, sent} = stubLambda(() => ({FunctionName: 'hello', State: 'Pending'})) + await new AwsServerlessAdapter(client).create({ + values: {functionName: 'hello', runtime: 'nodejs20.x', handler: 'index.handler', role: 'arn:role'}, + }) + + const command = sent[0] as CreateFunctionCommand + expect(command).toBeInstanceOf(CreateFunctionCommand) + expect(command.input.FunctionName).toBe('hello') + expect(command.input.MemorySize).toBe(128) + expect(command.input.Timeout).toBe(3) + expect(command.input.Code?.ZipFile).toBeInstanceOf(Uint8Array) + }) + + test('deletes a function by name', async () => { + const {client, sent} = stubLambda(() => ({})) + await new AwsServerlessAdapter(client).delete('hello') + + expect((sent[0] as DeleteFunctionCommand).input.FunctionName).toBe('hello') + }) + + describe('invoke', () => { + test('returns the decoded payload, status and duration', async () => { + const {client, sent} = stubLambda(() => ({ + StatusCode: 200, + Payload: new TextEncoder().encode('{"ok":true}'), + })) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{"a":1}') + + const command = sent[0] as InvokeCommand + expect(command).toBeInstanceOf(InvokeCommand) + expect(command.input.FunctionName).toBe('hello') + expect(command.input.LogType).toBe('Tail') + expect(new TextDecoder().decode(command.input.Payload as Uint8Array)).toBe('{"a":1}') + + expect(result.statusCode).toBe(200) + expect(result.payload).toBe('{"ok":true}') + expect(result.executionDuration).toBeGreaterThanOrEqual(0) + expect(result.functionError).toBeUndefined() + }) + + test('defaults an empty payload to an empty JSON object', async () => { + const {client, sent} = stubLambda(() => ({StatusCode: 200})) + await new AwsServerlessAdapter(client).invoke('hello', '') + + const command = sent[0] as InvokeCommand + expect(new TextDecoder().decode(command.input.Payload as Uint8Array)).toBe('{}') + }) + + test('surfaces a handler error alongside the response payload', async () => { + const {client} = stubLambda(() => ({ + StatusCode: 200, + FunctionError: 'Unhandled', + Payload: new TextEncoder().encode('{"errorMessage":"boom"}'), + })) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{}') + + expect(result.functionError).toBe('Unhandled') + expect(result.payload).toContain('boom') + }) + + test('decodes the base64 tailed log', async () => { + const {client} = stubLambda(() => ({ + StatusCode: 200, + LogResult: Buffer.from('START RequestId: abc\nEND', 'utf8').toString('base64'), + })) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{}') + + expect(result.logResult).toContain('START RequestId: abc') + }) + + test('handles a missing payload without throwing', async () => { + const {client} = stubLambda(() => ({StatusCode: 202})) + const result = await new AwsServerlessAdapter(client).invoke('hello', '{}') + + expect(result.statusCode).toBe(202) + expect(result.payload).toBe('') + }) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts index a08e150..5adac12 100644 --- a/packages/api/src/adapter-aws/AwsServerlessAdapter.ts +++ b/packages/api/src/adapter-aws/AwsServerlessAdapter.ts @@ -1,7 +1,10 @@ +import {ValidationError} from '../cloud-spi/errors' import { CreateFunctionCommand, DeleteFunctionCommand, GetFunctionCommand, + InvokeCommand, + type InvokeCommandOutput, ListFunctionsCommand, type LambdaClient, } from "@aws-sdk/client-lambda"; @@ -11,9 +14,11 @@ import type { CloudServiceAdapter, CreateResourceInput, ResourceQuery, + ServerlessInvokeResult, ServiceSchema, } from "../cloud-spi/types"; import { lambda as defaultLambda } from "../aws"; +import { createZipArchive, handlerFileName } from "./zipArchive"; export class AwsServerlessAdapter implements CloudServiceAdapter { readonly cloud = "aws" as const; @@ -118,10 +123,10 @@ exports.handler = async (event) => { }; `.trim(); - if (!functionName) throw new Error("functionName is required"); - if (!runtime) throw new Error("runtime is required"); - if (!handler) throw new Error("handler is required"); - if (!role) throw new Error("role is required"); + if (!functionName) throw new ValidationError("functionName is required"); + if (!runtime) throw new ValidationError("runtime is required"); + if (!handler) throw new ValidationError("handler is required"); + if (!role) throw new ValidationError("role is required"); const res = await this.lambda.send( new CreateFunctionCommand({ @@ -133,7 +138,11 @@ exports.handler = async (event) => { MemorySize: Number.isFinite(memorySize) ? memorySize : 128, Timeout: Number.isFinite(timeout) ? timeout : 3, Code: { - ZipFile: new TextEncoder().encode(code), + // Must be a real archive: the runtime looks for the handler's module + // inside it, so raw source text is rejected outright. + ZipFile: createZipArchive([ + { name: handlerFileName(handler, runtime), content: code }, + ]), }, }), ); @@ -167,6 +176,41 @@ exports.handler = async (event) => { async delete(id: string): Promise { await this.lambda.send(new DeleteFunctionCommand({ FunctionName: id })); } + + async invoke(id: string, payload: string): Promise { + const startedAt = performance.now(); + const res = await this.lambda.send( + new InvokeCommand({ + FunctionName: id, + Payload: new TextEncoder().encode(payload || "{}"), + // Tail returns the last 4 KB of the execution log, base64 encoded. + LogType: "Tail", + }), + ); + const executionDuration = Math.round(performance.now() - startedAt); + + return { + statusCode: res.StatusCode ?? 0, + payload: decodePayload(res.Payload), + ...(res.FunctionError ? { functionError: res.FunctionError } : {}), + ...(res.LogResult ? { logResult: decodeLogResult(res.LogResult) } : {}), + executionDuration, + }; + } +} + +function 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( diff --git a/packages/api/src/adapter-aws/AwsStorageAdapter.ts b/packages/api/src/adapter-aws/AwsStorageAdapter.ts index dd109c8..bb555d9 100644 --- a/packages/api/src/adapter-aws/AwsStorageAdapter.ts +++ b/packages/api/src/adapter-aws/AwsStorageAdapter.ts @@ -1,3 +1,4 @@ +import {ValidationError} from '../cloud-spi/errors' import { CopyObjectCommand, CreateBucketCommand, @@ -57,9 +58,9 @@ export class AwsStorageAdapter implements CloudServiceAdapter { async create(input: CreateResourceInput): Promise { const bucketName = stringValue(input.values.bucketName) - if (!bucketName) throw new Error('bucketName is required') + if (!bucketName) throw new ValidationError('bucketName is required') if (!isValidS3BucketName(bucketName)) { - throw new Error('Use a valid S3 bucket name: 3-63 lowercase characters, numbers, dots, or hyphens.') + 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})) diff --git a/packages/api/src/adapter-aws/awsErrors.test.ts b/packages/api/src/adapter-aws/awsErrors.test.ts new file mode 100644 index 0000000..cf608ec --- /dev/null +++ b/packages/api/src/adapter-aws/awsErrors.test.ts @@ -0,0 +1,97 @@ +import {describe, expect, test} from 'bun:test' +import type {CloudErrorCode, CloudErrorStatus} from '../cloud-spi/errors' +import {isAwsSdkError, mapAwsSdkError} from './awsErrors' + +/** Build an error shaped like an AWS SDK v3 failure. */ +function sdkError(name: string, extra: Record = {}): Error { + const err = new Error(`${name} from the runtime`) + err.name = name + return Object.assign(err, {$fault: 'client', $metadata: {httpStatusCode: 500}, ...extra}) +} + +describe('isAwsSdkError', () => { + test('recognises the SDK envelope', () => { + expect(isAwsSdkError(sdkError('NoSuchBucket'))).toBe(true) + expect(isAwsSdkError(Object.assign(new Error('x'), {$retryable: {}}))).toBe(true) + }) + + test('rejects anything else so the generic mapper can handle it', () => { + expect(isAwsSdkError(new Error('plain'))).toBe(false) + expect(isAwsSdkError(null)).toBe(false) + expect(isAwsSdkError('string')).toBe(false) + }) +}) + +describe('mapAwsSdkError', () => { + test('returns null for a non-SDK error', () => { + expect(mapAwsSdkError(new Error('plain'))).toBeNull() + }) + + const cases: Array<[string, CloudErrorStatus, CloudErrorCode]> = [ + // throttling — must win over everything, including the 500 metadata status + ['ThrottlingException', 429, 'rate_limited'], + ['ProvisionedThroughputExceededException', 429, 'rate_limited'], + ['SlowDown', 429, 'rate_limited'], + // conflicts — previously all 502 + ['BucketAlreadyExists', 409, 'resource_conflict'], + ['BucketAlreadyOwnedByYou', 409, 'resource_conflict'], + ['EntityAlreadyExists', 409, 'resource_conflict'], + ['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) + }) +}) diff --git a/packages/api/src/adapter-aws/awsErrors.ts b/packages/api/src/adapter-aws/awsErrors.ts new file mode 100644 index 0000000..63e807f --- /dev/null +++ b/packages/api/src/adapter-aws/awsErrors.ts @@ -0,0 +1,174 @@ +/** + * 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', + 'ResourceInUseException', + 'ResourceConflictException', + 'ConditionalCheckFailedException', + 'QueueNameExists', + 'QueueDeletedRecently', + 'InvalidChangeBatch', + 'DBInstanceAlreadyExists', + 'DBInstanceAlreadyExistsFault', + 'ConcurrentModificationException', + 'IncorrectState', +]) + +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', + 'NotFound', + 'NotFoundException', + 'ResourceNotFoundException', + 'ResourceNotFoundFault', + 'DBInstanceNotFound', + 'DBInstanceNotFoundFault', + 'ParameterNotFound', + 'QueueDoesNotExist', + 'SecretNotFoundException', + 'NoSuchLogGroup', + 'NoSuchResourceException', + 'InvalidInstanceID.NotFound', + 'InvalidVpcID.NotFound', + 'InvalidSubnetID.NotFound', + 'InvalidGroupId.NotFound', + 'InvalidAMIID.NotFound', +]) + +const NOT_IMPLEMENTED_NAMES = new Set([ + 'NotImplemented', + 'UnsupportedOperation', + 'UnsupportedOperationException', +]) + +const UNAVAILABLE_NAMES = new Set([ + 'NetworkingError', + 'TimeoutError', + 'ServiceUnavailable', + 'ServiceUnavailableException', + 'RequestTimeout', + 'InternalFailure', + 'InternalError', +]) + +/** True when the value carries the AWS SDK v3 error envelope. */ +export function isAwsSdkError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false + return '$metadata' in err || '$fault' in err || '$retryable' in err +} + +/** + * Translate an AWS SDK error, or return `null` when it is not one so the caller + * can fall through to the generic mapping. + */ +export function mapAwsSdkError(err: unknown): CloudError | null { + if (!isAwsSdkError(err)) return null + + const sdkError = err as AwsSdkErrorShape + const name = sdkError.name ?? '' + const message = sdkError.message ?? name ?? 'AWS request failed' + const options = {cause: err} + + if (sdkError.$retryable?.throttling === true || THROTTLING_NAMES.has(name)) { + return new RateLimitedError(message, options) + } + if (CONFLICT_NAMES.has(name) || /AlreadyExists(Exception|Fault)?$/.test(name)) { + return new ConflictError(message, options) + } + if (ACCESS_DENIED_NAMES.has(name)) return new AccessDeniedError(message, options) + if (VALIDATION_NAMES.has(name)) return new ValidationError(message, options) + if (NOT_FOUND_NAMES.has(name) || /\.NotFound$/.test(name)) return new NotFoundError(message, options) + if (NOT_IMPLEMENTED_NAMES.has(name)) return new NotImplementedByRuntimeError(message, options) + if (UNAVAILABLE_NAMES.has(name) || isUnreachableCause(err)) { + return new RuntimeUnavailableError(message, options) + } + + // Emulator status codes are less reliable than names, so this is the floor. + const status = sdkError.$metadata?.httpStatusCode + if (status === 400) return new ValidationError(message, options) + if (status === 401 || status === 403) return new AccessDeniedError(message, options) + if (status === 404) return new NotFoundError(message, options) + if (status === 409) return new ConflictError(message, options) + if (status === 429) return new RateLimitedError(message, options) + if (status === 501) return new NotImplementedByRuntimeError(message, options) + if (status === 503 || status === 504) return new RuntimeUnavailableError(message, options) + + return new RuntimeError(message, options) +} diff --git a/packages/api/src/adapter-aws/zipArchive.test.ts b/packages/api/src/adapter-aws/zipArchive.test.ts new file mode 100644 index 0000000..3bccb44 --- /dev/null +++ b/packages/api/src/adapter-aws/zipArchive.test.ts @@ -0,0 +1,118 @@ +import {describe, expect, test} from 'bun:test' +import {execFile} from 'node:child_process' +import {rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {createZipArchive, crc32, handlerFileName} from './zipArchive' + +describe('crc32', () => { + // Reference values from the standard CRC-32 (IEEE 802.3) test vectors. + test('matches known checksums', () => { + expect(crc32(new TextEncoder().encode(''))).toBe(0x00000000) + expect(crc32(new TextEncoder().encode('a'))).toBe(0xe8b7be43) + expect(crc32(new TextEncoder().encode('abc'))).toBe(0x352441c2) + expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926) + }) +}) + +describe('createZipArchive', () => { + test('writes the ZIP magic number and end-of-central-directory record', () => { + const archive = createZipArchive([{name: 'index.js', content: 'module.exports = 1'}]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + + expect(view.getUint32(0, true)).toBe(0x04034b50) + expect(view.getUint32(archive.length - 22, true)).toBe(0x06054b50) + // One entry, recorded in both the disk and total counts. + expect(view.getUint16(archive.length - 22 + 8, true)).toBe(1) + expect(view.getUint16(archive.length - 22 + 10, true)).toBe(1) + }) + + test('records the entry name and an accurate checksum', () => { + const content = 'exports.handler = async () => ({statusCode: 200})' + const archive = createZipArchive([{name: 'index.js', content}]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + const encoded = new TextEncoder().encode(content) + + expect(view.getUint32(14, true)).toBe(crc32(encoded)) + expect(view.getUint32(18, true)).toBe(encoded.length) + expect(view.getUint32(22, true)).toBe(encoded.length) + expect(new TextDecoder().decode(archive.slice(30, 38))).toBe('index.js') + }) + + test('is readable by an independent ZIP implementation', async () => { + // Validates against a real reader rather than trusting our writer to agree + // with itself — a self-consistent but malformed archive is exactly the bug + // this file exists to prevent. + const archive = createZipArchive([ + {name: 'index.js', content: 'exports.handler = 1'}, + {name: 'lib/util.js', content: 'module.exports = {}'}, + ]) + + const path = `${tmpdir()}/floci-zip-${process.pid}.zip` + await writeFile(path, archive) + try { + const listing = await new Promise((resolve, reject) => { + execFile('unzip', ['-Z1', path], (err, stdout) => (err ? reject(err) : resolve(stdout))) + }).catch(() => null) + + if (listing === null) { + // No unzip on this machine; assert the structure we can check. + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + expect(view.getUint16(archive.length - 22 + 8, true)).toBe(2) + return + } + + expect(listing.split('\n').filter(Boolean).sort()).toEqual(['index.js', 'lib/util.js']) + + const extracted = await new Promise((resolve, reject) => { + execFile('unzip', ['-p', path, 'index.js'], (err, stdout) => (err ? reject(err) : resolve(stdout))) + }) + expect(extracted).toBe('exports.handler = 1') + } finally { + await rm(path, {force: true}) + } + }) + + test('supports multiple entries with distinct offsets', () => { + const archive = createZipArchive([ + {name: 'a.js', content: 'a'}, + {name: 'b.js', content: 'bb'}, + ]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + + expect(view.getUint16(archive.length - 22 + 8, true)).toBe(2) + // Second local header follows the first entry's header plus its 1 byte body. + expect(view.getUint32(30 + 4 + 1, true)).toBe(0x04034b50) + }) + + test('accepts binary content', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]) + const archive = createZipArchive([{name: 'blob.bin', content: bytes}]) + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength) + + expect(view.getUint32(14, true)).toBe(crc32(bytes)) + expect(view.getUint32(18, true)).toBe(bytes.length) + }) + + test('produces a byte-identical archive for identical input', () => { + // No timestamps, so a deployment package is reproducible. + const build = () => createZipArchive([{name: 'index.js', content: 'x'}]) + expect(build()).toEqual(build()) + }) +}) + +describe('handlerFileName', () => { + test('derives the module file from the handler string', () => { + expect(handlerFileName('index.handler', 'nodejs20.x')).toBe('index.js') + expect(handlerFileName('app.main', 'nodejs18.x')).toBe('app.js') + expect(handlerFileName('src/app.run', 'nodejs20.x')).toBe('src/app.js') + }) + + test('uses the runtime language extension', () => { + expect(handlerFileName('lambda_function.lambda_handler', 'python3.12')).toBe('lambda_function.py') + expect(handlerFileName('function.handler', 'ruby3.3')).toBe('function.rb') + }) + + test('falls back to index when the handler has no module part', () => { + expect(handlerFileName('handler', 'nodejs20.x')).toBe('index.js') + }) +}) diff --git a/packages/api/src/adapter-aws/zipArchive.ts b/packages/api/src/adapter-aws/zipArchive.ts new file mode 100644 index 0000000..8f9779e --- /dev/null +++ b/packages/api/src/adapter-aws/zipArchive.ts @@ -0,0 +1,132 @@ +/** + * Minimal ZIP writer for Lambda deployment packages. + * + * `CreateFunction`'s `Code.ZipFile` must be a real ZIP archive — passing raw + * source text makes the runtime reject the call with "Handler file not found in + * deployment package". Nothing in the dependency tree can build one, and the + * archives here are a single small source file, so entries are written with the + * STORED method (no compression): that needs only a CRC-32 and keeps this to a + * format-exact, dependency-free implementation. + * + * Spec: PKWARE APPNOTE 6.3.x, sections 4.3.7 (local header), 4.3.12 (central + * directory) and 4.3.16 (end of central directory). + */ + +const CRC32_TABLE = buildCrc32Table() + +function buildCrc32Table(): Uint32Array { + const table = new Uint32Array(256) + for (let i = 0; i < 256; i += 1) { + let value = i + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + } + table[i] = value >>> 0 + } + return table +} + +export function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff + for (const byte of bytes) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +export interface ZipEntry { + name: string + content: string | Uint8Array +} + +/** Build a ZIP archive containing the given files, stored uncompressed. */ +export function createZipArchive(entries: ZipEntry[]): Uint8Array { + const encoder = new TextEncoder() + const localParts: Uint8Array[] = [] + const centralParts: Uint8Array[] = [] + let offset = 0 + let localSize = 0 + + for (const entry of entries) { + const nameBytes = encoder.encode(entry.name) + const data = typeof entry.content === 'string' ? encoder.encode(entry.content) : entry.content + const checksum = crc32(data) + + const localHeader = new Uint8Array(30 + nameBytes.length) + const localView = new DataView(localHeader.buffer) + localView.setUint32(0, 0x04034b50, true) // local file header signature + localView.setUint16(4, 20, true) // version needed to extract (2.0) + localView.setUint16(6, 0, true) // general purpose bit flag + localView.setUint16(8, 0, true) // compression method: STORED + localView.setUint16(10, 0, true) // last mod time — fixed for reproducibility + localView.setUint16(12, 0x0021, true) // last mod date: 1980-01-01, the epoch ZIP allows + localView.setUint32(14, checksum, true) + localView.setUint32(18, data.length, true) // compressed size + localView.setUint32(22, data.length, true) // uncompressed size + localView.setUint16(26, nameBytes.length, true) + localView.setUint16(28, 0, true) // extra field length + localHeader.set(nameBytes, 30) + + const centralHeader = new Uint8Array(46 + nameBytes.length) + const centralView = new DataView(centralHeader.buffer) + centralView.setUint32(0, 0x02014b50, true) // central directory signature + centralView.setUint16(4, 20, true) // version made by + centralView.setUint16(6, 20, true) // version needed to extract + centralView.setUint16(8, 0, true) + centralView.setUint16(10, 0, true) // STORED + centralView.setUint16(12, 0, true) + centralView.setUint16(14, 0x0021, true) + centralView.setUint32(16, checksum, true) + centralView.setUint32(20, data.length, true) + centralView.setUint32(24, data.length, true) + centralView.setUint16(28, nameBytes.length, true) + centralView.setUint16(30, 0, true) // extra field length + centralView.setUint16(32, 0, true) // file comment length + centralView.setUint16(34, 0, true) // disk number start + centralView.setUint16(36, 0, true) // internal file attributes + centralView.setUint32(38, 0o100644 << 16, true) // external attrs: regular file, rw-r--r-- + centralView.setUint32(42, offset, true) // relative offset of local header + centralHeader.set(nameBytes, 46) + + localParts.push(localHeader, data) + centralParts.push(centralHeader) + offset += localHeader.length + data.length + localSize += localHeader.length + data.length + } + + const centralSize = centralParts.reduce((total, part) => total + part.length, 0) + const endRecord = new Uint8Array(22) + const endView = new DataView(endRecord.buffer) + endView.setUint32(0, 0x06054b50, true) // end of central directory signature + endView.setUint16(4, 0, true) // this disk number + endView.setUint16(6, 0, true) // disk with central directory + endView.setUint16(8, entries.length, true) + endView.setUint16(10, entries.length, true) + endView.setUint32(12, centralSize, true) + endView.setUint32(16, localSize, true) // central directory offset + endView.setUint16(20, 0, true) // comment length + + return concat([...localParts, ...centralParts, endRecord]) +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(total) + let cursor = 0 + for (const part of parts) { + out.set(part, cursor) + cursor += part.length + } + return out +} + +/** + * Derive the deployment-package filename Lambda will look for from a handler + * string. `index.handler` means the exported `handler` lives in `index.js`; + * `src/app.run` means `run` lives in `src/app.js`. + */ +export function handlerFileName(handler: string, runtime: string): string { + const modulePath = handler.split('.').slice(0, -1).join('.') || 'index' + const extension = runtime.startsWith('python') ? 'py' : runtime.startsWith('ruby') ? 'rb' : 'js' + return `${modulePath}.${extension}` +} diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts index 0b5cd05..1b60188 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts @@ -1,3 +1,5 @@ +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import {CosmosNoSqlUnavailableError} from './CosmosNoSqlUnavailableError' import {azure, type AzureRuntimeClient} from '../azure' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' import type { @@ -43,14 +45,14 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { async create(input: CreateResourceInput): Promise { const databaseName = stringValue(input.values.databaseName) - if (!databaseName) throw new Error('databaseName is required') - if (!isValidCosmosId(databaseName)) throw new Error('Use a valid Cosmos database name.') + if (!databaseName) throw new ValidationError('databaseName is required') + if (!isValidCosmosId(databaseName)) throw new ValidationError('Use a valid Cosmos database name.') const body = await this.cosmosJson('/dbs', { method: 'POST', body: JSON.stringify({id: databaseName}), }) - if (!body) throw new Error('Cosmos database creation returned an empty response') + if (!body) throw new RuntimeError('Cosmos database creation returned an empty response') return toDatabaseResource(body) } @@ -70,8 +72,8 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { async createCosmosContainer(databaseId: string, input: CreateResourceInput): Promise { const containerName = stringValue(input.values.containerName) const partitionKeyPath = normalizePartitionKeyPath(stringValue(input.values.partitionKeyPath) || '/id') - if (!containerName) throw new Error('containerName is required') - if (!isValidCosmosId(containerName)) throw new Error('Use a valid Cosmos container name.') + if (!containerName) throw new ValidationError('containerName is required') + if (!isValidCosmosId(containerName)) throw new ValidationError('Use a valid Cosmos container name.') const body = await this.cosmosJson(`/dbs/${encodeSegment(databaseId)}/colls`, { method: 'POST', @@ -80,7 +82,7 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { partitionKey: {paths: [partitionKeyPath], kind: 'Hash'}, }), }) - if (!body) throw new Error('Cosmos container creation returned an empty response') + if (!body) throw new RuntimeError('Cosmos container creation returned an empty response') return toContainer(databaseId, body) } @@ -102,9 +104,9 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { } async upsertCosmosItem(databaseId: string, containerId: string, document: Record): Promise { - if (!isRecord(document)) throw new Error('document must be a JSON object') + if (!isRecord(document)) throw new ValidationError('document must be a JSON object') const id = stringValue(document.id) - if (!id) throw new Error('Cosmos document id is required') + if (!id) throw new ValidationError('Cosmos document id is required') const container = await this.getCosmosContainer(databaseId, containerId) const pkPath = container ? partitionKeyPath(container) : '/id' @@ -119,7 +121,7 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { }, }, ) - if (!body) throw new Error('Cosmos document upsert returned an empty response') + if (!body) throw new RuntimeError('Cosmos document upsert returned an empty response') return toItem(databaseId, containerId, body, pkPath) } @@ -147,7 +149,7 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { }, }, ) - if (!body) throw new Error('Cosmos query returned an empty response') + if (!body) throw new RuntimeError('Cosmos query returned an empty response') const items = body.Documents ?? [] return {items, count: body._count ?? items.length} } @@ -196,7 +198,7 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { } } - throw new Error(`Cosmos NoSQL request failed on all known routes. ${failures.join(' | ')}`) + throw new CosmosNoSqlUnavailableError(`Cosmos NoSQL request failed on all known routes. ${failures.join(' | ')}`) } } diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts new file mode 100644 index 0000000..f1ff108 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts @@ -0,0 +1,210 @@ +import {describe, expect, test} from 'bun:test' +import {AzureKeyVaultAdapter} from './AzureKeyVaultAdapter' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' + +interface RecordedCall { + path: string + init: RequestInit + options: AzureRuntimeFetchOptions +} + +describe('AzureKeyVaultAdapter', () => { + test('lists and filters Key Vault secrets without exposing values', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets?api-version=7.4': { + value: [listedSecretRecord('api-token')], + nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4&next=page-2', + }, + '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2': { + value: [listedSecretRecord('database-password', {env: 'test'})], + nextLink: null, + }, + }, calls)) + + await expect(adapter.list({search: 'database'})).resolves.toEqual([{ + id: 'database-password', + name: 'database-password', + cloud: 'azure', + service: 'secrets', + type: 'secret', + region: null, + createdAt: '2026-05-20T20:00:00.000Z', + status: 'enabled', + version: null, + metadata: { + provider: 'azure', + secretsService: 'key-vault', + vaultAccount: 'devstoreaccount1', + contentType: 'text/plain', + updatedAt: '2026-05-20T20:00:01.000Z', + expiresAt: null, + notBefore: null, + recoveryLevel: 'Purgeable', + recoverableDays: 7, + tags: [{key: 'env', value: 'test'}], + }, + }]) + expect(calls.map((call) => call.path)).toEqual([ + '/devstoreaccount1-keyvault/secrets?api-version=7.4', + '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2', + ]) + expect(calls.every((call) => call.options.includeStorageApiVersion === false)).toBe(true) + }) + + test('stops paging when the runtime echoes the current page as its nextLink', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets?api-version=7.4': { + value: [listedSecretRecord('api-token')], + nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4', + }, + }, calls)) + + await expect(adapter.list()).resolves.toHaveLength(1) + expect(calls).toHaveLength(1) + }) + + test('gets a secret and omits its value from normalized metadata', async () => { + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/database-password?api-version=7.4': { + ...secretRecord('database-password', 'v1'), + value: 'super-secret', + }, + })) + + const resource = await adapter.get('database-password') + + expect(resource).toMatchObject({id: 'database-password', version: 'v1'}) + expect(resource?.metadata).not.toHaveProperty('value') + }) + + test('creates secrets through the Azure Key Vault data-plane contract', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': { + ...secretRecord('api-key', 'created-version'), + value: 'abc123', + contentType: 'application/json', + }, + }, calls)) + + const created = await adapter.create({values: { + secretName: 'api-key', + secretValue: 'abc123', + contentType: 'application/json', + }}) + + expect(created).toMatchObject({id: 'api-key', version: 'created-version'}) + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe('/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4') + expect(calls[0].init.method).toBe('PUT') + expect(calls[0].init.headers).toMatchObject({ + authorization: 'Bearer floci-ui', + 'content-type': 'application/json', + }) + expect(calls[0].options.includeStorageApiVersion).toBe(false) + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + value: 'abc123', + contentType: 'application/json', + }) + }) + + test('deletes secrets using soft-delete endpoint', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {}, + }, calls)) + + await adapter.delete('api-key') + + expect(calls).toHaveLength(1) + expect(calls[0].init.method).toBe('DELETE') + expect(calls[0].init.headers).toMatchObject({authorization: 'Bearer floci-ui'}) + expect(calls[0].options.includeStorageApiVersion).toBe(false) + expect(calls[0].options.emptyOnNotFound).toBeUndefined() + }) + + test('surfaces a delete of a secret that does not exist', async () => { + const adapter = new AzureKeyVaultAdapter({ + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string) { + throw new Error(`Azure runtime request failed: HTTP 404 ${path}`) + }, + }) + + await expect(adapter.delete('missing')).rejects.toThrow('HTTP 404') + }) + + test('normalizes missing secrets to null and missing lists to empty', async () => { + const adapter = new AzureKeyVaultAdapter(testClient({})) + + await expect(adapter.get('missing')).resolves.toBeNull() + await expect(adapter.list()).resolves.toEqual([]) + }) + + test('validates required inputs before calling the runtime', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({}, calls)) + + await expect(adapter.create({values: {secretName: 'not valid', secretValue: 'value'}})) + .rejects.toThrow('Use a valid Key Vault secret name') + await expect(adapter.create({values: {secretName: 'valid-name', secretValue: ''}})) + .rejects.toThrow('secretValue is required') + expect(calls).toHaveLength(0) + }) + + test('exposes Azure Key Vault CRUD capabilities in its schema', () => { + const schema = new AzureKeyVaultAdapter(testClient({})).schema() + + expect(schema.service).toBe('secrets') + expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect']) + expect(schema.fields.find((field) => field.name === 'secretValue')?.type).toBe('password') + const namePattern = schema.fields.find((field) => field.name === 'secretName')?.validation?.pattern + expect(() => new RegExp(namePattern ?? '', 'v')).not.toThrow() + }) +}) + +// The list endpoint returns base identifiers; only a single-secret read carries a version. +function listedSecretRecord(name: string, tags: Record = {}) { + return secretRecord(name, null, tags) +} + +function secretRecord(name: string, version: string | null, tags: Record = {}) { + return { + id: `https://devstoreaccount1.vault.azure.net/secrets/${name}${version ? `/${version}` : ''}`, + attributes: { + enabled: true, + created: 1779307200, + updated: 1779307201, + exp: null, + nbf: null, + recoveryLevel: 'Purgeable', + recoverableDays: 7, + }, + contentType: 'text/plain', + tags, + } +} + +function testClient( + responses: Record, + calls: RecordedCall[] = [], +): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init, options}) + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + throw new Error(`Unexpected Key Vault path: ${path}`) + } + return new Response(JSON.stringify(responses[path]), { + status: 200, + headers: {'content-type': 'application/json'}, + }) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts new file mode 100644 index 0000000..65c04e4 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts @@ -0,0 +1,218 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {azureSecretsSchema, SECRET_NAME_MESSAGE, SECRET_NAME_PATTERN} from '../cloud-spi/secretsSchema' +import {ValidationError} from '../cloud-spi/errors' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +const API_VERSION = '7.4' + +interface KeyVaultSecretAttributes { + enabled?: boolean + created?: number + updated?: number + exp?: number | null + nbf?: number | null + recoveryLevel?: string + recoverableDays?: number +} + +interface KeyVaultSecretRecord { + id?: string + value?: string + attributes?: KeyVaultSecretAttributes + contentType?: string + tags?: Record +} + +interface KeyVaultSecretListResponse { + value?: KeyVaultSecretRecord[] + nextLink?: string | null +} + +export class AzureKeyVaultAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'secrets' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureSecretsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const records: KeyVaultSecretRecord[] = [] + const visited = new Set() + let path: string | null = `/secrets?api-version=${API_VERSION}` + + // A runtime that echoes the current page as its own nextLink would otherwise + // loop forever, so stop as soon as a page repeats. + while (path && !visited.has(path)) { + visited.add(path) + const body: KeyVaultSecretListResponse | null = await this.keyVaultJson( + path, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + records.push(...(body?.value ?? [])) + path = body?.nextLink ? keyVaultNextPath(body.nextLink) : null + } + + return filterBySearch(records.map((record) => toSecretResource(record)), query.search) + } + + async get(id: string): Promise { + const body = await this.keyVaultJson( + `/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + return body ? toSecretResource(body, id) : null + } + + async create(input: CreateResourceInput): Promise { + const secretName = stringValue(input.values.secretName ?? input.values.name) + const secretValue = stringValue(input.values.secretValue ?? input.values.value, false) + const contentType = stringValue(input.values.contentType) + + if (!secretName) throw new ValidationError('secretName is required') + if (!new RegExp(SECRET_NAME_PATTERN).test(secretName)) throw new ValidationError(SECRET_NAME_MESSAGE) + if (!secretValue) throw new ValidationError('secretValue is required') + + const body = await this.keyVaultJson( + `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`, + { + method: 'PUT', + body: JSON.stringify({ + value: secretValue, + ...(contentType ? {contentType} : {}), + }), + }, + ) + + if (!body) throw new Error('Azure Key Vault create returned an empty response') + return toSecretResource(body, secretName) + } + + async delete(id: string): Promise { + await this.client.fetch( + this.keyVaultPath(`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`), + {method: 'DELETE', headers: keyVaultHeaders()}, + {includeStorageApiVersion: false}, + ) + } + + private async keyVaultJson( + path: string, + init: RequestInit, + options?: {emptyOnNotFound?: boolean}, + ): Promise { + const res = await this.client.fetch( + this.keyVaultPath(path), + { + ...init, + headers: { + ...keyVaultHeaders(), + ...(init.headers ?? {}), + }, + }, + {...options, includeStorageApiVersion: false}, + ) + + if (!res || res.status === 204) return null + return await res.json() as T + } + + private keyVaultPath(path: string): string { + return `/${encodeURIComponent(this.client.accountName)}-keyvault${path}` + } +} + +function keyVaultNextPath(nextLink: string): string { + try { + const url = new URL(nextLink) + return `${url.pathname}${url.search}` + } catch { + return nextLink.startsWith('/') ? nextLink : `/${nextLink}` + } +} + +function keyVaultHeaders(): Record { + return { + accept: 'application/json', + authorization: 'Bearer floci-ui', + 'content-type': 'application/json', + } +} + +function toSecretResource(record: KeyVaultSecretRecord, fallbackName = ''): CloudResource { + const parsed = parseSecretId(record.id) + const name = parsed.name || fallbackName + const attributes = record.attributes ?? {} + + return { + id: name, + name, + cloud: 'azure', + service: 'secrets', + type: 'secret', + region: null, + createdAt: epochDate(attributes.created), + status: attributes.enabled === false ? 'disabled' : 'enabled', + version: parsed.version, + metadata: { + provider: 'azure', + secretsService: 'key-vault', + vaultAccount: parsed.account, + contentType: record.contentType || null, + updatedAt: epochDate(attributes.updated), + expiresAt: epochDate(attributes.exp), + notBefore: epochDate(attributes.nbf), + recoveryLevel: attributes.recoveryLevel, + recoverableDays: attributes.recoverableDays, + tags: Object.entries(record.tags ?? {}).map(([key, value]) => ({key, value})), + }, + } +} + +function parseSecretId(id?: string): {account: string | null; name: string; version: string | null} { + if (!id) return {account: null, name: '', version: null} + try { + const url = new URL(id) + const parts = url.pathname.split('/').filter(Boolean) + return { + account: url.hostname.split('.')[0] || null, + name: decodeURIComponent(parts[1] ?? ''), + version: parts[2] ? decodeURIComponent(parts[2]) : null, + } + } catch { + const parts = id.split('/').filter(Boolean) + const secretsIndex = parts.lastIndexOf('secrets') + return { + account: null, + name: decodeURIComponent(parts[secretsIndex + 1] ?? ''), + version: parts[secretsIndex + 2] ? decodeURIComponent(parts[secretsIndex + 2]) : null, + } + } +} + +function epochDate(value?: number | null): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return new Date(value * 1000).toISOString() +} + +function stringValue(value: unknown, trim = true): string { + if (typeof value !== 'string') return '' + return trim ? value.trim() : value +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts b/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts index f632dba..00f47f7 100644 --- a/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureServerlessAdapter.test.ts @@ -1,6 +1,6 @@ -import {describe, expect, test} from 'bun:test' -import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' -import {AzureServerlessAdapter} from './AzureServerlessAdapter' +import { describe, expect, test } from 'bun:test' +import type { AzureRuntimeClient, AzureRuntimeFetchOptions } from '../azure' +import { AzureServerlessAdapter } from './AzureServerlessAdapter' interface RecordedCall { path: string @@ -23,7 +23,13 @@ function azureFunction(name: string) { scriptHref: `http://localhost:4577/functions/${name}/script`, invokeUrlTemplate: `http://localhost:4577/functions/${name}/invoke`, config: { - bindings: [], + bindings: [ + { + type: 'httpTrigger', + direction: 'in', + name: 'req', + }, + ], }, files: { 'index.js': 'module.exports = async () => ({statusCode: 200})', @@ -53,7 +59,7 @@ describe('AzureServerlessAdapter', () => { JSON.stringify({ value: [azureFunction('hello')], }), - {status: 200}, + { status: 200 }, ), ) @@ -76,10 +82,18 @@ describe('AzureServerlessAdapter', () => { 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: [], + bindings: [ + { + type: 'httpTrigger', + direction: 'in', + name: 'req', + }, + ], }, files: { 'index.js': @@ -90,10 +104,33 @@ describe('AzureServerlessAdapter', () => { ]) }) + 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}) + return new Response('Not Found', { status: 404 }) }) await expect(new AzureServerlessAdapter(client).list()).resolves.toEqual([]) @@ -105,7 +142,7 @@ describe('AzureServerlessAdapter', () => { JSON.stringify({ value: [azureFunction('alpha'), azureFunction('beta')], }), - {status: 200}, + { status: 200 }, ), ) @@ -118,7 +155,7 @@ describe('AzureServerlessAdapter', () => { test('gets and maps a single function', async () => { const client = testClient(async () => - new Response(JSON.stringify(azureFunction('hello')), {status: 200}), + new Response(JSON.stringify(azureFunction('hello')), { status: 200 }), ) const resource = await new AzureServerlessAdapter(client).get('hello') @@ -131,7 +168,7 @@ describe('AzureServerlessAdapter', () => { 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}) + return new Response('Not Found', { status: 404 }) }) await expect( @@ -143,7 +180,7 @@ describe('AzureServerlessAdapter', () => { const calls: RecordedCall[] = [] const client = testClient(async (path, init, options) => { - calls.push({path, init, options}) + calls.push({ path, init, options }) return new Response(JSON.stringify(azureFunction('hello')), { status: 201, }) @@ -202,7 +239,7 @@ describe('AzureServerlessAdapter', () => { test('create rejects when functionName is missing', async () => { const client = testClient(async () => - new Response(JSON.stringify({}), {status: 200}), + new Response(JSON.stringify({}), { status: 200 }), ) await expect( @@ -214,7 +251,7 @@ describe('AzureServerlessAdapter', () => { test('create rejects when the runtime returns an empty response', async () => { const client = testClient(async () => - new Response(null, {status: 204}), + new Response(null, { status: 204 }), ) await expect( @@ -230,8 +267,8 @@ describe('AzureServerlessAdapter', () => { const calls: RecordedCall[] = [] const client = testClient(async (path, init, options) => { - calls.push({path, init, options}) - return new Response(null, {status: 204}) + calls.push({ path, init, options }) + return new Response(null, { status: 204 }) }) await new AzureServerlessAdapter(client).delete('hello') @@ -248,7 +285,7 @@ describe('AzureServerlessAdapter', () => { const calls: RecordedCall[] = [] const client = testClient(async (path, init, options) => { - calls.push({path, init, options}) + calls.push({ path, init, options }) return new Response( JSON.stringify({ statusCode: 202, @@ -258,7 +295,7 @@ describe('AzureServerlessAdapter', () => { functionError: 'Handled', logResult: 'execution log', }), - {status: 200}, + { status: 200 }, ) }) @@ -290,7 +327,7 @@ describe('AzureServerlessAdapter', () => { statusCode: 200, body: 'ok', }), - {status: 200}, + { status: 200 }, ) }) @@ -305,20 +342,20 @@ describe('AzureServerlessAdapter', () => { expect(result.payload).toBe('ok') }) - test('lists functions from a direct array response', async () => { - const client = testClient(async () => + test('lists functions from a direct array response', async () => { + const client = testClient(async () => new Response( JSON.stringify([azureFunction('hello')]), - {status: 200}, - ), - ) + { status: 200 }, + ), + ) - const resources = await new AzureServerlessAdapter(client).list() + const resources = await new AzureServerlessAdapter(client).list() - expect(resources).toHaveLength(1) - expect(resources[0].id).toBe('hello') - expect(resources[0].type).toBe('azure-function') -}) + 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') diff --git a/packages/api/src/adapter-azure/AzureServerlessAdapter.ts b/packages/api/src/adapter-azure/AzureServerlessAdapter.ts index f5af766..d95a41e 100644 --- a/packages/api/src/adapter-azure/AzureServerlessAdapter.ts +++ b/packages/api/src/adapter-azure/AzureServerlessAdapter.ts @@ -1,8 +1,10 @@ -import {azure, type AzureRuntimeClient} from '../azure' -import {azureServerlessSchema} from '../cloud-spi/serverlessSchema' +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, @@ -36,7 +38,20 @@ export class AzureServerlessAdapter implements CloudServiceAdapter { readonly cloud = 'azure' as const readonly service = 'serverless' as const - constructor(private readonly client: AzureRuntimeClient = azure) {} + 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() @@ -45,8 +60,8 @@ export class AzureServerlessAdapter implements CloudServiceAdapter { async list(query: ResourceQuery = {}): Promise { const body = await this.azureJson( '/functions', - {method: 'GET'}, - {emptyOnNotFound: true}, + { method: 'GET' }, + { emptyOnNotFound: true }, ) const records = Array.isArray(body) ? body : body?.value ?? [] @@ -56,8 +71,8 @@ export class AzureServerlessAdapter implements CloudServiceAdapter { async get(id: string): Promise { const body = await this.azureJson( `/functions/${encodeURIComponent(id)}`, - {method: 'GET'}, - {emptyOnNotFound: true}, + { method: 'GET' }, + { emptyOnNotFound: true }, ) return body ? toFunctionResource(body) : null @@ -71,7 +86,7 @@ export class AzureServerlessAdapter implements CloudServiceAdapter { const location = stringValue(input.values.location) const functionAppName = stringValue(input.values.functionAppName) - if (!functionName) throw new Error('functionName is required') + if (!functionName) throw new ValidationError('functionName is required') const body = await this.azureJson( '/functions', @@ -88,15 +103,15 @@ export class AzureServerlessAdapter implements CloudServiceAdapter { }, ) - if (!body) throw new Error('Azure Functions create returned an empty response') + if (!body) throw new RuntimeError('Azure Functions create returned an empty response') return toFunctionResource(body) } async delete(id: string): Promise { await this.client.fetch( `/functions/${encodeURIComponent(id)}`, - {method: 'DELETE'}, - {emptyOnNotFound: true}, + { method: 'DELETE' }, + { emptyOnNotFound: true }, ) } async invoke(id: string, payload: string): Promise { @@ -127,11 +142,11 @@ export class AzureServerlessAdapter implements CloudServiceAdapter { executionDuration: Math.round(performance.now() - startedAt), } } - + private async azureJson( path: string, init: RequestInit, - options?: {emptyOnNotFound?: boolean}, + options?: { emptyOnNotFound?: boolean }, ): Promise { const res = await this.client.fetch( path, @@ -171,6 +186,8 @@ function toFunctionResource(record: AzureFunctionRecord): CloudResource { 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, @@ -189,6 +206,23 @@ function stringifyPayload(value: unknown): string { return JSON.stringify(value) } +function getTriggerType(config?: Record): string | undefined { + const bindings = config?.bindings + if (!Array.isArray(bindings)) return undefined + + const trigger = bindings.find((binding) => { + if (!binding || typeof binding !== 'object') return false + + const type = (binding as Record).type + return typeof type === 'string' && type.toLowerCase().endsWith('trigger') + }) + + if (!trigger || typeof trigger !== 'object') return undefined + + const type = (trigger as Record).type + return typeof type === 'string' ? type : undefined +} + function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { const normalized = search?.trim().toLowerCase() if (!normalized) return resources diff --git a/packages/api/src/adapter-azure/AzureStorageAdapter.ts b/packages/api/src/adapter-azure/AzureStorageAdapter.ts index 6c7b4cd..6dec4b6 100644 --- a/packages/api/src/adapter-azure/AzureStorageAdapter.ts +++ b/packages/api/src/adapter-azure/AzureStorageAdapter.ts @@ -1,3 +1,4 @@ +import {NotFoundError, ValidationError} from '../cloud-spi/errors' import {azureStorageSchema} from '../cloud-spi/storageSchema' import {azure, type AzureRuntimeClient} from '../azure' import type { @@ -38,9 +39,9 @@ export class AzureStorageAdapter implements CloudServiceAdapter { async create(input: CreateResourceInput): Promise { const containerName = stringValue(input.values.containerName) - if (!containerName) throw new Error('containerName is required') + if (!containerName) throw new ValidationError('containerName is required') if (!isValidContainerName(containerName)) { - throw new Error('Use a valid Azure container name: 3-63 lowercase letters, numbers, or single hyphens.') + 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'}) @@ -75,7 +76,7 @@ export class AzureStorageAdapter implements CloudServiceAdapter { async getObject(resourceId: string, key: string): Promise { const res = await this.client.fetch(`${containerPath(this.client, resourceId)}/${encodePath(key)}`, {method: 'GET'}) - if (!res) throw new Error('Azure blob not found') + if (!res) throw new NotFoundError('Azure blob not found') return { body: await res.arrayBuffer(), contentType: res.headers.get('content-type') ?? 'application/octet-stream', diff --git a/packages/api/src/adapter-azure/CosmosNoSqlUnavailableError.ts b/packages/api/src/adapter-azure/CosmosNoSqlUnavailableError.ts new file mode 100644 index 0000000..6469094 --- /dev/null +++ b/packages/api/src/adapter-azure/CosmosNoSqlUnavailableError.ts @@ -0,0 +1,12 @@ +import {CloudError, type CloudErrorStatus} from '../cloud-spi/errors' + +/** + * Raised when none of the known Cosmos NoSQL route shapes answered on the + * Floci-AZ runtime. Keeps its own wire code so the frontend can keep telling + * "Cosmos is not enabled on this runtime" apart from a generic runtime failure. + */ +export class CosmosNoSqlUnavailableError extends CloudError { + readonly status: CloudErrorStatus = 502 + readonly code = 'cosmos_nosql_unavailable' as const + protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime' +} diff --git a/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts index 79f1206..434e27e 100644 --- a/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts +++ b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.test.ts @@ -1,5 +1,6 @@ import {afterEach, describe, expect, test} from 'bun:test' import {GcpCloudFunctionsAdapter} from './GcpCloudFunctionsAdapter' +import {GcpRestRuntimeClient} from '../gcp' const originalFetch = globalThis.fetch const ENDPOINT = 'http://localhost:4588' @@ -10,7 +11,7 @@ afterEach(() => { }) function adapter(): GcpCloudFunctionsAdapter { - return new GcpCloudFunctionsAdapter(ENDPOINT, 'floci-local', 'us-central1') + return new GcpCloudFunctionsAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) } function gen2Function(name: string) { @@ -63,6 +64,8 @@ describe('GcpCloudFunctionsAdapter', () => { revision: 'projects/floci-local/locations/us-central1/functions/hello/revisions/hello-00001', allTrafficOnLatestRevision: true, updateTime: '2026-06-22T05:29:13Z', + // Shared key so one serverless column works across all clouds. + lastModified: '2026-06-22T05:29:13Z', labels: undefined, }, }, diff --git a/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts index 35da431..a1e0406 100644 --- a/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts +++ b/packages/api/src/adapter-gcp/GcpCloudFunctionsAdapter.ts @@ -1,5 +1,6 @@ +import {ValidationError} from '../cloud-spi/errors' import {gcpServerlessSchema} from '../cloud-spi/serverlessSchema' -import {gcpEndpoint, gcpLocation, gcpProject} from '../gcp' +import {gcp, type GcpRuntimeClient} from '../gcp' import type { CloudResource, CloudServiceAdapter, @@ -66,25 +67,24 @@ export class GcpCloudFunctionsAdapter implements CloudServiceAdapter { readonly cloud = 'gcp' as const readonly service = 'serverless' as const - constructor( - private readonly endpoint: string = gcpEndpoint(), - private readonly project: string = gcpProject(), - private readonly location: string = gcpLocation(), - ) {} + constructor(private readonly client: GcpRuntimeClient = gcp) {} schema(): ServiceSchema { return gcpServerlessSchema() } async list(query: ResourceQuery = {}): Promise { - const body = await this.fetchJson(this.functionsPath()) - return filterBySearch((body.functions ?? []).map(toResource), query.search) + const body = await this.client.json(this.functionsPath()) + return filterBySearch((body?.functions ?? []).map(toResource), query.search) } async get(id: string): Promise { - const res = await this.fetch(`${this.functionsPath()}/${encodeURIComponent(id)}`, {method: 'GET'}, true) - if (res.status === 404) return null - return toResource(await res.json() as GcpFunction) + const fn = await this.client.json( + `${this.functionsPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return fn ? toResource(fn) : null } async create(input: CreateResourceInput): Promise { @@ -93,11 +93,11 @@ export class GcpCloudFunctionsAdapter implements CloudServiceAdapter { const entryPoint = stringValue(input.values.entryPoint) const code = stringValue(input.values.code) - if (!functionName) throw new Error('functionName is required') - if (!runtime) throw new Error('runtime is required') - if (!entryPoint) throw new Error('entryPoint is required') + if (!functionName) throw new ValidationError('functionName is required') + if (!runtime) throw new ValidationError('runtime is required') + if (!entryPoint) throw new ValidationError('entryPoint is required') - const operation = await this.fetchJson( + const operation = await this.client.json( `${this.functionsPath()}?functionId=${encodeURIComponent(functionName)}`, { method: 'POST', @@ -113,36 +113,18 @@ export class GcpCloudFunctionsAdapter implements CloudServiceAdapter { ) // create returns an Operation envelope; the function is under `response`. - const fn = operation.response ?? (operation as unknown as GcpFunction) - return toResource(fn) + const fn = operation?.response ?? (operation as unknown as GcpFunction | null) + return toResource(fn ?? {name: functionName}) } async delete(id: string): Promise { - await this.fetch(`${this.functionsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}, true) + await this.client.fetch(`${this.functionsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) } private functionsPath(): string { - return `/v2/projects/${encodeURIComponent(this.project)}/locations/${encodeURIComponent(this.location)}/functions` - } - - private async fetchJson(path: string, init: RequestInit = {}): Promise { - const res = await this.fetch(path, init) - return res.json() as Promise + return `/v2/projects/${encodeURIComponent(this.client.project)}/locations/${encodeURIComponent(this.client.location)}/functions` } - private async fetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { - let res: Response - try { - res = await globalThis.fetch(`${this.endpoint}${path}`, init) - } catch (error) { - throw new Error(`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`) - } - if (emptyOnNotFound && res.status === 404) return res - if (!res.ok) { - throw new Error(`GCP Cloud Functions request failed: HTTP ${res.status}`) - } - return res - } } function toResource(fn: GcpFunction): CloudResource { @@ -172,6 +154,8 @@ function toResource(fn: GcpFunction): CloudResource { revision: serviceConfig.revision, allTrafficOnLatestRevision: serviceConfig.allTrafficOnLatestRevision, updateTime: fn.updateTime, + // Shared key so the serverless schema can surface one column for all clouds. + lastModified: fn.updateTime, labels: fn.labels, }, } @@ -196,7 +180,3 @@ function filterBySearch(resources: CloudResource[], search?: string): CloudResou if (!normalized) return resources return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts new file mode 100644 index 0000000..f834e38 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts @@ -0,0 +1,225 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpCloudSqlAdapter} from './GcpCloudSqlAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const INSTANCES_PATH = '/sql/v1beta4/projects/floci-local/instances' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpCloudSqlAdapter { + return new GcpCloudSqlAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +/** Create answers with this receipt, not the instance. Captured from floci-gcp 0.5.0. */ +function sqlOperation(targetId: string) { + return { + kind: 'sql#operation', + name: 'b4e23845-3325-48e4-95a3-d558f44540e8', + targetId, + targetProject: 'floci-local', + status: 'DONE', + operationType: 'CREATE', + } +} + +/** Shape captured from floci-gcp 0.5.0. */ +function sqlInstance(name: string) { + return { + name, + databaseVersion: 'POSTGRES_15', + region: 'us-central1', + settings: {tier: 'db-f1-micro'}, + kind: 'sql#instance', + project: 'floci-local', + backendType: 'SECOND_GEN', + instanceType: 'CLOUD_SQL_INSTANCE', + state: 'RUNNABLE', + gceZone: 'us-central1-a', + connectionName: `floci-local:us-central1:${name}`, + ipAddresses: [{type: 'PRIMARY', ipAddress: '172.20.0.5', port: 5432}], + } +} + +describe('GcpCloudSqlAdapter', () => { + test('identifies itself as the GCP database adapter', () => { + const instance = adapter() + expect(instance.cloud).toBe('gcp') + expect(instance.service).toBe('database') + expect(instance.schema().displayName).toBe('Cloud SQL') + }) + + test('lists instances and normalizes the sqladmin shape', async () => { + const calls = stubFetch(() => new Response( + JSON.stringify({kind: 'sql#instancesList', items: [sqlInstance('orders-db')]}), + {status: 200}, + )) + + const [resource] = await adapter().list() + + expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}`) + expect(resource).toMatchObject({ + id: 'orders-db', + name: 'orders-db', + cloud: 'gcp', + service: 'database', + type: 'db-instance', + region: 'us-central1', + status: 'RUNNABLE', + engine: 'POSTGRES_15', + instanceClass: 'db-f1-micro', + }) + // The schema surfaces the connection endpoint through a metadata path. + expect(resource?.metadata.connectionName).toBe('floci-local:us-central1:orders-db') + expect(resource?.metadata.ipAddress).toBe('172.20.0.5') + expect(resource?.metadata.port).toBe(5432) + }) + + test('normalizes an empty list payload', async () => { + stubFetch(() => new Response(JSON.stringify({kind: 'sql#instancesList'}), {status: 200})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + stubFetch(() => new Response( + JSON.stringify({items: [sqlInstance('orders-db'), sqlInstance('billing-db')]}), + {status: 200}, + )) + + await expect(adapter().list({search: 'orders'})).resolves.toHaveLength(1) + await expect(adapter().list({search: 'db'})).resolves.toHaveLength(2) + await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a single instance', async () => { + const calls = stubFetch(() => new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200})) + const resource = await adapter().get('orders-db') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`) + expect(resource?.id).toBe('orders-db') + }) + + test('returns null when the instance does not exist', async () => { + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'Cloud SQL instance not found: nope', status: 'NOT_FOUND'}}), + {status: 404}, + )) + await expect(adapter().get('nope')).resolves.toBeNull() + }) + + test('creates an instance with the documented defaults', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200}) + : new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}), + ) + await adapter().create({values: {instanceName: 'orders-db'}}) + + const body = JSON.parse(String(calls[0]?.init?.body)) + expect(calls[0]?.init?.method).toBe('POST') + expect(body).toEqual({ + name: 'orders-db', + databaseVersion: 'POSTGRES_15', + region: 'us-central1', + settings: {tier: 'db-f1-micro'}, + }) + }) + + test('resolves the operation receipt into the created instance', async () => { + // Create returns a sql#operation naming the instance, not the instance — + // echoing the receipt would surface the operation UUID as the resource name. + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200}) + : new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}), + ) + + const resource = await adapter().create({values: {instanceName: 'orders-db'}}) + + expect(resource.id).toBe('orders-db') + expect(resource.status).toBe('RUNNABLE') + // POST, then a read-back of the named instance. + expect(calls).toHaveLength(2) + expect(calls[1]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`) + }) + + test('uses an embedded resource when the runtime provides one', async () => { + const calls = stubFetch(() => new Response( + JSON.stringify({done: true, response: sqlInstance('orders-db')}), + {status: 200}, + )) + + const resource = await adapter().create({values: {instanceName: 'orders-db'}}) + + expect(resource.id).toBe('orders-db') + expect(calls).toHaveLength(1) + }) + + test('passes through an explicit version, region and tier', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200}) + : new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}), + ) + await adapter().create({ + values: {instanceName: 'orders-db', databaseVersion: 'POSTGRES_16', region: 'europe-west1', tier: 'db-g1-small'}, + }) + + const body = JSON.parse(String(calls[0]?.init?.body)) + expect(body.databaseVersion).toBe('POSTGRES_16') + expect(body.region).toBe('europe-west1') + expect(body.settings.tier).toBe('db-g1-small') + }) + + test('requires an instance name', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('rejects a name the runtime would refuse', async () => { + stubFetch(() => new Response('{}', {status: 200})) + for (const name of ['1starts-with-digit', 'Has-Upper', 'has_underscore', 'a'.repeat(63)]) { + await expect(adapter().create({values: {instanceName: name}})).rejects.toBeInstanceOf(ValidationError) + } + }) + + test('deletes an instance', async () => { + const calls = stubFetch(() => new Response('{}', {status: 200})) + await adapter().delete('orders-db') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) + + test('surfaces a missing instance on delete rather than silently succeeding', async () => { + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'Cloud SQL instance not found: nope'}}), + {status: 404}, + )) + await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) + + test("surfaces the runtime's engine restriction verbatim", async () => { + // The emulator only supports PostgreSQL; the reason must reach the user. + stubFetch(() => new Response( + JSON.stringify({error: {code: 400, message: 'Only PostgreSQL Cloud SQL instances are supported'}}), + {status: 400}, + )) + + await expect(adapter().create({values: {instanceName: 'mysql-db', databaseVersion: 'MYSQL_8_0'}})) + .rejects.toThrow('Only PostgreSQL Cloud SQL instances are supported') + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts new file mode 100644 index 0000000..75cce2c --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts @@ -0,0 +1,164 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpDatabaseSchema} from '../cloud-spi/databaseSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import {type GcpOperationEnvelope, operationResponse, operationTargetId} from './operations' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * Cloud SQL through the Floci-GCP emulator, which mirrors the public + * `sqladmin` v1beta4 REST API. Verified against `floci/floci-gcp` 0.5.0: + * + * GET /sql/v1beta4/projects/{project}/instances + * POST /sql/v1beta4/projects/{project}/instances + * GET /sql/v1beta4/projects/{project}/instances/{instance} + * DELETE /sql/v1beta4/projects/{project}/instances/{instance} + * + * The runtime backs each instance with a real Postgres container, so it only + * accepts PostgreSQL and rejects other engines with a 400. + */ + +interface GcpSqlIpAddress { + type?: string + ipAddress?: string + port?: number +} + +interface GcpSqlInstance { + name?: string + databaseVersion?: string + region?: string + state?: string + project?: string + gceZone?: string + backendType?: string + instanceType?: string + connectionName?: string + createTime?: string + ipAddresses?: GcpSqlIpAddress[] + settings?: {tier?: string; dataDiskSizeGb?: string; activationPolicy?: string} +} + +interface GcpSqlInstanceList { + items?: GcpSqlInstance[] +} + +export class GcpCloudSqlAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'database' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpDatabaseSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.instancesPath()) + return filterBySearch((body?.items ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const instance = await this.client.json( + `${this.instancesPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return instance ? toResource(instance) : null + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.instanceName ?? input.values.name) + const databaseVersion = stringValue(input.values.databaseVersion) || 'POSTGRES_15' + const region = stringValue(input.values.region) || 'us-central1' + const tier = stringValue(input.values.tier) || 'db-f1-micro' + + if (!name) throw new ValidationError('instanceName is required') + if (!isValidInstanceName(name)) { + throw new ValidationError( + 'Use a valid Cloud SQL instance name: 1-62 lowercase letters, numbers, or hyphens, starting with a letter.', + ) + } + + const result = await this.client.json | GcpSqlInstance>( + this.instancesPath(), + { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name, databaseVersion, region, settings: {tier}}), + }, + ) + + // sqladmin answers with a `sql#operation` that names the instance but does + // not embed it, so read it back rather than echoing the request. + const embedded = operationResponse(result) + if (embedded) return toResource(embedded) + + const created = await this.get(operationTargetId(result) ?? name) + return created ?? toResource({name, databaseVersion, region, settings: {tier}}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.instancesPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + /** Cheaper than list(): the runtime keeps instance metadata in memory. */ + async health(): Promise { + await this.client.fetch(this.instancesPath(), {method: 'GET'}) + } + + private instancesPath(): string { + return `/sql/v1beta4/projects/${encodeURIComponent(this.client.project)}/instances` + } +} + +function toResource(instance: GcpSqlInstance): CloudResource { + const name = instance.name ?? '' + const primaryIp = instance.ipAddresses?.find((address) => address.type === 'PRIMARY') + + return { + id: name, + name, + cloud: 'gcp', + service: 'database', + type: 'db-instance', + region: instance.region ?? null, + createdAt: instance.createTime ?? null, + status: instance.state ?? null, + engine: instance.databaseVersion ?? null, + version: instance.databaseVersion ?? null, + instanceClass: instance.settings?.tier ?? null, + metadata: { + provider: 'gcp', + databaseService: 'cloud-sql', + project: instance.project, + gceZone: instance.gceZone, + backendType: instance.backendType, + instanceType: instance.instanceType, + connectionName: instance.connectionName, + tier: instance.settings?.tier, + ipAddress: primaryIp?.ipAddress, + port: primaryIp?.port, + ipAddresses: instance.ipAddresses, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidInstanceName(value: string): boolean { + return /^[a-z][a-z0-9-]{0,61}$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts b/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts new file mode 100644 index 0000000..426d287 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts @@ -0,0 +1,199 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpGkeAdapter} from './GcpGkeAdapter' +import {GcpRestRuntimeClient} from '../gcp' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' +const CLUSTERS_PATH = '/container/v1/projects/floci-local/locations/us-central1/clusters' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function adapter(): GcpGkeAdapter { + return new GcpGkeAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1')) +} + +function stubFetch(handler: (url: string, init?: RequestInit) => Response) { + const calls: Array<{url: string; init?: RequestInit}> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({url: String(url), init}) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +/** Shape captured from floci-gcp 0.5.0. */ +function gkeCluster(name: string) { + return { + name, + status: 'RUNNING', + location: 'us-central1', + endpoint: 'localhost:6550', + network: 'default', + subnetwork: 'default', + createTime: '2026-07-28T04:01:57.839066676Z', + currentMasterVersion: '1.30.5-gke.1014001', + currentNodeVersion: '1.30.5-gke.1014001', + initialClusterVersion: '1.30.5-gke.1014001', + nodePools: [{name: 'default-pool', status: 'RUNNING'}], + resourceLabels: {}, + } +} + +/** GKE create answers with an Operation carrying a targetLink, not the cluster. */ +function gkeOperation(clusterName: string) { + return { + name: 'operation-28fabcf7-9df7-44d5-b84a-b59435fd9093', + operationType: 'CREATE_CLUSTER', + status: 'DONE', + zone: 'us-central1', + location: 'us-central1', + targetLink: `projects/floci-local/locations/us-central1/clusters/${clusterName}`, + } +} + +describe('GcpGkeAdapter', () => { + test('identifies itself as the GCP k8s adapter', () => { + const instance = adapter() + expect(instance.cloud).toBe('gcp') + expect(instance.service).toBe('k8s') + expect(instance.schema().displayName).toBe('Google GKE') + }) + + test('talks to the container.googleapis.com path, not the unprefixed one', async () => { + // /v1/projects/{p}/locations/{l}/clusters on this runtime is Managed Service + // for Apache Kafka — same path shape, entirely different resource. Binding + // GKE there would surface Redpanda brokers as Kubernetes clusters. + const calls = stubFetch(() => new Response(JSON.stringify({clusters: []}), {status: 200})) + await adapter().list() + + expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}`) + expect(calls[0]?.url).toContain('/container/v1/') + }) + + test('lists clusters and normalizes the GKE shape', async () => { + stubFetch(() => new Response(JSON.stringify({clusters: [gkeCluster('prod')]}), {status: 200})) + const [resource] = await adapter().list() + + expect(resource).toMatchObject({ + id: 'prod', + name: 'prod', + cloud: 'gcp', + service: 'k8s', + type: 'cluster', + region: 'us-central1', + status: 'RUNNING', + version: '1.30.5-gke.1014001', + }) + expect(resource?.metadata.endpoint).toBe('localhost:6550') + expect(resource?.metadata.nodePoolCount).toBe(1) + }) + + test('reduces a fully qualified cluster path to its name', async () => { + stubFetch(() => new Response(JSON.stringify({ + clusters: [{...gkeCluster('prod'), name: 'projects/floci-local/locations/us-central1/clusters/prod'}], + }), {status: 200})) + + const [resource] = await adapter().list() + expect(resource?.id).toBe('prod') + expect(resource?.name).toBe('prod') + }) + + test('normalizes an empty list payload', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().list()).resolves.toEqual([]) + }) + + test('filters the list by search term', async () => { + stubFetch(() => new Response( + JSON.stringify({clusters: [gkeCluster('prod'), gkeCluster('staging')]}), + {status: 200}, + )) + + await expect(adapter().list({search: 'prod'})).resolves.toHaveLength(1) + await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0) + }) + + test('inspects a single cluster', async () => { + const calls = stubFetch(() => new Response(JSON.stringify(gkeCluster('prod')), {status: 200})) + const resource = await adapter().get('prod') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`) + expect(resource?.id).toBe('prod') + }) + + test('returns null when the cluster does not exist', async () => { + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'cluster not found'}}), + {status: 404}, + )) + await expect(adapter().get('nope')).resolves.toBeNull() + }) + + test('resolves the operation targetLink into the created cluster', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(gkeOperation('prod')), {status: 200}) + : new Response(JSON.stringify(gkeCluster('prod')), {status: 200}), + ) + + const resource = await adapter().create({values: {clusterName: 'prod'}}) + + // The operation names the cluster only via a path, so it is read back. + expect(resource.id).toBe('prod') + expect(resource.status).toBe('RUNNING') + expect(calls).toHaveLength(2) + expect(calls[1]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`) + }) + + test('sends the cluster body the runtime expects', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(gkeOperation('prod')), {status: 200}) + : new Response(JSON.stringify(gkeCluster('prod')), {status: 200}), + ) + await adapter().create({values: {clusterName: 'prod', initialNodeCount: '3'}}) + + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + cluster: {name: 'prod', initialNodeCount: 3}, + }) + }) + + test('defaults the node count', async () => { + const calls = stubFetch((_url, init) => + init?.method === 'POST' + ? new Response(JSON.stringify(gkeOperation('prod')), {status: 200}) + : new Response(JSON.stringify(gkeCluster('prod')), {status: 200}), + ) + await adapter().create({values: {clusterName: 'prod'}}) + + expect(JSON.parse(String(calls[0]?.init?.body)).cluster.initialNodeCount).toBe(1) + }) + + test('requires a cluster name', async () => { + stubFetch(() => new Response('{}', {status: 200})) + await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError) + }) + + test('rejects a name the runtime would refuse', async () => { + stubFetch(() => new Response('{}', {status: 200})) + for (const name of ['1prod', 'Prod', 'has_underscore', 'a'.repeat(41)]) { + await expect(adapter().create({values: {clusterName: name}})).rejects.toBeInstanceOf(ValidationError) + } + }) + + test('deletes a cluster', async () => { + const calls = stubFetch(() => new Response('{}', {status: 200})) + await adapter().delete('prod') + + expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`) + expect(calls[0]?.init?.method).toBe('DELETE') + }) + + test('surfaces a missing cluster on delete', async () => { + stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'not found'}}), {status: 404})) + await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError) + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpGkeAdapter.ts b/packages/api/src/adapter-gcp/GcpGkeAdapter.ts new file mode 100644 index 0000000..eb99b51 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpGkeAdapter.ts @@ -0,0 +1,163 @@ +import {ValidationError} from '../cloud-spi/errors' +import {gcpGkeSchema} from '../cloud-spi/eksSchema' +import {gcp, type GcpRuntimeClient} from '../gcp' +import {type GcpOperationEnvelope, operationResponse, operationTargetId} from './operations' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * GKE through the Floci-GCP emulator, which mirrors the public + * `container.googleapis.com` v1 REST API and backs each cluster with a real k3s + * container. Verified against `floci/floci-gcp` 0.5.0: + * + * GET /container/v1/projects/{project}/locations/{location}/clusters + * POST /container/v1/projects/{project}/locations/{location}/clusters + * GET /container/v1/projects/{project}/locations/{location}/clusters/{cluster} + * DELETE /container/v1/projects/{project}/locations/{location}/clusters/{cluster} + * + * Note the `/container/v1` prefix. The runtime also serves + * `/v1/projects/{p}/locations/{l}/clusters`, but that is Managed Service for + * Apache Kafka — same path shape, entirely different resource — so binding GKE + * to the unprefixed path would surface Kafka brokers as Kubernetes clusters. + */ + +interface GkeNodePool { + name?: string + status?: string + initialNodeCount?: number + version?: string +} + +interface GkeCluster { + name?: string + status?: string + location?: string + endpoint?: string + network?: string + subnetwork?: string + createTime?: string + currentMasterVersion?: string + currentNodeVersion?: string + initialClusterVersion?: string + currentNodeCount?: number + nodePools?: GkeNodePool[] + resourceLabels?: Record +} + +interface GkeClusterList { + clusters?: GkeCluster[] +} + +export class GcpGkeAdapter implements CloudServiceAdapter { + readonly cloud = 'gcp' as const + readonly service = 'k8s' as const + + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + schema(): ServiceSchema { + return gcpGkeSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.client.json(this.clustersPath()) + return filterBySearch((body?.clusters ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const cluster = await this.client.json( + `${this.clustersPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return cluster ? toResource(cluster) : null + } + + async create(input: CreateResourceInput): Promise { + const name = stringValue(input.values.clusterName ?? input.values.name) + const initialNodeCount = Number(input.values.initialNodeCount ?? 1) + + if (!name) throw new ValidationError('clusterName is required') + if (!isValidClusterName(name)) { + throw new ValidationError( + 'Use a valid GKE cluster name: 1-40 lowercase letters, numbers, or hyphens, starting with a letter.', + ) + } + + const result = await this.client.json | GkeCluster>(this.clustersPath(), { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + cluster: { + name, + initialNodeCount: Number.isFinite(initialNodeCount) ? initialNodeCount : 1, + }, + }), + }) + + // GKE answers with an Operation carrying a targetLink, not the cluster. + const embedded = operationResponse(result) + if (embedded) return toResource(embedded) + + const created = await this.get(operationTargetId(result) ?? name) + return created ?? toResource({name}) + } + + async delete(id: string): Promise { + await this.client.fetch(`${this.clustersPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + private clustersPath(): string { + const {project, location} = this.client + return `/container/v1/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/clusters` + } +} + +function toResource(cluster: GkeCluster): CloudResource { + // The list response returns bare names while some paths return full paths. + const name = (cluster.name ?? '').split('/').pop() ?? '' + const version = cluster.currentMasterVersion ?? cluster.initialClusterVersion ?? null + + return { + id: name, + name, + cloud: 'gcp', + service: 'k8s', + type: 'cluster', + region: cluster.location ?? null, + createdAt: cluster.createTime ?? null, + status: cluster.status ?? null, + version, + metadata: { + provider: 'gcp', + k8sService: 'gke', + endpoint: cluster.endpoint, + network: cluster.network, + subnetwork: cluster.subnetwork, + currentMasterVersion: cluster.currentMasterVersion, + currentNodeVersion: cluster.currentNodeVersion, + nodePools: cluster.nodePools, + nodePoolCount: cluster.nodePools?.length ?? 0, + currentNodeCount: cluster.currentNodeCount, + labels: cluster.resourceLabels, + }, + } +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function isValidClusterName(value: string): boolean { + return /^[a-z][a-z0-9-]{0,39}$/.test(value) +} diff --git a/packages/api/src/adapter-gcp/GcpStorageAdapter.ts b/packages/api/src/adapter-gcp/GcpStorageAdapter.ts index 2d96964..f6d36ed 100644 --- a/packages/api/src/adapter-gcp/GcpStorageAdapter.ts +++ b/packages/api/src/adapter-gcp/GcpStorageAdapter.ts @@ -1,5 +1,6 @@ +import {NotFoundError, ValidationError} from '../cloud-spi/errors' import {gcpStorageSchema} from '../cloud-spi/storageSchema' -import {gcpEndpoint, gcpProject} from '../gcp' +import {gcp, type GcpRuntimeClient} from '../gcp' import type { CloudResource, CloudServiceAdapter, @@ -35,53 +36,56 @@ export class GcpStorageAdapter implements CloudServiceAdapter { readonly cloud = 'gcp' as const readonly service = 'storage' as const - constructor( - private readonly endpoint: string = gcpEndpoint(), - private readonly project: string = gcpProject(), - ) {} + constructor(private readonly client: GcpRuntimeClient = gcp) {} + + private get project(): string { + return this.client.project + } schema(): ServiceSchema { return gcpStorageSchema() } async list(query: ResourceQuery = {}): Promise { - const body = await this.fetchJson<{items?: GcpBucket[]}>(`/storage/v1/b?project=${encodeURIComponent(this.project)}`) - return filterBySearch((body.items ?? []).map(toResource), query.search) + const body = await this.client.json<{items?: GcpBucket[]}>(`/storage/v1/b?project=${encodeURIComponent(this.project)}`) + return filterBySearch((body?.items ?? []).map(toResource), query.search) } async get(id: string): Promise { - const res = await this.fetch(`/storage/v1/b/${encodeURIComponent(id)}`, {method: 'GET'}, true) - if (res.status === 404) return null - if (!res) return null - return toResource(await res.json() as GcpBucket) + const bucket = await this.client.json( + `/storage/v1/b/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return bucket ? toResource(bucket) : null } async create(input: CreateResourceInput): Promise { const bucketName = stringValue(input.values.bucketName) - if (!bucketName) throw new Error('bucketName is required') + if (!bucketName) throw new ValidationError('bucketName is required') if (!isValidBucketName(bucketName)) { - throw new Error('Use a valid GCS bucket name: 3-63 lowercase characters, numbers, dots, underscores, or hyphens.') + throw new ValidationError('Use a valid GCS bucket name: 3-63 lowercase characters, numbers, dots, underscores, or hyphens.') } - const body = await this.fetchJson(`/storage/v1/b?project=${encodeURIComponent(this.project)}`, { + const body = await this.client.json(`/storage/v1/b?project=${encodeURIComponent(this.project)}`, { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({name: bucketName}), }) - return toResource(body) + return toResource(body ?? {name: bucketName}) } async delete(id: string): Promise { - await this.fetch(`/storage/v1/b/${encodeURIComponent(id)}`, {method: 'DELETE'}, true) + await this.client.fetch(`/storage/v1/b/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) } async listObjects(resourceId: string, prefix = ''): Promise { const qs = new URLSearchParams({delimiter: '/'}) if (prefix) qs.set('prefix', prefix) - const body = await this.fetchJson<{items?: GcpObject[]; prefixes?: string[]}>(`/storage/v1/b/${encodeURIComponent(resourceId)}/o?${qs}`) + const body = await this.client.json<{items?: GcpObject[]; prefixes?: string[]}>(`/storage/v1/b/${encodeURIComponent(resourceId)}/o?${qs}`) return { prefix, objects: [ - ...(body.prefixes ?? []).map((key): StorageObject => ({ + ...(body?.prefixes ?? []).map((key): StorageObject => ({ key, name: objectName(key, prefix), type: 'folder', @@ -93,7 +97,7 @@ export class GcpStorageAdapter implements CloudServiceAdapter { prefix: key, }, })), - ...(body.items ?? []) + ...(body?.items ?? []) .filter((item) => item.name && item.name !== prefix) .map((item): StorageObject => ({ key: item.name ?? '', @@ -115,7 +119,7 @@ export class GcpStorageAdapter implements CloudServiceAdapter { async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise { const path = `/upload/storage/v1/b/${encodeURIComponent(resourceId)}/o?uploadType=media&name=${encodeURIComponent(key)}` - await this.fetch(path, { + await this.client.fetch(path, { method: 'POST', headers: {'content-type': contentType}, body: copyBytes(body), @@ -123,7 +127,11 @@ export class GcpStorageAdapter implements CloudServiceAdapter { } async getObject(resourceId: string, key: string): Promise { - const res = await this.fetch(`/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}?alt=media`, {method: 'GET'}) + const res = await this.client.fetch( + `/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}?alt=media`, + {method: 'GET'}, + ) + if (!res) throw new NotFoundError(`Object ${key} not found in bucket ${resourceId}`) return { body: await res.arrayBuffer(), contentType: res.headers.get('content-type') ?? 'application/octet-stream', @@ -132,35 +140,17 @@ export class GcpStorageAdapter implements CloudServiceAdapter { } async deleteObject(resourceId: string, key: string): Promise { - await this.fetch(`/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}`, {method: 'DELETE'}, true) + await this.client.fetch(`/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}`, {method: 'DELETE'}, {emptyOnNotFound: true}) } async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise { const destBucket = destResourceId ?? srcResourceId - await this.fetch( + await this.client.fetch( `/storage/v1/b/${encodeURIComponent(srcResourceId)}/o/${encodeURIComponent(srcKey)}/copyTo/b/${encodeURIComponent(destBucket)}/o/${encodeURIComponent(destKey)}`, {method: 'POST'}, ) } - private async fetchJson(path: string, init: RequestInit = {}): Promise { - const res = await this.fetch(path, init) - return res.json() as Promise - } - - private async fetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { - let res: Response - try { - res = await globalThis.fetch(`${this.endpoint}${path}`, init) - } catch (error) { - throw new Error(`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`) - } - if (emptyOnNotFound && res.status === 404) return res - if (!res.ok && !(emptyOnNotFound && res.status === 404)) { - throw new Error(`GCP Storage request failed: HTTP ${res.status}`) - } - return res - } } function toResource(bucket: GcpBucket): CloudResource { @@ -212,7 +202,3 @@ function copyBytes(bytes: Uint8Array): ArrayBuffer { function isValidBucketName(value: string): boolean { return /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/.test(value) } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/packages/api/src/adapter-gcp/operations.ts b/packages/api/src/adapter-gcp/operations.ts new file mode 100644 index 0000000..207108f --- /dev/null +++ b/packages/api/src/adapter-gcp/operations.ts @@ -0,0 +1,62 @@ +/** + * Google's REST APIs answer mutations with a long-running-operation envelope + * rather than the resource, and the shape differs per service. All three are + * present on the local runtime: + * + * - Cloud Functions / Cloud Run: `{done, response: }` + * - Cloud SQL (`sql#operation`): `{status: 'DONE', targetId: }` + * - GKE: `{status: 'DONE', operationType: 'CREATE_CLUSTER', targetLink: }` + * + * Only the first embeds the resource; the others name it and expect a re-read. + * These helpers keep that discrimination in one place instead of each adapter + * guessing whether it received a resource or a receipt for one. + */ + +export interface GcpOperationEnvelope { + kind?: string + /** Cloud Functions / GKE / Cloud Run. */ + done?: boolean + response?: T + /** Cloud SQL. */ + status?: string + targetId?: string + operationType?: string + /** GKE: a resource path whose last segment is the name. */ + targetLink?: string + error?: unknown +} + +/** True when the payload is an operation receipt rather than the resource. */ +export function isOperationEnvelope(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false + const envelope = payload as GcpOperationEnvelope + return ( + envelope.kind?.endsWith('#operation') === true || + typeof envelope.done === 'boolean' || + (typeof envelope.status === 'string' && typeof envelope.operationType === 'string') + ) +} + +/** + * Unwrap an embedded resource. Returns null when the operation carries only a + * reference, in which case the caller should read the resource back by name. + */ +export function operationResponse(payload: GcpOperationEnvelope | T | null): T | null { + if (!payload) return null + if (!isOperationEnvelope(payload)) return payload as T + + const envelope = payload as GcpOperationEnvelope + return envelope.response ?? null +} + +/** + * The resource name an operation acted on, when it reports one. Accepts either a + * bare id (Cloud SQL) or a resource path (GKE), returning the final segment. + */ +export function operationTargetId(payload: GcpOperationEnvelope | T | null): string | null { + if (!payload || !isOperationEnvelope(payload)) return null + const envelope = payload as GcpOperationEnvelope + if (envelope.targetId) return envelope.targetId + if (envelope.targetLink) return envelope.targetLink.split('/').pop() ?? null + return null +} diff --git a/packages/api/src/azure.test.ts b/packages/api/src/azure.test.ts index 4cc9e9e..35db2cb 100644 --- a/packages/api/src/azure.test.ts +++ b/packages/api/src/azure.test.ts @@ -8,6 +8,22 @@ afterEach(() => { }) describe('AzureRestRuntimeClient', () => { + test('can omit the Blob Storage API version header', async () => { + let requestHeaders: HeadersInit | undefined + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + requestHeaders = init?.headers + return new Response('{}') + }) as unknown as typeof fetch + + const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1') + + await client.fetch('/devstoreaccount1-keyvault/secrets', {method: 'GET'}, { + includeStorageApiVersion: false, + }) + + expect(new Headers(requestHeaders).has('x-ms-version')).toBe(false) + }) + test('adds endpoint context to network failures', async () => { globalThis.fetch = (async () => { throw new Error('connection refused') diff --git a/packages/api/src/azure.ts b/packages/api/src/azure.ts index b8ea2ba..d5a643a 100644 --- a/packages/api/src/azure.ts +++ b/packages/api/src/azure.ts @@ -1,5 +1,8 @@ +import {RuntimeUnavailableError, httpStatusToCloudError} from './cloud-spi/errors' + export interface AzureRuntimeFetchOptions { emptyOnNotFound?: boolean + includeStorageApiVersion?: boolean } export interface AzureRuntimeClient { @@ -20,18 +23,26 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient { res = await globalThis.fetch(`${this.endpoint}${path}`, { ...init, headers: { - 'x-ms-version': '2021-12-02', + ...(options.includeStorageApiVersion === false ? {} : {'x-ms-version': '2021-12-02'}), ...(init.headers ?? {}), }, }) } catch (error) { - throw new Error(`Cannot reach Floci-AZ at ${this.endpoint}: ${errorMessage(error)}`) + throw new RuntimeUnavailableError( + `Cannot reach Floci-AZ at ${this.endpoint}: ${errorMessage(error)}`, + {cause: error}, + ) } if (options.emptyOnNotFound && res.status === 404) return null if (!res.ok) { const detail = await safeResponseText(res) - throw new Error(`Azure runtime request failed: HTTP ${res.status} ${path}${detail ? ` - ${detail}` : ''}`) + // A 501 here is the runtime declaring the operation missing (e.g. floci-az + // has no /functions), which must surface as such rather than a bare 502. + throw httpStatusToCloudError( + res.status, + `Azure runtime request failed: HTTP ${res.status} ${path}${detail ? ` - ${detail}` : ''}`, + ) } return res diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index f02f053..5befc83 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -8,6 +8,16 @@ const databaseColumns: TableColumnSchema[] = [ {name: 'instanceClass', label: 'Class'}, ] +/** Cloud SQL reports a connection endpoint, which RDS does not surface here. */ +const cloudSqlColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'engine', label: 'Version'}, + {name: 'region', label: 'Region'}, + {name: 'instanceClass', label: 'Tier'}, + {name: 'connectionName', label: 'Connection', path: 'metadata.connectionName', format: 'code'}, +] + const databaseFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, ] @@ -67,10 +77,53 @@ export function gcpDatabaseSchema(): ServiceSchema { cloud: 'gcp', service: 'database', displayName: 'Cloud SQL', - fields: [], - actions: ['list', 'inspect'], + fields: [ + { + name: 'instanceName', + label: 'Instance Name', + type: 'text', + required: true, + description: 'Lowercase letters, numbers, and hyphens; must start with a letter.', + }, + { + name: 'databaseVersion', + label: 'Database Version', + type: 'select', + required: false, + // The runtime backs instances with real Postgres containers and + // rejects every other engine, so this is not the full GCP list. + description: 'The local runtime supports PostgreSQL only.', + options: [ + {label: 'PostgreSQL 15', value: 'POSTGRES_15'}, + {label: 'PostgreSQL 16', value: 'POSTGRES_16'}, + ], + }, + { + name: 'region', + label: 'Region', + type: 'text', + required: false, + description: 'Defaults to us-central1.', + }, + { + name: 'tier', + label: 'Machine Tier', + type: 'text', + required: false, + description: 'Defaults to db-f1-micro.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], filters: databaseFilters, - columns: databaseColumns, + columns: cloudSqlColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List instances', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect instance', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, } } diff --git a/packages/api/src/cloud-spi/eksSchema.ts b/packages/api/src/cloud-spi/eksSchema.ts index 80c57d2..beaa942 100644 --- a/packages/api/src/cloud-spi/eksSchema.ts +++ b/packages/api/src/cloud-spi/eksSchema.ts @@ -7,6 +7,16 @@ const eksColumns: TableColumnSchema[] = [ {name: 'createdAt', label: 'Created At'}, ] +/** GKE reports an API endpoint and node pools, which the EKS list does not. */ +const gkeColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'version', label: 'Version'}, + {name: 'region', label: 'Location'}, + {name: 'endpoint', label: 'Endpoint', path: 'metadata.endpoint', format: 'code'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + const eksFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, ] @@ -40,10 +50,33 @@ export function gcpGkeSchema(): ServiceSchema { cloud: 'gcp', service: 'k8s', displayName: 'Google GKE', - fields: [], - actions: ['list', 'inspect'], + fields: [ + { + name: 'clusterName', + label: 'Cluster Name', + type: 'text', + required: true, + description: 'Lowercase letters, numbers, and hyphens; must start with a letter.', + }, + { + name: 'initialNodeCount', + label: 'Initial Node Count', + type: 'text', + required: false, + description: 'Defaults to 1. The local runtime backs the cluster with a single k3s container.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], filters: eksFilters, - columns: eksColumns, + columns: gkeColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List clusters', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create cluster', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete cluster', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect cluster', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, } } diff --git a/packages/api/src/cloud-spi/errors.test.ts b/packages/api/src/cloud-spi/errors.test.ts new file mode 100644 index 0000000..1995be9 --- /dev/null +++ b/packages/api/src/cloud-spi/errors.test.ts @@ -0,0 +1,134 @@ +import {describe, expect, test} from 'bun:test' +import { + AccessDeniedError, + type CloudError, + type CloudErrorCode, + type CloudErrorStatus, + ConflictError, + NotFoundError, + NotImplementedByRuntimeError, + NotSupportedError, + RateLimitedError, + RuntimeError, + RuntimeUnavailableError, + ValidationError, + httpStatusToCloudError, + isUnreachableCause, + toHttpError, +} from './errors' + +describe('CloudError wire shape', () => { + test('keeps the generic label in message and the raw text in detail', () => { + const body = new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577').toBody() + + expect(body.code).toBe('runtime_unavailable') + expect(body.message).toBe('Runtime unavailable') + expect(body.error).toBe(body.message) + expect(body.detail).toBe('Cannot reach Floci-AZ at http://localhost:4577') + }) + + test('omits detail when it would duplicate the message', () => { + // ValidationError intentionally uses its own message as the label, so a + // field-level complaint reaches the UI verbatim instead of being generified. + const body = new ValidationError('bucketName is required').toBody() + + expect(body.message).toBe('bucketName is required') + expect(body.detail).toBeUndefined() + }) + + test('preserves the cause chain for debugging', () => { + const cause = new Error('socket hang up') + expect(new RuntimeError('wrapped', {cause}).cause).toBe(cause) + }) + + const statuses: Array<[CloudError, CloudErrorStatus, CloudErrorCode]> = [ + [new ValidationError('x'), 400, 'invalid_request'], + [new AccessDeniedError('x'), 403, 'access_denied'], + [new NotFoundError('x'), 404, 'resource_not_found'], + [new ConflictError('x'), 409, 'resource_conflict'], + [new RateLimitedError('x'), 429, 'rate_limited'], + [new NotSupportedError('x'), 501, 'operation_not_supported'], + [new NotImplementedByRuntimeError('x'), 501, 'operation_not_implemented'], + [new RuntimeError('x'), 502, 'runtime_error'], + [new RuntimeUnavailableError('x'), 503, 'runtime_unavailable'], + ] + + for (const [error, status, code] of statuses) { + test(`${error.name} carries ${status}/${code}`, () => { + expect(error.status).toBe(status) + expect(error.code).toBe(code) + expect(toHttpError(error).status).toBe(status) + }) + } +}) + +describe('httpStatusToCloudError', () => { + const cases: Array<[number, CloudErrorStatus, CloudErrorCode]> = [ + [400, 400, 'invalid_request'], + [401, 403, 'access_denied'], + [403, 403, 'access_denied'], + [404, 404, 'resource_not_found'], + [409, 409, 'resource_conflict'], + [429, 429, 'rate_limited'], + [501, 501, 'operation_not_implemented'], + [502, 503, 'runtime_unavailable'], + [503, 503, 'runtime_unavailable'], + [504, 503, 'runtime_unavailable'], + [418, 502, 'runtime_error'], + ] + + for (const [runtimeStatus, expectedStatus, expectedCode] of cases) { + test(`HTTP ${runtimeStatus} becomes ${expectedStatus}`, () => { + const error = httpStatusToCloudError(runtimeStatus, `HTTP ${runtimeStatus}`) + expect(error.status).toBe(expectedStatus) + expect(error.code).toBe(expectedCode) + }) + } +}) + +describe('isUnreachableCause', () => { + test('detects a nested transport failure code', () => { + const inner = Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}) + expect(isUnreachableCause(new Error('fetch failed', {cause: inner}))).toBe(true) + }) + + test('detects an aborted or timed-out request by name', () => { + const timeout = new Error('timed out') + timeout.name = 'TimeoutError' + expect(isUnreachableCause(timeout)).toBe(true) + }) + + test('does not treat an ordinary error as unreachable', () => { + expect(isUnreachableCause(new Error('bucket already exists'))).toBe(false) + expect(isUnreachableCause('not an error')).toBe(false) + }) +}) + +describe('toHttpError', () => { + test('defaults an unknown failure to 502 rather than guessing', () => { + const {status, body} = toHttpError(new Error('something odd happened')) + + expect(status).toBe(502) + expect(body.code).toBe('runtime_error') + expect(body.detail).toBe('something odd happened') + }) + + test('promotes a transport failure to 503 without any message matching', () => { + const inner = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:4566'), {code: 'ECONNREFUSED'}) + expect(toHttpError(new Error('fetch failed', {cause: inner})).status).toBe(503) + }) + + test('lets a vendor mapper win over the generic fallback', () => { + const {status} = toHttpError(new Error('vendor specific'), () => new ConflictError('already exists')) + expect(status).toBe(409) + }) + + test('a CloudError outranks the vendor mapper', () => { + const {status} = toHttpError(new NotFoundError('missing'), () => new ConflictError('nope')) + expect(status).toBe(404) + }) + + test('handles a non-Error throw', () => { + expect(toHttpError('boom').status).toBe(502) + }) +}) diff --git a/packages/api/src/cloud-spi/errors.ts b/packages/api/src/cloud-spi/errors.ts new file mode 100644 index 0000000..f7a871f --- /dev/null +++ b/packages/api/src/cloud-spi/errors.ts @@ -0,0 +1,183 @@ +/** + * Typed errors for the cloud SPI. + * + * Adapters throw these instead of bare `Error`s so `routes/clouds.ts` can map a + * failure to an HTTP status without pattern-matching on message text. The wire + * shape is deliberately unchanged from the hand-rolled mapper it replaces: + * `message` carries a generic label and `detail` carries the raw runtime text. + */ + +export type CloudErrorCode = + | 'invalid_request' + | 'access_denied' + | 'resource_not_found' + | 'resource_conflict' + | 'rate_limited' + | 'operation_not_supported' + | 'operation_not_implemented' + | 'runtime_unavailable' + | 'runtime_error' + | 'cosmos_nosql_unavailable' + +export type CloudErrorStatus = 400 | 403 | 404 | 409 | 429 | 501 | 502 | 503 + +export interface CloudErrorBody { + /** Mirrors `message`; kept for clients that read `error`. */ + error: string + code: string + message: string + detail?: string +} + +export abstract class CloudError extends Error { + abstract readonly status: CloudErrorStatus + abstract readonly code: CloudErrorCode + + /** + * Generic, user-facing label. `message` holds the raw runtime text and is + * surfaced as `detail`, so a label change never leaks adapter internals. + */ + protected abstract readonly label: string + + constructor(message: string, options?: {cause?: unknown}) { + super(message, options?.cause === undefined ? undefined : {cause: options.cause}) + this.name = new.target.name + } + + toBody(): CloudErrorBody { + const message = this.label + const detail = this.message + return { + error: message, + code: this.code, + message, + ...(detail && detail !== message ? {detail} : {}), + } + } +} + +/** 400 — the caller sent something unusable. The raw message is the label. */ +export class ValidationError extends CloudError { + readonly status = 400 as const + readonly code = 'invalid_request' as const + protected get label(): string { + return this.message + } +} + +export class AccessDeniedError extends CloudError { + readonly status = 403 as const + readonly code = 'access_denied' as const + protected readonly label = 'Access denied by the runtime' +} + +export class NotFoundError extends CloudError { + readonly status = 404 as const + readonly code = 'resource_not_found' as const + protected readonly label = 'Resource not found' +} + +export class ConflictError extends CloudError { + readonly status = 409 as const + readonly code = 'resource_conflict' as const + protected readonly label = 'Resource already exists or is in use' +} + +export class RateLimitedError extends CloudError { + readonly status = 429 as const + readonly code = 'rate_limited' as const + protected readonly label = 'Runtime is throttling requests' +} + +/** 501 — this adapter does not implement the operation at all. */ +export class NotSupportedError extends CloudError { + readonly status = 501 as const + readonly code = 'operation_not_supported' as const + protected readonly label = 'Operation is not supported by this adapter' +} + +/** 501 — the adapter supports it but the local runtime answered NotImplemented. */ +export class NotImplementedByRuntimeError extends CloudError { + readonly status = 501 as const + readonly code = 'operation_not_implemented' as const + protected readonly label = 'Operation is not implemented by the selected runtime' +} + +export class RuntimeUnavailableError extends CloudError { + readonly status = 503 as const + readonly code = 'runtime_unavailable' as const + protected readonly label = 'Runtime unavailable' +} + +export class RuntimeError extends CloudError { + readonly status = 502 as const + readonly code = 'runtime_error' as const + protected readonly label = 'Runtime request failed' +} + +/** Transport-level failure codes that mean "nothing is listening over there". */ +const UNREACHABLE_CAUSE_CODES = new Set([ + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', +]) + +/** + * Map a runtime HTTP status onto the matching `CloudError`. Shared by the Azure + * and GCP REST clients so both report a 404 or a 501 the same way. + */ +export function httpStatusToCloudError(status: number, message: string, options?: {cause?: unknown}): CloudError { + if (status === 400) return new ValidationError(message, options) + if (status === 401 || status === 403) return new AccessDeniedError(message, options) + if (status === 404) return new NotFoundError(message, options) + if (status === 409) return new ConflictError(message, options) + if (status === 429) return new RateLimitedError(message, options) + if (status === 501) return new NotImplementedByRuntimeError(message, options) + if (status === 502 || status === 503 || status === 504) return new RuntimeUnavailableError(message, options) + return new RuntimeError(message, options) +} + +/** True when the failure is a transport error rather than an HTTP response. */ +export function isUnreachableCause(err: unknown): boolean { + if (!(err instanceof Error)) return false + if (err.name === 'TimeoutError' || err.name === 'AbortError') return true + + let cause: unknown = err + for (let depth = 0; depth < 5 && cause instanceof Error; depth += 1) { + const code = (cause as {code?: unknown}).code + if (typeof code === 'string' && UNREACHABLE_CAUSE_CODES.has(code)) return true + cause = (cause as {cause?: unknown}).cause + } + return false +} + +/** + * The single place adapter failures become HTTP responses. + * + * `mapVendorError` lets a provider contribute SDK-specific knowledge (see + * `adapter-aws/awsErrors.ts`) without `cloud-spi` importing any vendor package. + */ +export function toHttpError( + err: unknown, + mapVendorError?: (err: unknown) => CloudError | null, +): {status: CloudErrorStatus; body: CloudErrorBody} { + const mapped = resolveCloudError(err, mapVendorError) + return {status: mapped.status, body: mapped.toBody()} +} + +function resolveCloudError(err: unknown, mapVendorError?: (err: unknown) => CloudError | null): CloudError { + if (err instanceof CloudError) return err + + const vendor = mapVendorError?.(err) + if (vendor) return vendor + + const message = err instanceof Error ? err.message : 'Runtime request failed' + if (isUnreachableCause(err)) return new RuntimeUnavailableError(message, {cause: err}) + + return new RuntimeError(message, {cause: err}) +} diff --git a/packages/api/src/cloud-spi/networkingSchema.ts b/packages/api/src/cloud-spi/networkingSchema.ts index 158d29c..82a0a03 100644 --- a/packages/api/src/cloud-spi/networkingSchema.ts +++ b/packages/api/src/cloud-spi/networkingSchema.ts @@ -2,8 +2,8 @@ import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types' const networkingColumns: TableColumnSchema[] = [ {name: 'name', label: 'Name'}, - {name: 'version', label: 'CIDR'}, - {name: 'status', label: 'State'}, + {name: 'version', label: 'CIDR', path: 'metadata.cidrBlock', format: 'code'}, + {name: 'status', label: 'State', format: 'badge'}, {name: 'type', label: 'Type'}, ] @@ -22,10 +22,27 @@ export function awsNetworkingSchema(): ServiceSchema { columns: networkingColumns, capabilities: { resourceActions: [ - {name: 'list', label: 'VPCs', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'create', label: 'Create resources', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'delete', label: 'Delete resources', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'list', label: 'VPCs', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect', enabled: true, status: 'available', runtimeRequired: true}, + // The generic form cannot express the dependent selectors these + // need, so both live in the Networking panel. Advertising them as + // available here produced a 502 telling the user to go elsewhere. + { + name: 'create', + label: 'Create resources', + enabled: false, + status: 'partial', + reason: 'Use the Networking panel — VPC and subnet creation need dependent selectors.', + runtimeRequired: true, + }, + { + name: 'delete', + label: 'Delete resources', + enabled: false, + status: 'partial', + reason: 'Use the Networking panel — deletion must resolve dependent networking resources first.', + runtimeRequired: true, + }, ], }, } diff --git a/packages/api/src/cloud-spi/secretsSchema.ts b/packages/api/src/cloud-spi/secretsSchema.ts new file mode 100644 index 0000000..62919bf --- /dev/null +++ b/packages/api/src/cloud-spi/secretsSchema.ts @@ -0,0 +1,68 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +// The hyphen is escaped so the pattern also compiles under the `v` flag used for +// HTML pattern validation in the browser. +export const SECRET_NAME_PATTERN = '^[0-9A-Za-z\\-]{1,127}$' +export const SECRET_NAME_MESSAGE = 'Use a valid Key Vault secret name: 1-127 letters, numbers, or hyphens.' + +const secretsFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +// The list endpoint returns base secret identifiers without a version, so a Version +// column would be blank for every row. Versions are surfaced on inspect instead. +const secretsColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Secret Name'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, +] + +export function azureSecretsSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'secrets', + displayName: 'Key Vault', + fields: [ + { + name: 'secretName', + label: 'Secret Name', + type: 'text', + required: true, + description: 'Unique Key Vault secret name.', + validation: { + minLength: 1, + maxLength: 127, + pattern: SECRET_NAME_PATTERN, + message: SECRET_NAME_MESSAGE, + }, + }, + { + name: 'secretValue', + label: 'Secret Value', + type: 'password', + required: true, + description: 'Value stored in the secret.', + span: true, + }, + { + name: 'contentType', + label: 'Content Type', + type: 'text', + required: false, + description: 'Optional content type, for example application/json.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: secretsFilters, + columns: secretsColumns, + } +} + diff --git a/packages/api/src/cloud-spi/serverlessSchema.ts b/packages/api/src/cloud-spi/serverlessSchema.ts index 6a6f7f4..f8e1dfc 100644 --- a/packages/api/src/cloud-spi/serverlessSchema.ts +++ b/packages/api/src/cloud-spi/serverlessSchema.ts @@ -1,15 +1,28 @@ -import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' +import type {CapabilitySchema, CloudProvider, FieldSchema, ResourceActionName, ServiceSchema, TableColumnSchema} from './types' const serverlessColumns: TableColumnSchema[] = [ {name: 'name', label: 'Function Name'}, {name: 'type', label: 'Type'}, {name: 'cloud', label: 'Cloud'}, {name: 'region', label: 'Region'}, - {name: 'runtime', label: 'Runtime'}, + {name: 'runtime', label: 'Runtime', path: 'metadata.runtime'}, {name: 'status', label: 'Status'}, - {name: 'updatedAt', label: 'Last Updated'}, + {name: 'updatedAt', label: 'Last Updated', path: 'metadata.lastModified', format: 'datetime'}, ] +/** Invoke is the verb that distinguishes serverless from every other category. */ +function serverlessResourceActions( + invoke: CapabilitySchema, +): CapabilitySchema[] { + return [ + {name: 'list', label: 'List functions', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create function', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete function', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect function', enabled: true, status: 'available', runtimeRequired: false}, + invoke, + ] +} + const serverlessFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, {name: 'runtime', label: 'Runtime', type: 'text', required: false}, @@ -86,6 +99,11 @@ export function awsServerlessSchema(): ServiceSchema { actions: ['list', 'create', 'inspect', 'delete'], filters: serverlessFilters, columns: serverlessColumns, + capabilities: { + resourceActions: serverlessResourceActions({ + name: 'invoke', label: 'Invoke function', enabled: true, status: 'available', runtimeRequired: true, + }), + }, } } @@ -147,6 +165,11 @@ export function azureServerlessSchema(): ServiceSchema { actions: ['list', 'create', 'inspect', 'delete'], filters: serverlessFilters, columns: serverlessColumns, + capabilities: { + resourceActions: serverlessResourceActions({ + name: 'invoke', label: 'Invoke function', enabled: true, status: 'available', runtimeRequired: true, + }), + }, } } @@ -195,6 +218,16 @@ export function gcpServerlessSchema(): ServiceSchema { actions: ['list', 'create', 'inspect', 'delete'], filters: serverlessFilters, columns: serverlessColumns, + capabilities: { + resourceActions: serverlessResourceActions({ + name: 'invoke', + label: 'Invoke function', + enabled: false, + status: 'coming_soon', + reason: 'The Cloud Functions :call endpoint is not wired through the adapter yet.', + runtimeRequired: true, + }), + }, } } diff --git a/packages/api/src/cloud-spi/serviceCatalog.test.ts b/packages/api/src/cloud-spi/serviceCatalog.test.ts new file mode 100644 index 0000000..bc6fcde --- /dev/null +++ b/packages/api/src/cloud-spi/serviceCatalog.test.ts @@ -0,0 +1,82 @@ +import {describe, expect, test} from 'bun:test' +import { + SERVICE_CATALOG, + SERVICE_CATALOG_ENTRIES, + SERVICE_GROUP_ORDER, + SERVICE_TYPES, + catalogEntry, + displayNameFor, + isServiceType, + routeFor, +} from './serviceCatalog' + +describe('SERVICE_CATALOG', () => { + test('every entry carries the metadata the nav needs', () => { + for (const entry of SERVICE_CATALOG_ENTRIES) { + expect(entry.displayName.length).toBeGreaterThan(0) + expect(entry.iconKey.length).toBeGreaterThan(0) + expect(SERVICE_GROUP_ORDER).toContain(entry.group) + expect(Number.isFinite(entry.order)).toBe(true) + expect(routeFor(entry).length).toBeGreaterThan(0) + } + }) + + test('exposes one entry per catalog key', () => { + expect(SERVICE_CATALOG_ENTRIES).toHaveLength(Object.keys(SERVICE_CATALOG).length) + expect(new Set(SERVICE_TYPES).size).toBe(SERVICE_TYPES.length) + }) + + test('orders entries by group then in-group order', () => { + const positions = SERVICE_CATALOG_ENTRIES.map((entry) => [ + SERVICE_GROUP_ORDER.indexOf(entry.group), + entry.order, + ]) + + for (let i = 1; i < positions.length; i += 1) { + const [prevGroup, prevOrder] = positions[i - 1]! + const [group, order] = positions[i]! + expect(prevGroup < group || (prevGroup === group && prevOrder <= order)).toBe(true) + } + }) + + test('routes default to the slug and stay absolute for legacy pages', () => { + expect(routeFor(catalogEntry('storage')!)).toBe('storage') + // Secrets Manager still lives outside Cloud Explorer. + expect(routeFor(catalogEntry('secrets')!)).toBe('/secretsmanager') + }) + + test('resolves per-cloud routes so one category can span a legacy page and the explorer', () => { + const secrets = catalogEntry('secrets')! + expect(routeFor(secrets, 'aws')).toBe('/secretsmanager') + expect(routeFor(secrets, 'azure')).toBe('secrets') + // A category with no per-cloud override is unaffected by the cloud argument. + expect(routeFor(catalogEntry('storage')!, 'azure')).toBe('storage') + }) + + test('resolves per-cloud display names', () => { + const k8s = catalogEntry('k8s')! + expect(displayNameFor(k8s, 'aws')).toBe('EKS') + expect(displayNameFor(k8s, 'azure')).toBe('AKS') + expect(displayNameFor(k8s, 'gcp')).toBe('GKE') + // No override -> the shared display name. + expect(displayNameFor(catalogEntry('storage')!, 'gcp')).toBe('Storage') + }) +}) + +describe('isServiceType', () => { + test('accepts every catalog key', () => { + for (const service of SERVICE_TYPES) { + expect(isServiceType(service)).toBe(true) + } + }) + + test('rejects unknown slugs so routes 404 instead of failing later', () => { + for (const slug of ['ledger', 'stroage', '', 'constructor', '__proto__', 'toString']) { + expect(isServiceType(slug)).toBe(false) + } + }) + + test('catalogEntry returns undefined for an unknown slug', () => { + expect(catalogEntry('ledger')).toBeUndefined() + }) +}) diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts new file mode 100644 index 0000000..c45322a --- /dev/null +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -0,0 +1,136 @@ +import type {CloudAvailability, CloudProvider} from './types' + +/** + * The single source of truth for which services the console knows about. + * + * Availability is NOT declared here — it is derived per cloud from whether an + * adapter is registered (see `CloudProxyService.services`). This file only + * carries the presentation metadata the nav needs, which is why adding a + * service is one row here plus one adapter, with no frontend edit. + * + * `CloudServiceType` is derived from these keys, so a new row is also a new + * route-addressable service type. Keeping the union closed means an unknown + * slug is a 404 instead of a service that silently 501s later. + */ + +/** Sidebar grouping. A flat list stops being usable past ~12 services. */ +export type ServiceGroup = + | 'Compute' + | 'Storage' + | 'Databases' + | 'Networking' + | 'Integration' + | 'Security' + | 'Observability' + +export const SERVICE_GROUP_ORDER: ServiceGroup[] = [ + 'Compute', + 'Storage', + 'Databases', + 'Networking', + 'Integration', + 'Security', + 'Observability', +] + +export interface ServiceCatalogMetadata { + displayName: string + /** Per-cloud display override, e.g. 'EKS' vs 'AKS' vs 'GKE'. */ + displayNameByCloud?: Partial> + /** Resolved to a component client-side; an unknown key degrades to a default. */ + iconKey: string + group: ServiceGroup + /** Sort order within the group. */ + order: number + /** Defaults to the catalog key. A leading '/' marks a page outside Cloud Explorer. */ + route?: string + /** + * Per-cloud route override. Needed when one category is served by a legacy + * standalone page on one cloud and by Cloud Explorer on another — AWS secrets + * still live at /secretsmanager while Azure Key Vault is a normal explorer route. + */ + routeByCloud?: Partial> + /** + * Escape hatch for a service whose UI predates the SPI, so availability + * cannot come from the registry. Every entry here is migration debt — delete + * the field once a real adapter exists. + */ + legacyAvailability?: Partial> +} + +export const SERVICE_CATALOG = { + compute: {displayName: 'Compute', iconKey: 'compute', group: 'Compute', order: 10}, + k8s: { + displayName: 'k8s Engine', + displayNameByCloud: {aws: 'EKS', azure: 'AKS', gcp: 'GKE'}, + iconKey: 'k8s', + group: 'Compute', + order: 20, + }, + serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30}, + storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10}, + database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10}, + networking: {displayName: 'Networking', iconKey: 'networking', group: 'Networking', order: 10}, + queue: { + displayName: 'Queue', + displayNameByCloud: {aws: 'SQS'}, + iconKey: 'queue', + group: 'Integration', + order: 10, + }, + secrets: { + displayName: 'Secrets Manager', + displayNameByCloud: {azure: 'Key Vault'}, + iconKey: 'secrets', + group: 'Security', + order: 10, + route: '/secretsmanager', + // Azure Key Vault is a normal Cloud Explorer service, so it uses the catalog + // slug rather than the legacy standalone page AWS still points at. + routeByCloud: {azure: 'secrets'}, + // Migration debt: the AWS Secrets Manager page still lives outside Cloud + // Explorer, so there is no adapter to derive availability from. + legacyAvailability: {aws: 'available'}, + }, +} as const satisfies Record + +export type CloudServiceType = keyof typeof SERVICE_CATALOG + +export interface ServiceCatalogEntry extends ServiceCatalogMetadata { + service: CloudServiceType +} + +/** Every known service, sorted by group then in-group order. */ +export const SERVICE_CATALOG_ENTRIES: ServiceCatalogEntry[] = ( + Object.keys(SERVICE_CATALOG) as CloudServiceType[] +) + .map((service) => ({service, ...SERVICE_CATALOG[service]})) + .sort(compareCatalogEntries) + +export const SERVICE_TYPES: CloudServiceType[] = SERVICE_CATALOG_ENTRIES.map((entry) => entry.service) + +export function catalogEntry(service: string): ServiceCatalogEntry | undefined { + return isServiceType(service) ? {service, ...SERVICE_CATALOG[service]} : undefined +} + +/** + * Route guard. A slug missing from the catalog is a 404 rather than a service + * that renders and then fails on every call. + */ +export function isServiceType(value: string): value is CloudServiceType { + return Object.hasOwn(SERVICE_CATALOG, value) +} + +export function displayNameFor(entry: ServiceCatalogEntry, cloud: CloudProvider): string { + return entry.displayNameByCloud?.[cloud] ?? entry.displayName +} + +export function routeFor(entry: ServiceCatalogEntry, cloud?: CloudProvider): string { + const perCloud = cloud ? entry.routeByCloud?.[cloud] : undefined + return perCloud ?? entry.route ?? entry.service +} + +export function compareCatalogEntries(a: ServiceCatalogEntry, b: ServiceCatalogEntry): number { + const groupDelta = SERVICE_GROUP_ORDER.indexOf(a.group) - SERVICE_GROUP_ORDER.indexOf(b.group) + return groupDelta !== 0 ? groupDelta : a.order - b.order +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index addd27d..ebeca3b 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,10 @@ -export type CloudProvider = 'aws' | 'azure' | 'gcp' +// Derived from SERVICE_CATALOG so a new catalog row is a new service type. +// This type-only cycle with serviceCatalog.ts is erased at compile time. +import type {CloudServiceType, ServiceGroup} from './serviceCatalog' + +export type {CloudServiceType, ServiceGroup} -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' | 'queue' +export type CloudProvider = 'aws' | 'azure' | 'gcp' export type CloudAvailability = 'available' | 'coming_soon' @@ -10,23 +14,58 @@ export interface CloudDescriptor { availability: CloudAvailability } +/** + * What the frontend needs to render a nav entry. Everything except + * `availability` and `reason` comes from the service catalog; those two are + * resolved per cloud at request time. + */ export interface CloudServiceDescriptor { cloud: CloudProvider service: CloudServiceType displayName: string availability: CloudAvailability + /** Why the service is unavailable. Always set when availability is coming_soon. */ + reason?: string + /** Route slug, or an absolute path for a page outside Cloud Explorer. */ + route: string + /** Icon hint; the frontend maps it to a component and falls back if unknown. */ + iconKey: string + group: ServiceGroup + order: number } +export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon' + export interface CloudStatus { cloud: CloudProvider adapterRegistered: boolean - runtime: 'reachable' | 'unavailable' | 'coming_soon' + runtime: RuntimeReachability endpoint: string | null checkedAt: string error: string | null + /** Present only when the caller asks for per-service detail. */ + services?: CloudServiceStatus[] } -export type FieldType = 'text' | 'select' +/** + * Health of one service rather than the whole cloud. Needed because a cloud can + * be up while an individual service is not — the Azure runtime serves blob + * storage but answers 501 for functions. + */ +export interface CloudServiceStatus { + cloud: CloudProvider + service: CloudServiceType + adapterRegistered: boolean + runtime: RuntimeReachability + endpoint: string | null + checkedAt: string + latencyMs: number | null + error: string | null + /** The mapped error code, so the UI can tell "not implemented" from "down". */ + errorCode: string | null +} + +export type FieldType = 'text' | 'password' | 'select' export interface FieldSchema { name: string @@ -45,8 +84,22 @@ export interface FieldSchema { options?: Array<{label: string; value: string}> } +/** + * Verbs a service can advertise. `ActionSchema` drives which controls the + * generic view renders; `ResourceActionName` additionally covers lifecycle verbs + * that a capability block can describe but that are not table-level controls. + */ export type ActionSchema = 'list' | 'create' | 'delete' | 'inspect' -export type ResourceActionName = 'list' | 'create' | 'delete' | 'inspect' +export type ResourceActionName = + | 'list' + | 'create' + | 'delete' + | 'inspect' + | 'invoke' + | 'start' + | 'stop' + | 'reboot' + | 'updateTags' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' @@ -59,9 +112,25 @@ export interface CapabilitySchema { runtimeRequired?: boolean } +/** + * How a column is rendered. Everything past `label` is optional and every + * default reproduces the previous behaviour, so existing schemas are unaffected. + */ +export type ColumnFormat = 'text' | 'datetime' | 'relative' | 'bytes' | 'boolean' | 'badge' | 'code' | 'list' + export interface TableColumnSchema { + /** Column identity and sort key. */ name: string label: string + /** + * Dotted accessor into the resource, defaulting to `name`. Needed because + * most provider detail lives under `metadata`, which a top-level lookup + * cannot reach — those columns previously rendered blank for every row. + */ + path?: string + format?: ColumnFormat + emptyText?: string + width?: string } export interface ServiceSchema { @@ -83,7 +152,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'queue' + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'secret' | 'queue' region: string | null createdAt: string | null status?: string | null @@ -167,10 +236,26 @@ export interface ServerlessInvokeResult { logResult?: string executionDuration?: number } +/** + * Lets a registered adapter correct its own advertised availability. + * + * Needed because "an adapter exists" and "the local runtime implements it" are + * different facts: floci-az ships no /functions endpoint, so the Azure + * serverless adapter is registered but cannot serve a request. Declaring that + * here keeps the sidebar, the console card and the explorer surface consistent + * from one string. + */ +export interface CloudServiceDescriptorOverride { + availability?: CloudAvailability + reason?: string + displayName?: string +} + export interface CloudServiceAdapter { readonly cloud: CloudProvider readonly service: CloudServiceType schema(): ServiceSchema + descriptorOverride?(): CloudServiceDescriptorOverride list(query?: ResourceQuery): Promise get(id: string): Promise create(input: CreateResourceInput): Promise @@ -180,6 +265,18 @@ export interface CloudServiceAdapter { getObject?(resourceId: string, key: string): Promise deleteObject?(resourceId: string, key: string): Promise invoke?(id: string, payload: string): Promise + // Lifecycle verbs. Optional because most categories have no notion of them; + // an adapter that advertises one in `capabilities` must implement it, which + // cloudProxy.test.ts enforces. + start?(id: string): Promise + stop?(id: string, force?: boolean): Promise + reboot?(id: string): Promise + updateTags?(id: string, tags: Record): Promise + /** + * Cheap liveness check. Defaults to `list()`, which is a valid probe for + * every current adapter; override where listing is expensive. + */ + health?(): Promise copyObject?(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise listCosmosContainers?(databaseId: string): Promise createCosmosContainer?(databaseId: string, input: CreateResourceInput): Promise diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts new file mode 100644 index 0000000..ec6b387 --- /dev/null +++ b/packages/api/src/cloudProxy.test.ts @@ -0,0 +1,143 @@ +import {describe, expect, test} from 'bun:test' +import {createCloudAdapterRegistry} from './cloudProxy' +import {isServiceType} from './cloud-spi/serviceCatalog' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, ResourceActionName} from './cloud-spi/types' + +/** + * Guards the schema-to-implementation contract for every registered adapter. + * + * A schema is a promise to the frontend: it decides which controls render and + * which are greyed out. Three separate bugs came from breaking that promise — + * networking advertised create/delete as available while the adapter threw, + * serverless shipped an invoke panel against an adapter with no invoke, and the + * Azure serverless adapter was advertised as available against a runtime that + * answers 501. These tests make the contract mechanical instead of a convention. + */ + +const CLOUDS: CloudProvider[] = ['aws', 'azure', 'gcp'] + +/** Adapter method that must exist for a capability to claim `available`. */ +const METHOD_FOR_ACTION: Record = { + list: 'list', + create: 'create', + delete: 'delete', + inspect: 'get', + invoke: 'invoke', + start: 'start', + stop: 'stop', + reboot: 'reboot', + updateTags: 'updateTags', +} + +const registry = createCloudAdapterRegistry() + +function registeredAdapters(): Array<{cloud: CloudProvider; adapter: CloudServiceAdapter}> { + return CLOUDS.flatMap((cloud) => + registry.servicesFor(cloud).map((service) => ({cloud, adapter: adapterFor(cloud, service)})), + ) +} + +function adapterFor(cloud: CloudProvider, service: CloudServiceType): CloudServiceAdapter { + const adapter = registry.get(cloud, service) + if (!adapter) throw new Error(`expected an adapter for ${cloud}/${service}`) + return adapter +} + +describe('registered adapters honour their schema', () => { + const adapters = registeredAdapters() + + test('at least one adapter is registered for every cloud', () => { + for (const cloud of CLOUDS) { + expect(adapters.filter((entry) => entry.cloud === cloud).length).toBeGreaterThan(0) + } + }) + + for (const {cloud, adapter} of adapters) { + const label = `${cloud}/${adapter.service}` + + test(`${label} schema identifies itself consistently`, () => { + const schema = adapter.schema() + expect(schema.cloud).toBe(adapter.cloud) + expect(schema.service).toBe(adapter.service) + expect(isServiceType(schema.service)).toBe(true) + expect(schema.displayName.length).toBeGreaterThan(0) + }) + + test(`${label} implements every action it advertises`, () => { + for (const action of adapter.schema().actions) { + const method = METHOD_FOR_ACTION[action] + expect(typeof adapter[method]).toBe('function') + } + }) + + test(`${label} implements every capability it marks available`, () => { + const capabilities = adapter.schema().capabilities?.resourceActions ?? [] + const claimed = capabilities.filter((capability) => capability.status === 'available') + + for (const capability of claimed) { + const method = METHOD_FOR_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } + }) + + test(`${label} explains every capability it does not fully support`, () => { + const capabilities = [ + ...(adapter.schema().capabilities?.resourceActions ?? []), + ...(adapter.schema().capabilities?.objectActions ?? []), + ] + + for (const capability of capabilities) { + if (capability.status === 'available') continue + expect( + capability.reason?.length ?? 0, + `${label} capability ${capability.name} is ${capability.status} with no reason`, + ).toBeGreaterThan(0) + } + }) + + test(`${label} declares object capabilities only when it can serve them`, () => { + const objectActions = adapter.schema().capabilities?.objectActions ?? [] + if (objectActions.length === 0) return + + // An object-action block means the resource is a container of objects. + expect(typeof adapter.listObjects).toBe('function') + }) + + test(`${label} columns resolve to a path`, () => { + for (const column of adapter.schema().columns) { + expect((column.path ?? column.name).length).toBeGreaterThan(0) + } + }) + } +}) + +describe('adapter capability advertisements match the runtime reality', () => { + test('AWS serverless advertises invoke and implements it', () => { + const adapter = adapterFor('aws', 'serverless') + const invoke = adapter.schema().capabilities?.resourceActions?.find((c) => c.name === 'invoke') + + expect(invoke?.status).toBe('available') + expect(typeof adapter.invoke).toBe('function') + }) + + test('AWS networking does not advertise create or delete as available', () => { + const adapter = adapterFor('aws', 'networking') + const actions = adapter.schema().capabilities?.resourceActions ?? [] + + for (const name of ['create', 'delete'] as const) { + const capability = actions.find((c) => c.name === name) + expect(capability?.status).not.toBe('available') + expect(capability?.reason).toBeTruthy() + } + }) + + test('Azure serverless reports its runtime gap through descriptorOverride', () => { + const override = adapterFor('azure', 'serverless').descriptorOverride?.() + + expect(override?.availability).toBe('coming_soon') + expect(override?.reason).toContain('501') + }) +}) diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index c008826..874443b 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -8,8 +8,11 @@ import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' +import {GcpCloudSqlAdapter} from './adapter-gcp/GcpCloudSqlAdapter' +import {GcpGkeAdapter} from './adapter-gcp/GcpGkeAdapter' import {CloudProxyService} from './service/CloudProxyService' import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter' +import {AzureKeyVaultAdapter} from './adapter-azure/AzureKeyVaultAdapter' import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter' import {AwsQueueAdapter} from './adapter-aws/AwsQueueAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' @@ -18,16 +21,19 @@ import {createEksService} from './services/eks' import {createRdsService} from './services/rds' /** - * Build a CloudProxyService whose AWS adapters are bound to a specific account. - * The account id drives the AWS SDK credentials (see aws.ts), so every AWS call - * the returned service makes is isolated to that account. Azure and GCP adapters - * use their own runtime auth model and are account-neutral. + * Build the adapter registry for an account. The account id drives the AWS SDK + * credentials (see aws.ts), so every AWS call is isolated to that account; Azure + * and GCP adapters use their own runtime auth model and are account-neutral. + * + * Exported separately from the service so tests can assert registry contents — + * notably that every adapter implements what its schema advertises — without + * reaching into private state. */ -export function createCloudProxyService(accountId?: string | null): CloudProxyService { +export function createCloudAdapterRegistry(accountId?: string | null): CloudAdapterRegistry { const clients = awsClientsForAccount(accountId) const ec2Service = createEc2Service(clients.ec2) - const registry = new CloudAdapterRegistry([ + return new CloudAdapterRegistry([ new AwsStorageAdapter(clients.s3), new AwsEksAdapter(createEksService(clients.eks)), new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds), @@ -39,10 +45,15 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe new AzureDatabaseAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), + new GcpCloudSqlAdapter(), + new GcpGkeAdapter(), new AzureServerlessAdapter(), + new AzureKeyVaultAdapter(), ]) +} - return new CloudProxyService(registry) +export function createCloudProxyService(accountId?: string | null): CloudProxyService { + return new CloudProxyService(createCloudAdapterRegistry(accountId)) } const serviceCache = new Map() diff --git a/packages/api/src/gcp.test.ts b/packages/api/src/gcp.test.ts new file mode 100644 index 0000000..e3c6d09 --- /dev/null +++ b/packages/api/src/gcp.test.ts @@ -0,0 +1,127 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import {GcpRestRuntimeClient, gcpEndpoint, gcpLocation, gcpProject} from './gcp' +import {NotFoundError, NotImplementedByRuntimeError, RuntimeUnavailableError, ValidationError} from './cloud-spi/errors' + +const originalFetch = globalThis.fetch +const ENDPOINT = 'http://localhost:4588' + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + const calls: string[] = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push(String(url)) + return handler(String(url), init) + }) as unknown as typeof fetch + return calls +} + +function client(): GcpRestRuntimeClient { + return new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1') +} + +describe('environment defaults', () => { + test('falls back to the documented local endpoints', () => { + expect(gcpEndpoint()).toBe(ENDPOINT) + expect(gcpProject()).toBe('floci-local') + expect(gcpLocation()).toBe('us-central1') + }) +}) + +describe('GcpRestRuntimeClient.fetch', () => { + test('prefixes the endpoint and returns the response', async () => { + const calls = stubFetch(() => new Response('{"ok":true}', {status: 200})) + const res = await client().fetch('/storage/v1/b') + + expect(calls[0]).toBe(`${ENDPOINT}/storage/v1/b`) + expect(res?.status).toBe(200) + }) + + test('turns a transport failure into a runtime-unavailable error', async () => { + stubFetch(() => { + throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}) + }) + await expect(client().fetch('/storage/v1/b')).rejects.toBeInstanceOf(RuntimeUnavailableError) + }) + + test('returns null for a 404 when asked to', async () => { + stubFetch(() => new Response('not found', {status: 404})) + await expect(client().fetch('/x', {}, {emptyOnNotFound: true})).resolves.toBeNull() + }) + + test('throws a typed error for a 404 otherwise', async () => { + stubFetch(() => new Response('not found', {status: 404})) + await expect(client().fetch('/x')).rejects.toBeInstanceOf(NotFoundError) + }) + + test('maps a runtime 501 to not-implemented', async () => { + stubFetch(() => new Response('nope', {status: 501})) + await expect(client().fetch('/x')).rejects.toBeInstanceOf(NotImplementedByRuntimeError) + }) + + test('maps a runtime 400 to a validation error', async () => { + stubFetch(() => new Response('bad', {status: 400})) + await expect(client().fetch('/x')).rejects.toBeInstanceOf(ValidationError) + }) + + test("surfaces Google's structured error message as detail", async () => { + // The previous per-adapter fetch discarded the body entirely, leaving + // callers with a bare "HTTP 500". + stubFetch(() => new Response( + JSON.stringify({error: {code: 404, message: 'Bucket nope does not exist', status: 'NOT_FOUND'}}), + {status: 404, headers: {'content-type': 'application/json'}}, + )) + + await expect(client().fetch('/storage/v1/b/nope')).rejects.toThrow('Bucket nope does not exist') + }) + + test('falls back to the raw body when it is not a Google error envelope', async () => { + stubFetch(() => new Response('Resource not found', {status: 404})) + await expect(client().fetch('/x')).rejects.toThrow('Resource not found') + }) +}) + +describe('GcpRestRuntimeClient.json', () => { + test('parses the body', async () => { + stubFetch(() => new Response('{"kind":"storage#buckets"}', {status: 200})) + await expect(client().json('/storage/v1/b')).resolves.toEqual({kind: 'storage#buckets'}) + }) + + test('returns null when the resource is absent', async () => { + stubFetch(() => new Response('', {status: 404})) + await expect(client().json('/x', {}, {emptyOnNotFound: true})).resolves.toBeNull() + }) +}) + +describe('GcpRestRuntimeClient.health', () => { + test("probes the runtime's own health endpoint", async () => { + const calls = stubFetch(() => new Response('{"services":{"gcs":"running"}}', {status: 200})) + await client().health() + + // Deliberately not /_floci/health, which this runtime 404s. + expect(calls[0]).toBe(`${ENDPOINT}/_floci-gcp/health`) + }) + + test('treats any non-5xx as reachable', async () => { + // "Responded at all" is the signal — an older runtime without this exact + // path still proves the process is up and routing. + for (const status of [200, 401, 403, 404]) { + stubFetch(() => new Response('', {status})) + await expect(client().health()).resolves.toBeUndefined() + } + }) + + test('reports 5xx as unavailable', async () => { + stubFetch(() => new Response('', {status: 503})) + await expect(client().health()).rejects.toBeInstanceOf(RuntimeUnavailableError) + }) + + test('reports a refused connection as unavailable', async () => { + stubFetch(() => { + throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'}) + }) + await expect(client().health()).rejects.toThrow(`Cannot reach Floci-GCP at ${ENDPOINT}`) + }) +}) diff --git a/packages/api/src/gcp.ts b/packages/api/src/gcp.ts index c19f652..fb42ab6 100644 --- a/packages/api/src/gcp.ts +++ b/packages/api/src/gcp.ts @@ -1,3 +1,91 @@ +import {RuntimeUnavailableError, httpStatusToCloudError} from './cloud-spi/errors' + +/** Floci-GCP's health endpoint; deliberately not the `/_floci/health` core uses. */ +const GCP_HEALTH_PATH = '/_floci-gcp/health' + +export interface GcpRuntimeFetchOptions { + emptyOnNotFound?: boolean +} + +/** + * Transport seam for the Floci-GCP runtime, mirroring `AzureRuntimeClient`. + * + * Both GCP adapters previously carried their own copy of this and threw away the + * response body, so a failure surfaced as a bare "HTTP 500" with no explanation. + */ +export interface GcpRuntimeClient { + readonly endpoint: string + readonly project: string + readonly location: string + fetch(path: string, init?: RequestInit, options?: GcpRuntimeFetchOptions): Promise + json(path: string, init?: RequestInit, options?: GcpRuntimeFetchOptions): Promise + health(): Promise +} + +/** Google's REST error envelope. */ +interface GcpErrorEnvelope { + error?: {code?: number; message?: string; status?: string} +} + +export class GcpRestRuntimeClient implements GcpRuntimeClient { + constructor( + readonly endpoint: string = gcpEndpoint(), + readonly project: string = gcpProject(), + readonly location: string = gcpLocation(), + ) {} + + async fetch(path: string, init: RequestInit = {}, options: GcpRuntimeFetchOptions = {}): Promise { + let res: Response + try { + res = await globalThis.fetch(`${this.endpoint}${path}`, init) + } catch (error) { + throw new RuntimeUnavailableError( + `Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`, + {cause: error}, + ) + } + + if (options.emptyOnNotFound && res.status === 404) return null + if (!res.ok) { + const detail = await readErrorDetail(res) + throw httpStatusToCloudError( + res.status, + `GCP runtime request failed: HTTP ${res.status} ${path}${detail ? ` - ${detail}` : ''}`, + ) + } + + return res + } + + async json(path: string, init: RequestInit = {}, options: GcpRuntimeFetchOptions = {}): Promise { + const res = await this.fetch(path, init, options) + if (!res) return null + return res.json() as Promise + } + + /** + * Liveness probe against the runtime's own health endpoint. + * + * Note the path is `/_floci-gcp/health`, not the `/_floci/health` that Floci + * core uses — `/` and `/_floci/health` both 404 on this runtime. + */ + async health(): Promise { + const url = `${this.endpoint}${GCP_HEALTH_PATH}` + let res: Response + try { + res = await globalThis.fetch(url, {method: 'GET'}) + } catch (error) { + throw new RuntimeUnavailableError( + `Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`, + {cause: error}, + ) + } + if (res.status >= 500) { + throw new RuntimeUnavailableError(`Floci-GCP at ${this.endpoint} returned HTTP ${res.status}`) + } + } +} + export function gcpEndpoint(): string { return process.env.FLOCI_GCP_ENDPOINT ?? process.env.FLOCI_GP_ENDPOINT ?? 'http://localhost:4588' } @@ -10,14 +98,32 @@ export function gcpLocation(): string { return process.env.FLOCI_GCP_LOCATION ?? 'us-central1' } +export const gcp = new GcpRestRuntimeClient() + export async function checkGcpRuntime(endpoint: string = gcpEndpoint()): Promise { - try { - await globalThis.fetch(endpoint, {method: 'GET'}) - } catch (error) { - throw new Error(`Cannot reach Floci-GCP at ${endpoint}: ${errorMessage(error)}`) - } + await new GcpRestRuntimeClient(endpoint).health() } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } + +/** Prefer Google's structured error message over the raw body. */ +async function readErrorDetail(res: Response): Promise { + const text = await safeResponseText(res) + if (!text) return '' + try { + const parsed = JSON.parse(text) as GcpErrorEnvelope + return parsed.error?.message ?? text + } catch { + return text + } +} + +async function safeResponseText(res: Response): Promise { + try { + return (await res.text()).trim().slice(0, 500) + } catch { + return '' + } +} diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 49296be..273c913 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -1,17 +1,19 @@ import {describe, expect, test} from 'bun:test' import {Hono} from 'hono' +import {NotImplementedByRuntimeError, RuntimeUnavailableError, ValidationError} from '../cloud-spi/errors' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' -import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema' -import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' +import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema' +import type {CloudProvider, CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudProxyService} from '../service/CloudProxyService' +import type {RuntimeProbe} from '../service/runtimeProbe' import {createCloudRoutes} from './clouds' -function mockAdapter(cloud: 'aws' | 'azure', overrides: Partial = {}): CloudServiceAdapter { +function mockAdapter(cloud: CloudProvider, overrides: Partial = {}): CloudServiceAdapter { return { cloud, service: 'storage', - schema: cloud === 'aws' ? awsStorageSchema : azureStorageSchema, + schema: cloud === 'aws' ? awsStorageSchema : cloud === 'gcp' ? gcpStorageSchema : azureStorageSchema, list: async () => [], get: async () => null, create: async (_input: CreateResourceInput): Promise => ({ @@ -19,7 +21,7 @@ function mockAdapter(cloud: 'aws' | 'azure', overrides: Partial> = {}): Record { + const reachable: RuntimeProbe = async () => {} + return {aws: reachable, azure: reachable, gcp: reachable, ...overrides} +} + +function unreachable(message: string): RuntimeProbe { + return async () => { + throw new RuntimeUnavailableError(message) + } +} + +function appWithRoutes( + adapters: CloudServiceAdapter[] = [mockAdapter('aws'), mockAdapter('azure')], + probes: Record = stubProbes(), +) { const app = new Hono() const registry = new CloudAdapterRegistry(adapters) - app.route('/api/clouds', createCloudRoutes(new CloudProxyService(registry))) + app.route('/api/clouds', createCloudRoutes(new CloudProxyService(registry, probes))) return app } @@ -82,8 +103,9 @@ describe('cloud schema routes', () => { expect(body.displayName).toBe('Cosmos DB') }) - test('returns GCP storage schema', async () => { - const res = await appWithRoutes().request('/api/clouds/gcp/services/storage/schema') + test('returns GCP storage schema when the adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('gcp', {service: 'storage', schema: gcpStorageSchema})]) + const res = await app.request('/api/clouds/gcp/services/storage/schema') const body = await res.json() expect(res.status).toBe(200) @@ -92,28 +114,28 @@ describe('cloud schema routes', () => { expect(body.fields[0].name).toBe('bucketName') }) - test('returns provider k8s schemas without registered adapters', async () => { - const azureRes = await appWithRoutes().request('/api/clouds/azure/services/k8s/schema') - const azureBody = await azureRes.json() - const gcpRes = await appWithRoutes().request('/api/clouds/gcp/services/k8s/schema') - const gcpBody = await gcpRes.json() - - expect(azureRes.status).toBe(200) - expect(azureBody.displayName).toBe('Azure AKS') - expect(gcpRes.status).toBe(200) - expect(gcpBody.displayName).toBe('Google GKE') + // Previously a static schema was served for any known service even with no + // adapter behind it, so the UI rendered a table that then 501'd on every call. + test('does not serve a schema for a service with no registered adapter', async () => { + const app = appWithRoutes([mockAdapter('aws')]) + + for (const path of [ + '/api/clouds/azure/services/k8s/schema', + '/api/clouds/gcp/services/k8s/schema', + '/api/clouds/gcp/services/database/schema', + '/api/clouds/azure/services/compute/schema', + ]) { + const res = await app.request(path) + expect(res.status).toBe(404) + expect((await res.json()).error).toBe('Schema not available') + } }) - test('returns provider database schemas without registered adapters', async () => { - const azureRes = await appWithRoutes().request('/api/clouds/azure/services/database/schema') - const azureBody = await azureRes.json() - const gcpRes = await appWithRoutes().request('/api/clouds/gcp/services/database/schema') - const gcpBody = await gcpRes.json() + test('rejects a service slug that is not in the catalog', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/ledger/schema') - expect(azureRes.status).toBe(200) - expect(azureBody.displayName).toBe('Cosmos DB') - expect(gcpRes.status).toBe(200) - expect(gcpBody.displayName).toBe('Cloud SQL') + expect(res.status).toBe(404) + expect((await res.json()).error).toBe('Unknown cloud or service') }) test('returns AWS cloud status', async () => { @@ -127,7 +149,11 @@ describe('cloud schema routes', () => { }) test('returns GCP runtime status without a registered adapter', async () => { - const res = await appWithRoutes().request('/api/clouds/gcp/status') + const app = appWithRoutes( + [mockAdapter('aws'), mockAdapter('azure')], + stubProbes({gcp: unreachable('Cannot reach Floci-GCP at http://localhost:4588')}), + ) + const res = await app.request('/api/clouds/gcp/status') const body = await res.json() expect(res.status).toBe(200) @@ -135,6 +161,20 @@ describe('cloud schema routes', () => { expect(body.adapterRegistered).toBe(false) expect(body.runtime).toBe('unavailable') expect(body.endpoint).toBe('http://localhost:4588') + expect(body.error).toContain('Cannot reach Floci-GCP') + }) + + test('cloud status reflects the runtime probe, not one adapter listing', async () => { + // Previously a cloud whose storage adapter could list reported "reachable" + // no matter what state the runtime was actually in. + const app = appWithRoutes( + [mockAdapter('aws')], + stubProbes({aws: unreachable('Cannot reach Floci core at http://localhost:4566')}), + ) + const body = await (await app.request('/api/clouds/aws/status')).json() + + expect(body.runtime).toBe('unavailable') + expect(body.adapterRegistered).toBe(true) }) test('lists storage objects through the cloud adapter', async () => { @@ -281,7 +321,7 @@ describe('cloud schema routes', () => { const app = appWithRoutes([ mockAdapter('aws', { list: async () => { - throw new Error('Cannot reach Floci-AZ at http://localhost:4577: connection refused') + throw new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577: connection refused') }, }), ]) @@ -298,7 +338,7 @@ describe('cloud schema routes', () => { const app = appWithRoutes([ mockAdapter('azure', { create: async () => { - throw new Error('Azure Blob request failed: HTTP 501') + throw new NotImplementedByRuntimeError('Azure Blob request failed: HTTP 501') }, }), ]) @@ -312,4 +352,242 @@ describe('cloud schema routes', () => { expect(body.code).toBe('operation_not_implemented') expect(body.message).toBe('Operation is not implemented by the selected runtime') }) + + test('reports a missing adapter as unsupported rather than a runtime failure', async () => { + const res = await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/azure/services/storage/resources') + const body = await res.json() + + expect(res.status).toBe(501) + expect(body.code).toBe('operation_not_supported') + expect(body.detail).toContain('No adapter registered for azure/storage') + }) + + test('maps a validation error to 400 with the adapter message intact', async () => { + const app = appWithRoutes([ + mockAdapter('aws', { + create: async () => { + throw new ValidationError('bucketName is required') + }, + }), + ]) + const res = await app.request('/api/clouds/aws/services/storage/resources', { + method: 'POST', + body: JSON.stringify({}), + }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.code).toBe('invalid_request') + expect(body.message).toBe('bucketName is required') + }) + + // Before typed errors these AWS SDK failures all collapsed into a blanket 502. + const sdkCases: Array<{name: string; status: number; code: string}> = [ + {name: 'BucketAlreadyOwnedByYou', status: 409, code: 'resource_conflict'}, + {name: 'AccessDenied', status: 403, code: 'access_denied'}, + {name: 'ValidationException', status: 400, code: 'invalid_request'}, + {name: 'ThrottlingException', status: 429, code: 'rate_limited'}, + {name: 'NoSuchBucket', status: 404, code: 'resource_not_found'}, + ] + + for (const sdkCase of sdkCases) { + test(`maps the AWS SDK ${sdkCase.name} error to ${sdkCase.status}`, async () => { + const app = appWithRoutes([ + mockAdapter('aws', { + create: async () => { + const err = new Error(`${sdkCase.name} raised by the runtime`) + err.name = sdkCase.name + Object.assign(err, {$metadata: {httpStatusCode: 500}, $fault: 'client'}) + throw err + }, + }), + ]) + const res = await app.request('/api/clouds/aws/services/storage/resources', { + method: 'POST', + body: JSON.stringify({name: 'demo'}), + }) + const body = await res.json() + + expect(res.status).toBe(sdkCase.status) + expect(body.code).toBe(sdkCase.code) + expect(body.detail ?? body.message).toContain(sdkCase.name) + }) + } +}) + +describe('service descriptors', () => { + test('every descriptor carries nav metadata for every cloud', async () => { + for (const cloud of ['aws', 'azure', 'gcp']) { + const res = await appWithRoutes().request(`/api/clouds/${cloud}/services`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.length).toBeGreaterThan(0) + for (const descriptor of body) { + expect(descriptor.cloud).toBe(cloud) + expect(typeof descriptor.route).toBe('string') + expect(descriptor.route.length).toBeGreaterThan(0) + expect(typeof descriptor.iconKey).toBe('string') + expect(typeof descriptor.group).toBe('string') + expect(typeof descriptor.order).toBe('number') + expect(descriptor.displayName.length).toBeGreaterThan(0) + } + } + }) + + test('every unavailable service explains itself', async () => { + for (const cloud of ['aws', 'azure', 'gcp']) { + const body = await (await appWithRoutes().request(`/api/clouds/${cloud}/services`)).json() + const unexplained = body.filter( + (d: {availability: string; reason?: string}) => d.availability === 'coming_soon' && !d.reason, + ) + expect(unexplained).toEqual([]) + } + }) + + test('availability follows adapter registration rather than a hardcoded list', async () => { + const withK8s = await (await appWithRoutes([ + mockAdapter('gcp', {service: 'k8s'}), + ]).request('/api/clouds/gcp/services')).json() + const withoutK8s = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/gcp/services')).json() + + const find = (body: Array<{service: string; reason?: string}>, service: string) => + body.find((d) => d.service === service) + + expect(find(withK8s, 'k8s')).toMatchObject({availability: 'available'}) + expect(find(withoutK8s, 'k8s')).toMatchObject({availability: 'coming_soon'}) + expect(find(withoutK8s, 'k8s')?.reason).toContain('GCP') + }) + + test('an adapter can report coming_soon when its runtime does not implement it', async () => { + // floci-az answers 501 for /functions, so a registered adapter must still + // be able to tell the truth about the runtime behind it. + const app = appWithRoutes([ + mockAdapter('azure', { + service: 'serverless', + descriptorOverride: () => ({ + availability: 'coming_soon', + reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.', + }), + }), + ]) + const body = await (await app.request('/api/clouds/azure/services')).json() + const serverless = body.find((d: {service: string}) => d.service === 'serverless') + + expect(serverless.availability).toBe('coming_soon') + expect(serverless.reason).toContain('501') + }) + + test('keeps the legacy Secrets Manager page available on AWS only', async () => { + const aws = await (await appWithRoutes().request('/api/clouds/aws/services')).json() + const gcp = await (await appWithRoutes().request('/api/clouds/gcp/services')).json() + const secretsFor = (body: Array<{service: string}>) => body.find((d) => d.service === 'secrets') + + expect(secretsFor(aws)).toMatchObject({availability: 'available', route: '/secretsmanager'}) + expect(secretsFor(gcp)).toMatchObject({availability: 'coming_soon'}) + }) +}) + +describe('per-service status', () => { + test('reports a reachable service with a latency measurement', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/storage/status') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toMatchObject({ + cloud: 'aws', + service: 'storage', + adapterRegistered: true, + runtime: 'reachable', + error: null, + errorCode: null, + }) + expect(body.latencyMs).toBeGreaterThanOrEqual(0) + }) + + test('reports coming_soon for a service with no adapter', async () => { + const res = await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/gcp/services/storage/status') + const body = await res.json() + + expect(body).toMatchObject({runtime: 'coming_soon', adapterRegistered: false, latencyMs: null}) + }) + + test('distinguishes a runtime that does not implement a service from one that is down', async () => { + // This is the whole point of errorCode: floci-az serves blob storage but + // answers 501 for functions, and the UI must not call that "offline". + const app = appWithRoutes([ + mockAdapter('azure', { + service: 'serverless', + list: async () => { + throw new NotImplementedByRuntimeError('HTTP 501 /functions') + }, + }), + mockAdapter('gcp', { + list: async () => { + throw new RuntimeUnavailableError('Cannot reach Floci-GCP') + }, + }), + ]) + + const notImplemented = await (await app.request('/api/clouds/azure/services/serverless/status')).json() + expect(notImplemented.runtime).toBe('unavailable') + expect(notImplemented.errorCode).toBe('operation_not_implemented') + + const down = await (await app.request('/api/clouds/gcp/services/storage/status')).json() + expect(down.errorCode).toBe('runtime_unavailable') + }) + + test('prefers an adapter health() override to list()', async () => { + let listCalls = 0 + let healthCalls = 0 + const app = appWithRoutes([ + mockAdapter('aws', { + list: async () => { + listCalls += 1 + return [] + }, + health: async () => { + healthCalls += 1 + }, + }), + ]) + + await app.request('/api/clouds/aws/services/storage/status') + expect(healthCalls).toBe(1) + expect(listCalls).toBe(0) + }) + + test('omits per-service detail from the cloud status by default', async () => { + const body = await (await appWithRoutes().request('/api/clouds/aws/status')).json() + expect(body.services).toBeUndefined() + }) + + test('includes per-service detail only when asked', async () => { + const body = await (await appWithRoutes().request('/api/clouds/aws/status?services=all')).json() + + expect(Array.isArray(body.services)).toBe(true) + expect(body.services[0]).toMatchObject({cloud: 'aws', service: 'storage'}) + }) + + test('caches probes so a polling sidebar does not fan out per request', async () => { + let probes = 0 + const app = appWithRoutes([ + mockAdapter('aws', { + list: async () => { + probes += 1 + return [] + }, + }), + ]) + + for (let i = 0; i < 5; i += 1) { + await app.request('/api/clouds/aws/services/storage/status') + } + expect(probes).toBe(1) + }) + + test('rejects an unknown service slug', async () => { + const res = await appWithRoutes().request('/api/clouds/aws/services/ledger/status') + expect(res.status).toBe(404) + }) }) diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 66a966e..b983b60 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -1,6 +1,9 @@ import {Hono} from 'hono' import type {Context} from 'hono' import type {CloudProvider, CloudServiceType} from '../cloud-spi/types' +import {toHttpError} from '../cloud-spi/errors' +import {isServiceType} from '../cloud-spi/serviceCatalog' +import {mapAwsSdkError} from '../adapter-aws/awsErrors' import {serviceForAccount} from '../cloudProxy' import {CloudProxyService} from '../service/CloudProxyService' @@ -27,7 +30,16 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { app.get('/:cloud/status', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) - return c.json(await svc(c).status(cloud)) + // Per-service detail is opt-in; the connection indicator polls this often. + const includeServices = c.req.query('services') === 'all' + return c.json(await svc(c).status(cloud, {includeServices})) + }) + + app.get('/:cloud/services/:service/status', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + const serviceType = c.req.param('service') as CloudServiceType + if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404) + return c.json(await svc(c).serviceStatus(cloud, serviceType)) }) app.get('/:cloud/services/:service/schema', (c) => { @@ -317,74 +329,12 @@ function isCloudProvider(value: string): value is CloudProvider { return value === 'aws' || value === 'azure' || value === 'gcp' } -function isServiceType(value: string): value is CloudServiceType { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' || value === 'queue' -} - async function withRuntime(c: Context, handler: () => Promise): Promise { try { return await handler() } catch (err) { - const error = normalizeRuntimeError(err) - return c.json(error.body, error.status) - } -} - -function normalizeRuntimeError(err: unknown): { - status: 400 | 404 | 501 | 502 | 503 - body: {error: string; code: string; message: string; detail?: string} -} { - const message = err instanceof Error ? err.message : 'Runtime request failed' - - if (message.includes('Cannot reach')) { - return errorResponse(503, 'runtime_unavailable', 'Runtime unavailable', message) - } - - if (message.includes('Cosmos NoSQL request failed on all known routes')) { - return errorResponse( - 502, - 'cosmos_nosql_unavailable', - 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime', - message, - ) - } - - if (message.includes('HTTP 501') || message.includes('NotImplemented')) { - return errorResponse(501, 'operation_not_implemented', 'Operation is not implemented by the selected runtime', message) - } - - if (message.includes('not found') || message.includes('NotFound') || message.includes('NoSuchBucket') || message.includes('NoSuchKey')) { - return errorResponse(404, 'resource_not_found', 'Resource not found', message) - } - - if (message.includes('is not supported') || message.includes('No adapter registered')) { - return errorResponse(501, 'operation_not_supported', 'Operation is not supported by this adapter', message) - } - - if (message.includes('is required') || message.includes('Use a valid')) { - return errorResponse(400, 'invalid_request', message) - } - - return errorResponse(502, 'runtime_error', 'Runtime request failed', message) -} - -function errorResponse( - status: 400 | 404 | 501 | 502 | 503, - code: string, - message: string, - detail?: string, -): { - status: 400 | 404 | 501 | 502 | 503 - body: {error: string; code: string; message: string; detail?: string} -} { - return { - status, - body: { - error: message, - code, - message, - ...(detail && detail !== message ? {detail} : {}), - }, + const {status, body} = toHttpError(err, mapAwsSdkError) + return c.json(body, status) } } diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index e6999e9..bf2735e 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -1,9 +1,11 @@ import type { + CloudAvailability, CloudDescriptor, CloudProvider, CloudResource, CloudServiceDescriptor, CloudServiceType, + CloudServiceStatus, CloudStatus, CosmosContainer, CosmosItem, @@ -13,21 +15,41 @@ import type { ResourceQuery, SendMessageOptions, ServerlessInvokeResult, + RuntimeReachability, ServiceSchema, StorageObjectDownload, StorageObjectList, } from '../cloud-spi/types' -import {storageSchemaFor} from '../cloud-spi/storageSchema' +import {NotSupportedError} from '../cloud-spi/errors' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' -import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema' -import {queueSchemaFor} from '../cloud-spi/queueSchema' -import {k8sSchemaFor} from '../cloud-spi/eksSchema' -import {databaseSchemaFor} from '../cloud-spi/databaseSchema' -import {azureEndpoint} from '../azure' -import {checkGcpRuntime, gcpEndpoint} from '../gcp' +import {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog' +import {toHttpError} from '../cloud-spi/errors' +import {mapAwsSdkError} from '../adapter-aws/awsErrors' +import {endpointFor, runtimeProbes, type RuntimeProbe} from './runtimeProbe' + +/** + * Status probes are real network calls and the console polls them on a short + * interval, so results are memoized briefly. Without this, asking for + * per-service detail turns one page load into a probe per registered service. + */ +const STATUS_TTL_MS = 5_000 export class CloudProxyService { - constructor(private readonly registry: CloudAdapterRegistry) {} + private readonly runtimeCache = new Map() + private readonly serviceStatusCache = new Map() + private readonly probes: Record + + /** + * `probes` is injectable so tests can exercise status without reaching the + * network — the probes hit real runtime endpoints, which are present on a + * developer machine and absent in CI. + */ + constructor( + private readonly registry: CloudAdapterRegistry, + probes: Record = runtimeProbes, + ) { + this.probes = probes + } clouds(): CloudDescriptor[] { return [ @@ -37,121 +59,135 @@ export class CloudProxyService { ] } + /** + * Derived from the service catalog and the adapter registry — never from a + * hardcoded per-cloud list. Registering an adapter is therefore the only + * thing needed to make a service appear as available in the UI. + */ services(cloud: CloudProvider): CloudServiceDescriptor[] { + return SERVICE_CATALOG_ENTRIES.map((entry) => { + const adapter = this.registry.get(cloud, entry.service) + const override = adapter?.descriptorOverride?.() ?? {} + const derived: CloudAvailability = entry.legacyAvailability?.[cloud] + ?? (adapter ? 'available' : 'coming_soon') + const availability = override.availability ?? derived + const displayName = override.displayName ?? displayNameFor(entry, cloud) - const services: CloudServiceDescriptor[] = [{ - cloud, - service: 'storage', - displayName: 'Storage', - availability: this.registry.get(cloud, 'storage') ? 'available' : 'coming_soon', - }] - - services.push({ - cloud, - service: 'k8s', - displayName: 'k8s Engine', - availability: this.registry.get(cloud, 'k8s') ? 'available' : 'coming_soon', - }) - services.push({ - cloud, - service: 'database', - displayName: 'Database', - availability: this.registry.get(cloud, 'database') ? 'available' : 'coming_soon', - }) - services.push({ - cloud, - service: 'serverless', - displayName: 'Serverless', - availability: this.registry.get(cloud, 'serverless') ? 'available' : 'coming_soon', - }) - services.push({ - cloud, - service: 'compute', - displayName: 'Compute', - availability: this.registry.get(cloud, 'compute') ? 'available' : 'coming_soon', - }) - services.push({ - cloud, - service: 'networking', - displayName: 'Networking', - availability: this.registry.get(cloud, 'networking') ? 'available' : 'coming_soon', - }) - services.push({ - cloud, - service: 'queue', - displayName: 'Queue', - availability: this.registry.get(cloud, 'queue') ? 'available' : 'coming_soon', + return { + cloud, + service: entry.service, + displayName, + availability, + reason: override.reason ?? unavailableReason(availability, cloud, displayName), + route: routeFor(entry, cloud), + iconKey: entry.iconKey, + group: entry.group, + order: entry.order, + } }) - return services } + /** + * Only a registered adapter can describe a service. Returning a static + * schema for an unregistered pair used to make the UI render a table that + * then failed on every request. + */ schema(cloud: CloudProvider, service: CloudServiceType): ServiceSchema | null { - const adapter = this.registry.get(cloud, service) - if (adapter) return adapter.schema() - if (service === 'storage') return storageSchemaFor(cloud) - if (service === 'k8s') return k8sSchemaFor(cloud) - if (service === 'database') return databaseSchemaFor(cloud) - if (service === 'serverless') return serverlessSchemaFor(cloud) - if (service === 'queue') return queueSchemaFor(cloud) - return null + return this.registry.get(cloud, service)?.schema() ?? null } - async status(cloud: CloudProvider): Promise { - const adapter = this.registry.get(cloud, 'storage') - if (cloud === 'gcp') { - try { - await checkGcpRuntime() - return { - cloud, - adapterRegistered: Boolean(adapter), - runtime: 'reachable', - endpoint: endpointFor(cloud), - checkedAt: new Date().toISOString(), - error: null, - } - } catch (error) { - return { - cloud, - adapterRegistered: Boolean(adapter), - runtime: 'unavailable', - endpoint: endpointFor(cloud), - checkedAt: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Runtime check failed', - } - } + /** + * Cloud-level reachability, probed against the runtime itself rather than + * inferred from one adapter's list call. + * + * Per-service detail is opt-in: the sidebar polls this every few seconds, so + * fanning out across every service by default would hammer the runtime. + */ + async status(cloud: CloudProvider, options: {includeServices?: boolean} = {}): Promise { + const services = this.registry.servicesFor(cloud) + const cached = await this.probeRuntime(cloud) + + const base: CloudStatus = { + cloud, + adapterRegistered: services.length > 0, + runtime: cached.runtime, + endpoint: endpointFor(cloud), + checkedAt: cached.checkedAt, + error: cached.error, + } + + if (!options.includeServices) return base + + return { + ...base, + services: await Promise.all(services.map((service) => this.serviceStatus(cloud, service))), } + } + + /** Health of a single service, so the UI can gate on the service it is showing. */ + async serviceStatus(cloud: CloudProvider, service: CloudServiceType): Promise { + const cacheKey = `${cloud}:${service}` + const cached = this.serviceStatusCache.get(cacheKey) + if (cached && Date.now() - cached.at < STATUS_TTL_MS) return cached.value + + const value = await this.measureServiceStatus(cloud, service) + this.serviceStatusCache.set(cacheKey, {at: Date.now(), value}) + return value + } + + private async measureServiceStatus(cloud: CloudProvider, service: CloudServiceType): Promise { + const endpoint = endpointFor(cloud) + const adapter = this.registry.get(cloud, service) + const checkedAt = new Date().toISOString() if (!adapter) { return { - cloud, - adapterRegistered: false, - runtime: 'coming_soon', - endpoint: endpointFor(cloud), - checkedAt: new Date().toISOString(), - error: null, + cloud, service, adapterRegistered: false, runtime: 'coming_soon', + endpoint, checkedAt, latencyMs: null, error: null, errorCode: null, } } + const startedAt = performance.now() try { - await adapter.list() + // list() is a valid probe for every current adapter; health() overrides it. + if (adapter.health) await adapter.health() + else await adapter.list() return { - cloud, - adapterRegistered: true, - runtime: 'reachable', - endpoint: endpointFor(cloud), - checkedAt: new Date().toISOString(), - error: null, + cloud, service, adapterRegistered: true, runtime: 'reachable', + endpoint, checkedAt, latencyMs: Math.round(performance.now() - startedAt), + error: null, errorCode: null, } } catch (error) { + // The mapped code is what lets the UI distinguish "the runtime does not + // implement this" from "the runtime is down". + const {body} = toHttpError(error, mapAwsSdkError) return { - cloud, - adapterRegistered: true, + cloud, service, adapterRegistered: true, runtime: 'unavailable', + endpoint, checkedAt, latencyMs: Math.round(performance.now() - startedAt), + error: body.detail ?? body.message, errorCode: body.code, + } + } + } + + private async probeRuntime(cloud: CloudProvider): Promise<{runtime: RuntimeReachability; checkedAt: string; error: string | null}> { + const cached = this.runtimeCache.get(cloud) + if (cached && Date.now() - cached.at < STATUS_TTL_MS) return cached.value + + const checkedAt = new Date().toISOString() + let value: {runtime: RuntimeReachability; checkedAt: string; error: string | null} + try { + await this.probes[cloud]() + value = {runtime: 'reachable', checkedAt, error: null} + } catch (error) { + value = { runtime: 'unavailable', - endpoint: endpointFor(cloud), - checkedAt: new Date().toISOString(), + checkedAt, error: error instanceof Error ? error.message : 'Runtime check failed', } } + + this.runtimeCache.set(cloud, {at: Date.now(), value}) + return value } async listResources(cloud: CloudProvider, service: CloudServiceType, query: ResourceQuery): Promise { @@ -176,115 +212,119 @@ async invokeResource( payload: string, ): Promise { const adapter = this.requireAdapter(cloud, service) - if (!adapter.invoke) throw new Error(`${cloud}/${service} invoke is not supported`) + if (!adapter.invoke) throw new NotSupportedError(`${cloud}/${service} invoke is not supported`) return adapter.invoke(id, payload) } - async sendQueueMessage(cloud: CloudProvider, queueId: string, body: string, options?: SendMessageOptions): Promise { - const adapter = this.requireAdapter(cloud, 'queue') - if (!adapter.sendMessage) throw new Error(`${cloud}/queue send message is not supported`) - return adapter.sendMessage(queueId, body, options) - } - - async receiveQueueMessages(cloud: CloudProvider, queueId: string, maxMessages?: number, waitTimeSeconds?: number): Promise { - const adapter = this.requireAdapter(cloud, 'queue') - if (!adapter.receiveMessages) throw new Error(`${cloud}/queue receive messages is not supported`) - return adapter.receiveMessages(queueId, maxMessages, waitTimeSeconds) - } - - async deleteQueueMessage(cloud: CloudProvider, queueId: string, receiptHandle: string): Promise { - const adapter = this.requireAdapter(cloud, 'queue') - if (!adapter.deleteMessage) throw new Error(`${cloud}/queue delete message is not supported`) - await adapter.deleteMessage(queueId, receiptHandle) - } - - async purgeQueue(cloud: CloudProvider, queueId: string): Promise { - const adapter = this.requireAdapter(cloud, 'queue') - if (!adapter.purgeQueue) throw new Error(`${cloud}/queue purge is not supported`) - await adapter.purgeQueue(queueId) - } - async listObjects(cloud: CloudProvider, service: CloudServiceType, resourceId: string, prefix?: string): Promise { const adapter = this.requireAdapter(cloud, service) - if (!adapter.listObjects) throw new Error(`Object listing is not supported for ${cloud}/${service}`) + if (!adapter.listObjects) throw new NotSupportedError(`Object listing is not supported for ${cloud}/${service}`) return adapter.listObjects(resourceId, prefix) } async putObject(cloud: CloudProvider, service: CloudServiceType, resourceId: string, key: string, body: Uint8Array, contentType: string): Promise { const adapter = this.requireAdapter(cloud, service) - if (!adapter.putObject) throw new Error(`Object upload is not supported for ${cloud}/${service}`) + if (!adapter.putObject) throw new NotSupportedError(`Object upload is not supported for ${cloud}/${service}`) await adapter.putObject(resourceId, key, body, contentType) } async getObject(cloud: CloudProvider, service: CloudServiceType, resourceId: string, key: string): Promise { const adapter = this.requireAdapter(cloud, service) - if (!adapter.getObject) throw new Error(`Object download is not supported for ${cloud}/${service}`) + if (!adapter.getObject) throw new NotSupportedError(`Object download is not supported for ${cloud}/${service}`) return adapter.getObject(resourceId, key) } async deleteObject(cloud: CloudProvider, service: CloudServiceType, resourceId: string, key: string): Promise { const adapter = this.requireAdapter(cloud, service) - if (!adapter.deleteObject) throw new Error(`Object delete is not supported for ${cloud}/${service}`) + if (!adapter.deleteObject) throw new NotSupportedError(`Object delete is not supported for ${cloud}/${service}`) await adapter.deleteObject(resourceId, key) } async copyObject(cloud: CloudProvider, service: CloudServiceType, srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise { const adapter = this.requireAdapter(cloud, service) - if (!adapter.copyObject) throw new Error(`Object copy is not supported for ${cloud}/${service}`) + if (!adapter.copyObject) throw new NotSupportedError(`Object copy is not supported for ${cloud}/${service}`) await adapter.copyObject(srcResourceId, srcKey, destKey, destResourceId) } async listCosmosContainers(cloud: CloudProvider, databaseId: string): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.listCosmosContainers) throw new Error(`Cosmos containers are not supported for ${cloud}/database`) + if (!adapter.listCosmosContainers) throw new NotSupportedError(`Cosmos containers are not supported for ${cloud}/database`) return adapter.listCosmosContainers(databaseId) } async createCosmosContainer(cloud: CloudProvider, databaseId: string, input: CreateResourceInput): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.createCosmosContainer) throw new Error(`Cosmos container creation is not supported for ${cloud}/database`) + if (!adapter.createCosmosContainer) throw new NotSupportedError(`Cosmos container creation is not supported for ${cloud}/database`) return adapter.createCosmosContainer(databaseId, input) } async deleteCosmosContainer(cloud: CloudProvider, databaseId: string, containerId: string): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.deleteCosmosContainer) throw new Error(`Cosmos container deletion is not supported for ${cloud}/database`) + if (!adapter.deleteCosmosContainer) throw new NotSupportedError(`Cosmos container deletion is not supported for ${cloud}/database`) await adapter.deleteCosmosContainer(databaseId, containerId) } async listCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.listCosmosItems) throw new Error(`Cosmos items are not supported for ${cloud}/database`) + if (!adapter.listCosmosItems) throw new NotSupportedError(`Cosmos items are not supported for ${cloud}/database`) return adapter.listCosmosItems(databaseId, containerId) } async upsertCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, document: Record): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.upsertCosmosItem) throw new Error(`Cosmos item upsert is not supported for ${cloud}/database`) + if (!adapter.upsertCosmosItem) throw new NotSupportedError(`Cosmos item upsert is not supported for ${cloud}/database`) return adapter.upsertCosmosItem(databaseId, containerId, document) } async deleteCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.deleteCosmosItem) throw new Error(`Cosmos item deletion is not supported for ${cloud}/database`) + if (!adapter.deleteCosmosItem) throw new NotSupportedError(`Cosmos item deletion is not supported for ${cloud}/database`) await adapter.deleteCosmosItem(databaseId, containerId, itemId, partitionKey) } async queryCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string, query: string): Promise { const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.queryCosmosItems) throw new Error(`Cosmos query is not supported for ${cloud}/database`) + if (!adapter.queryCosmosItems) throw new NotSupportedError(`Cosmos query is not supported for ${cloud}/database`) return adapter.queryCosmosItems(databaseId, containerId, query) } + async sendQueueMessage(cloud: CloudProvider, queueId: string, body: string, options?: SendMessageOptions): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.sendMessage) throw new NotSupportedError(`Sending messages is not supported for ${cloud}/queue`) + return adapter.sendMessage(queueId, body, options) + } + + async receiveQueueMessages(cloud: CloudProvider, queueId: string, maxMessages?: number, waitTimeSeconds?: number): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.receiveMessages) throw new NotSupportedError(`Receiving messages is not supported for ${cloud}/queue`) + return adapter.receiveMessages(queueId, maxMessages, waitTimeSeconds) + } + + async deleteQueueMessage(cloud: CloudProvider, queueId: string, receiptHandle: string): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.deleteMessage) throw new NotSupportedError(`Deleting messages is not supported for ${cloud}/queue`) + await adapter.deleteMessage(queueId, receiptHandle) + } + + async purgeQueue(cloud: CloudProvider, queueId: string): Promise { + const adapter = this.requireAdapter(cloud, 'queue') + if (!adapter.purgeQueue) throw new NotSupportedError(`Purging is not supported for ${cloud}/queue`) + await adapter.purgeQueue(queueId) + } + private requireAdapter(cloud: CloudProvider, service: CloudServiceType) { const adapter = this.registry.get(cloud, service) - if (!adapter) throw new Error(`No adapter registered for ${cloud}/${service}`) + if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`) return adapter } } -function endpointFor(cloud: CloudProvider): string | null { - if (cloud === 'aws') return process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566' - if (cloud === 'azure') return azureEndpoint() - if (cloud === 'gcp') return gcpEndpoint() - return null +/** Keeps the promise that every coming_soon descriptor explains itself. */ +function unavailableReason( + availability: CloudAvailability, + cloud: CloudProvider, + displayName: string, +): string | undefined { + if (availability === 'available') return undefined + return `No ${cloud.toUpperCase()} adapter is registered for ${displayName} yet.` } + diff --git a/packages/api/src/service/runtimeProbe.ts b/packages/api/src/service/runtimeProbe.ts new file mode 100644 index 0000000..85a772d --- /dev/null +++ b/packages/api/src/service/runtimeProbe.ts @@ -0,0 +1,47 @@ +import {RuntimeUnavailableError} from '../cloud-spi/errors' +import type {CloudProvider} from '../cloud-spi/types' +import {azure, azureEndpoint} from '../azure' +import {gcp, gcpEndpoint} from '../gcp' + +/** + * Liveness probes per runtime. + * + * Cloud status used to be inferred from whether the *storage* adapter could list, + * which meant a cloud whose storage worked reported "reachable" no matter what + * else was broken, and a cloud with no storage adapter reported "coming_soon" + * even when its runtime was up. These probe the runtime itself. + */ +export type RuntimeProbe = () => Promise + +export const runtimeProbes: Record = { + // Every runtime exposes a health endpoint, but under its own path: core and + // Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health. + aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'Floci core'), + azure: async () => { + await azure.fetch('/_floci/health', {method: 'GET'}) + }, + gcp: () => gcp.health(), +} + +export function endpointFor(cloud: CloudProvider): string | null { + if (cloud === 'aws') return awsEndpoint() + if (cloud === 'azure') return azureEndpoint() + if (cloud === 'gcp') return gcpEndpoint() + return null +} + +export function awsEndpoint(): string { + return process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566' +} + +async function probeHttp(endpoint: string, label: string): Promise { + let res: Response + try { + res = await globalThis.fetch(endpoint, {method: 'GET'}) + } catch (error) { + throw new RuntimeUnavailableError(`Cannot reach ${label} at ${endpoint}`, {cause: error}) + } + if (res.status >= 500) { + throw new RuntimeUnavailableError(`${label} at ${endpoint} returned HTTP ${res.status}`) + } +} diff --git a/packages/frontend/Dockerfile.dev b/packages/frontend/Dockerfile.dev index 0cc5346..114b85e 100644 --- a/packages/frontend/Dockerfile.dev +++ b/packages/frontend/Dockerfile.dev @@ -1,5 +1,8 @@ FROM node:20-alpine WORKDIR /app +# pnpm resolves platform-specific optional deps (e.g. Vite's rolldown native +# binding) reliably; npm intermittently drops them (npm/cli#4828). +RUN corepack enable && corepack prepare pnpm@9.2.0 --activate COPY package.json ./ -RUN npm install -CMD ["npm", "run", "dev"] +RUN pnpm install +CMD ["pnpm", "run", "dev"] diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 27fb478..cdcb5e9 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -13,6 +13,7 @@ export const apiEndpointKeys = { list: "clouds.list", services: "clouds.services.list", status: "clouds.status.get", + serviceStatus: "clouds.services.status.get", schema: "clouds.services.schema.get", resources: { list: "clouds.services.resources.list", @@ -202,6 +203,14 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "cloud-proxy" }, }, ], + [ + apiEndpointKeys.clouds.serviceStatus, + { + path: "/clouds/:cloud/services/:service/status", + method: "GET", + telemetry: { service: "cloud-proxy" }, + }, + ], [ apiEndpointKeys.clouds.schema, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 840d726..a77b555 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -3,6 +3,7 @@ import type { CloudDescriptor, CloudProvider, CloudServiceDescriptor, + CloudServiceStatus, CloudServiceType, CloudStatus, } from "@/types/cloud"; @@ -46,6 +47,19 @@ export async function getCloudStatus( return res.data; } +export async function getCloudServiceStatus( + cloud: CloudProvider, + service: CloudServiceType, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.serviceStatus, + requestOptions(cloud, service, { signal }), + { cloud, service }, + ); + return res.data; +} + export async function getServiceSchema( cloud: CloudProvider, service: CloudServiceType, diff --git a/packages/frontend/src/api/queries/cloudQueries.ts b/packages/frontend/src/api/queries/cloudQueries.ts new file mode 100644 index 0000000..ac37b35 --- /dev/null +++ b/packages/frontend/src/api/queries/cloudQueries.ts @@ -0,0 +1,87 @@ +import {useQuery} from '@tanstack/react-query' +import { + getCloudServiceStatus, + getCloudStatus, + listCloudResources, + listCloudServices, + listClouds, +} from '@/api/cloudProxyClient' +import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud' + +/** + * Shared cloud queries. + * + * These live under api/ rather than in a feature folder because the app shell + * (nav, connection indicator) needs them as much as the console home does. + * Key strings are unchanged so existing caches still share. + */ +export const cloudQueryKeys = { + clouds: ['clouds'] as const, + services: (cloud: CloudProvider) => ['cloud-services', cloud] as const, + status: (cloud: CloudProvider) => ['cloud-status', cloud] as const, + serviceStatus: (cloud: CloudProvider, service: CloudServiceType) => + ['cloud-service-status', cloud, service] as const, + resources: (cloud: CloudProvider, service: CloudServiceType) => + ['cloud-console-resources', cloud, service] as const, +} + +export function useCloudsQuery() { + return useQuery({ + queryKey: cloudQueryKeys.clouds, + queryFn: ({signal}) => listClouds(signal), + }) +} + +export function useCloudServicesQuery(cloud: CloudProvider) { + return useQuery({ + queryKey: cloudQueryKeys.services(cloud), + queryFn: ({signal}) => listCloudServices(cloud, signal), + // The catalog only changes when the API restarts, so this is effectively + // free on every navigation while still driving the whole nav. + staleTime: 60_000, + }) +} + +export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInterval?: number} = {}) { + return useQuery({ + queryKey: cloudQueryKeys.status(cloud), + queryFn: ({signal}) => getCloudStatus(cloud, signal), + refetchInterval: options.refetchInterval ?? 10_000, + }) +} + +/** Health of the single service being viewed, rather than of the whole cloud. */ +export function useCloudServiceStatusQuery( + cloud: CloudProvider, + service: CloudServiceType, + options: {enabled?: boolean} = {}, +) { + return useQuery({ + queryKey: cloudQueryKeys.serviceStatus(cloud, service), + queryFn: ({signal}) => getCloudServiceStatus(cloud, service, signal), + refetchInterval: 10_000, + enabled: options.enabled ?? true, + }) +} + +export function useCloudConsoleResourcesQuery({ + cloud, + service, + services, + status, +}: { + cloud: CloudProvider + service: CloudServiceType + services?: CloudServiceDescriptor[] + status?: CloudStatus +}) { + return useQuery({ + queryKey: cloudQueryKeys.resources(cloud, service), + queryFn: ({signal}) => listCloudResources(cloud, service, undefined, signal), + enabled: hasAvailableService(services, service) && status?.runtime === 'reachable', + }) +} + +function hasAvailableService(services: CloudServiceDescriptor[] | undefined, service: CloudServiceType): boolean { + return services?.some((item) => item.service === service && item.availability === 'available') ?? false +} diff --git a/packages/frontend/src/components/DynamicFormRenderer.tsx b/packages/frontend/src/components/DynamicFormRenderer.tsx index 0c718b6..fc6e17a 100644 --- a/packages/frontend/src/components/DynamicFormRenderer.tsx +++ b/packages/frontend/src/components/DynamicFormRenderer.tsx @@ -89,6 +89,7 @@ function FieldInput({field, value, invalid, onChange}: {field: FieldSchema; valu return ( void; @@ -46,6 +48,7 @@ export function DynamicResourceView({ cloud, service, serviceAvailability = "coming_soon", + serviceReason, cloudStatus, statusLoading = false, onOpenInfo, @@ -272,6 +275,7 @@ export function DynamicResourceView({ cloudStatus, statusLoading, serviceAvailability, + serviceReason, resourcesLoading: resourcesQuery.isLoading, resourcesError: resourcesQuery.error, isRetrying: resourcesQuery.isFetching, @@ -364,6 +368,8 @@ function resourceCreateLabel(schema: ServiceSchema): string { if (schema.cloud === "azure" && schema.service === "database") return "Create database"; if (schema.service === "queue") return "Create queue"; + if (schema.cloud === "azure" && schema.service === "secrets") + return "Create secret"; return "Create resource"; } @@ -392,6 +398,7 @@ function renderResourceSurface({ cloudStatus, statusLoading, serviceAvailability, + serviceReason, resourcesLoading, resourcesError, isRetrying, @@ -406,6 +413,7 @@ function renderResourceSurface({ cloudStatus?: CloudStatus; statusLoading: boolean; serviceAvailability: CloudAvailability; + serviceReason?: string; resourcesLoading: boolean; resourcesError: unknown; isRetrying: boolean; @@ -416,8 +424,8 @@ function renderResourceSurface({ if (statusLoading) { return ( ); @@ -426,7 +434,10 @@ function renderResourceSurface({ return ( ); diff --git a/packages/frontend/src/components/Layout.tsx b/packages/frontend/src/components/Layout.tsx index 2a10d65..a576fc0 100644 --- a/packages/frontend/src/components/Layout.tsx +++ b/packages/frontend/src/components/Layout.tsx @@ -1,24 +1,17 @@ import {NavLink, Outlet, useLocation} from 'react-router-dom' -import { - Database, - Boxes, - KeyRound, - LayoutDashboard, - MessageSquare, - Moon, - Network, - Search, - Server, - Sun, - Table2, - Zap, -} from 'lucide-react' +import {AlertTriangle, LayoutDashboard, Moon, Search, Sun} from 'lucide-react' import flociWhite from '@/assets/floci-white.svg' import flociBlack from '@/assets/floci-black.svg' import {useTheme} from '@/lib/useTheme' import {useQuery} from '@tanstack/react-query' import {getCloudStatus} from '@/api/cloudProxyClient' +import {useCloudServicesQuery} from '@/api/queries/cloudQueries' import {AccountSwitcher} from '@/components/AccountSwitcher' +import {serviceIcon} from '@/components/serviceIcons' +import type {CloudProvider, CloudServiceDescriptor} from '@/types/cloud' + +/** Matches today's service count, so the real nav causes no layout jump. */ +const SKELETON_ROWS = 7 function NavItem({to, icon, label}: { to: string; icon: React.ElementType; label: string }) { const Icon = icon @@ -30,65 +23,98 @@ function NavItem({to, icon, label}: { to: string; icon: React.ElementType; label ) } -const CLOUD_SERVICE_ICONS = { - storage: Database, - k8s: Boxes, - secretsmanager: KeyRound, - queue: MessageSquare, - function: Zap, - database: Table2, - compute: Server, - networking: Network, - serverless: Zap, -} satisfies Record - -type CloudSidebarService = keyof typeof CLOUD_SERVICE_ICONS - -const CLOUD_SERVICE_ITEMS: Array<{name: CloudSidebarService; label: string; route?: string}> = [ - {name: 'storage', label: 'Storage', route: 'storage'}, - {name: 'k8s', label: 'k8s Engine', route: 'k8s'}, - {name: 'database', label: 'Database', route: 'database'}, - {name: 'compute', label: 'Compute', route: 'compute'}, - {name: 'networking', label: 'Networking', route: 'networking'}, - {name: 'secretsmanager', label: 'Secrets Manager', route: '/secretsmanager'}, - {name: 'serverless', label: 'Serverless', route: 'serverless'}, - {name: 'queue', label: 'Queue', route: 'queue'}, - {name: 'function', label: 'Function'}, -] - +/** + * The nav is rendered entirely from `GET /clouds/:cloud/services`. + * + * It used to be a hardcoded item list plus a per-cloud boolean that never + * consulted the server, so registering an adapter did not light up the nav and + * availability could disagree with the API. Adding a service is now a catalog + * row on the server and nothing here. + */ function CloudServiceNav() { const location = useLocation() const cloud = activeCloudFromPath(location.pathname) const cloudLabel = cloud.toUpperCase() + const {data, isPending, isError, refetch, isFetching} = useCloudServicesQuery(cloud) + + if (isPending) return + + if (isError) { + return ( +
+ Cloud Services · {cloudLabel} +
+ + Services unavailable +
+ +
+ ) + } + + const groups = groupByGroup(data ?? []) return (
Cloud Services · {cloudLabel} - {CLOUD_SERVICE_ITEMS.map((service) => { - const Icon = CLOUD_SERVICE_ICONS[service.name] - const available = service.name === 'storage' - || (service.name === 'secretsmanager' && cloud === 'aws') - || (service.name === 'database' && (cloud === 'aws' || cloud === 'azure')) - || ((service.name === 'k8s' || service.name === 'compute' || service.name === 'networking') && cloud === 'aws') - || (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure')) - || (service.name === 'queue' && cloud === 'aws') - if (service.route && available) { - const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}` - return - } - - return ( -
- - {service.label} - Soon -
- ) - })} + {groups.map(([group, services]) => ( +
+ {group} + {services.map((service) => ( + + ))} +
+ ))}
) } +function CloudServiceNavItem({cloud, service}: {cloud: CloudProvider; service: CloudServiceDescriptor}) { + const Icon = serviceIcon(service.iconKey) + + if (service.availability === 'available') { + const target = service.route.startsWith('/') + ? service.route + : `/cloud-explorer/${cloud}/${service.route}` + return + } + + // The server explains why, so the chip is no longer a bare "Soon". + return ( +
+ + {service.displayName} + Soon +
+ ) +} + +function CloudServiceNavSkeleton({cloudLabel}: {cloudLabel: string}) { + return ( +
+ Cloud Services · {cloudLabel} + {Array.from({length: SKELETON_ROWS}, (_, index) => ( + + ))} +
+ ) +} + +/** Preserves the server's ordering while bucketing into its groups. */ +function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudServiceDescriptor[]]> { + const groups = new Map() + for (const service of services) { + const existing = groups.get(service.group) + if (existing) existing.push(service) + else groups.set(service.group, [service]) + } + return [...groups] +} + export function Layout() { const location = useLocation() const activeCloud = activeCloudFromPath(location.pathname) diff --git a/packages/frontend/src/components/ResourceInspector.tsx b/packages/frontend/src/components/ResourceInspector.tsx index e6faa21..fef038b 100644 --- a/packages/frontend/src/components/ResourceInspector.tsx +++ b/packages/frontend/src/components/ResourceInspector.tsx @@ -2,6 +2,7 @@ import { useCreateRdsSnapshotMutation } from "@/api/aws/rds.mutations"; import { useRdsSnapshotsQuery } from "@/api/aws/rds.queries"; import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails"; import type { CloudResource, StorageObject } from "@/types/resource"; +import {formatBytes} from "@/lib/format"; interface ResourceInspectorProps { resource?: CloudResource; @@ -363,15 +364,6 @@ function humanizeKey(value: string): string { .replace(/\b\w/g, (char) => char.toUpperCase()); } -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const index = Math.min( - Math.floor(Math.log(bytes) / Math.log(1024)), - units.length - 1, - ); - return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; -} function getStringMetadata(value: unknown): string | null { return typeof value === "string" ? value : null; diff --git a/packages/frontend/src/components/ResourceTable.tsx b/packages/frontend/src/components/ResourceTable.tsx index 240c0c1..662d3d6 100644 --- a/packages/frontend/src/components/ResourceTable.tsx +++ b/packages/frontend/src/components/ResourceTable.tsx @@ -2,6 +2,8 @@ import {useState} from 'react'; import {Trash2} from 'lucide-react'; import type {CloudResource} from '@/types/resource'; import type {ServiceSchema} from '@/types/schema'; +import {getPath} from '@/lib/resourcePath'; +import {renderColumnValue} from '@/lib/columnFormat'; interface ResourceTableProps { schema: ServiceSchema; @@ -39,7 +41,9 @@ export function ResourceTable({ {schema.columns.map((column) => ( - {column.label} + + {column.label} + ))} {canDelete && } @@ -49,7 +53,7 @@ export function ResourceTable({ {schema.columns.map((column) => ( onSelect(resource)}> - {formatValue(resource[column.name as keyof CloudResource])} + {renderColumnValue(getPath(resource, column.path ?? column.name), column)} ))} {canDelete && ( @@ -86,7 +90,3 @@ export function ResourceTable({ ); } -function formatValue(value: unknown): string { - if (value === null || value === undefined || value === '') return '-'; - return String(value); -} diff --git a/packages/frontend/src/components/StorageObjectBrowser.tsx b/packages/frontend/src/components/StorageObjectBrowser.tsx index 8cbafa6..7265509 100644 --- a/packages/frontend/src/components/StorageObjectBrowser.tsx +++ b/packages/frontend/src/components/StorageObjectBrowser.tsx @@ -14,6 +14,7 @@ import {capabilityEnabled, capabilityFor, normalizeCapabilities, withRuntimeStat import type {CloudProvider} from '@/types/cloud' import type {CloudResource, StorageObject} from '@/types/resource' import type {CapabilitySchema, ObjectActionName} from '@/types/schema' +import {formatBytes} from '@/lib/format' interface StorageObjectBrowserProps { cloud: CloudProvider @@ -597,9 +598,3 @@ function parentPrefix(prefix: string): string { return segments.length ? `${segments.join('/')}/` : '' } -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B' - const units = ['B', 'KB', 'MB', 'GB'] - const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) - return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}` -} diff --git a/packages/frontend/src/components/serviceIcons.ts b/packages/frontend/src/components/serviceIcons.ts new file mode 100644 index 0000000..d6a2e94 --- /dev/null +++ b/packages/frontend/src/components/serviceIcons.ts @@ -0,0 +1,49 @@ +import { + Boxes, + Circle, + Database, + HardDrive, + KeyRound, + Lock, + MessageSquare, + Network, + ScrollText, + Server, + ShieldCheck, + SlidersHorizontal, + Table2, + Zap, + type LucideIcon, +} from 'lucide-react' + +/** + * Maps the server's `iconKey` hint to a component. + * + * Icon keys are additive and never load-bearing: the server can ship a service + * whose key this build has never heard of, and the nav must still render. Passing + * `undefined` as a JSX component throws and — before the ErrorBoundary lands — + * would blank the whole app, so the fallback is not optional. + */ +const SERVICE_ICONS: Record = { + storage: HardDrive, + database: Table2, + nosql: Database, + k8s: Boxes, + compute: Server, + containers: Boxes, + networking: Network, + serverless: Zap, + secrets: KeyRound, + messaging: MessageSquare, + queue: MessageSquare, + logs: ScrollText, + iam: ShieldCheck, + kms: Lock, + parameters: SlidersHorizontal, +} + +const FALLBACK_ICON: LucideIcon = Circle + +export function serviceIcon(iconKey?: string): LucideIcon { + return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON +} diff --git a/packages/frontend/src/features/cloud-console/cloudConsoleHome.queries.ts b/packages/frontend/src/features/cloud-console/cloudConsoleHome.queries.ts index 0a9061a..5694832 100644 --- a/packages/frontend/src/features/cloud-console/cloudConsoleHome.queries.ts +++ b/packages/frontend/src/features/cloud-console/cloudConsoleHome.queries.ts @@ -1,59 +1,12 @@ -import {useQuery} from '@tanstack/react-query' -import { - getCloudStatus, - listClouds, - listCloudResources, - listCloudServices, -} from '@/api/cloudProxyClient' -import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud' - -export const cloudConsoleHomeQueryKeys = { - clouds: ['clouds'] as const, - services: (cloud: CloudProvider) => ['cloud-services', cloud] as const, - status: (cloud: CloudProvider) => ['cloud-status', cloud] as const, - resources: (cloud: CloudProvider, service: CloudServiceType) => ['cloud-console-resources', cloud, service] as const, -} - -export function useCloudsQuery() { - return useQuery({ - queryKey: cloudConsoleHomeQueryKeys.clouds, - queryFn: ({signal}) => listClouds(signal), - }) -} - -export function useCloudServicesQuery(cloud: CloudProvider) { - return useQuery({ - queryKey: cloudConsoleHomeQueryKeys.services(cloud), - queryFn: ({signal}) => listCloudServices(cloud, signal), - }) -} - -export function useCloudStatusQuery(cloud: CloudProvider) { - return useQuery({ - queryKey: cloudConsoleHomeQueryKeys.status(cloud), - queryFn: ({signal}) => getCloudStatus(cloud, signal), - refetchInterval: 10_000, - }) -} - -export function useCloudConsoleResourcesQuery({ - cloud, - service, - services, - status, -}: { - cloud: CloudProvider - service: CloudServiceType - services?: CloudServiceDescriptor[] - status?: CloudStatus -}) { - return useQuery({ - queryKey: cloudConsoleHomeQueryKeys.resources(cloud, service), - queryFn: ({signal}) => listCloudResources(cloud, service, undefined, signal), - enabled: hasAvailableService(services, service) && status?.runtime === 'reachable', - }) -} - -function hasAvailableService(services: CloudServiceDescriptor[] | undefined, service: CloudServiceType): boolean { - return services?.some((item) => item.service === service && item.availability === 'available') ?? false -} +/** + * Re-export shim. These queries moved to api/queries/cloudQueries so the app + * shell can use them without importing from a feature folder. + */ +export { + cloudQueryKeys as cloudConsoleHomeQueryKeys, + useCloudConsoleResourcesQuery, + useCloudServiceStatusQuery, + useCloudServicesQuery, + useCloudStatusQuery, + useCloudsQuery, +} from '@/api/queries/cloudQueries' diff --git a/packages/frontend/src/features/cloud-console/cloudConsoleHome.utils.ts b/packages/frontend/src/features/cloud-console/cloudConsoleHome.utils.ts index 688d09c..d3a69ba 100644 --- a/packages/frontend/src/features/cloud-console/cloudConsoleHome.utils.ts +++ b/packages/frontend/src/features/cloud-console/cloudConsoleHome.utils.ts @@ -1,7 +1,7 @@ import type {CloudProvider, CloudStatus} from '@/types/cloud' -export function runtimeEndpointLabel(cloud: CloudProvider, status?: CloudStatus): string { - return status?.endpoint ?? (cloud === 'aws' ? 'http://localhost:4566' : cloud === 'azure' ? 'http://localhost:4577' : 'http://localhost:4588') +export function runtimeEndpointLabel(status?: CloudStatus): string { + return status?.endpoint ?? 'Unknown endpoint' } export function runtimeLabelFor(status: CloudStatus | undefined, loading: boolean): string { @@ -24,14 +24,7 @@ export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): st return 'Waiting for runtime status' } -export function activeServicesDetailFor(cloud: CloudProvider): string { - if (cloud === 'aws') return 'Storage, k8s Engine, Database, and Secrets Manager are wired' - if (cloud === 'gcp') return 'Storage is wired through Floci-GCP' - return 'Storage only for this multi-cloud pass' -} - export function resourceDetailFor( - cloud: CloudProvider, status: CloudStatus | undefined, statusLoading: boolean, resourcesLoading: boolean, @@ -42,9 +35,7 @@ export function resourceDetailFor( if (status?.runtime === 'coming_soon') return 'No adapter registered yet' if (resourcesLoading) return 'Loading normalized resources' if (resourcesError) return 'Resource load failed' - if (cloud === 'aws') return 'Storage, k8s Engine, Database, and Secrets Manager resources' - if (cloud === 'gcp') return 'Cloud Storage resources' - return 'Normalized storage resources' + return 'Normalized resources across available services' } export function serviceMetaLabel(status: CloudStatus | undefined, loading: boolean, label: string): string { diff --git a/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts b/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts index 33494aa..5128afd 100644 --- a/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts +++ b/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts @@ -1,14 +1,14 @@ import {useMemo} from 'react' -import {Cpu, Database, KeyRound, MessageSquare, Table2, Zap} from 'lucide-react' +import {useQueries} from '@tanstack/react-query' +import {listCloudResources} from '@/api/cloudProxyClient' import { - useCloudConsoleResourcesQuery, - useCloudsQuery, + cloudQueryKeys, useCloudServicesQuery, useCloudStatusQuery, -} from './cloudConsoleHome.queries' -import {useSecretsQuery} from '@/api/aws/secretsmanager.queries' + useCloudsQuery, +} from '@/api/queries/cloudQueries' +import {serviceIcon} from '@/components/serviceIcons' import { - activeServicesDetailFor, resourceDetailFor, runtimeClassFor, runtimeDetailFor, @@ -19,123 +19,98 @@ import { import type {CloudProvider} from '@/types/cloud' import type {ConsoleServiceCard} from './types' -const SERVICE_PLACEHOLDERS = [ - {id: 'function', label: 'Function', icon: Zap}, -] - +/** + * Console home is driven entirely by `GET /clouds/:cloud/services`. + * + * It previously hardcoded three resource queries, spliced in a Secrets Manager + * card for AWS with a hardcoded "available", and appended two permanent + * placeholder cards — so it could disagree with both the sidebar and the API. + */ export function useCloudConsoleHomeData(cloud: CloudProvider) { const cloudsQuery = useCloudsQuery() const servicesQuery = useCloudServicesQuery(cloud) const statusQuery = useCloudStatusQuery(cloud) const status = statusQuery.data - const queryContext = { - cloud, - services: servicesQuery.data, - status, - } - const storageResourcesQuery = useCloudConsoleResourcesQuery({...queryContext, service: 'storage'}) - const k8sResourcesQuery = useCloudConsoleResourcesQuery({...queryContext, service: 'k8s'}) - const databaseResourcesQuery = useCloudConsoleResourcesQuery({...queryContext, service: 'database'}) - const queueResourcesQuery = useCloudConsoleResourcesQuery({...queryContext, service: 'queue'}) - const secretsQuery = useSecretsQuery(cloud === 'aws' && status?.runtime === 'reachable') - const serviceCards = useMemo(() => { - const storage = servicesQuery.data?.find((service) => service.service === 'storage') - const k8s = servicesQuery.data?.find((service) => service.service === 'k8s') - const database = servicesQuery.data?.find((service) => service.service === 'database') - const queue = servicesQuery.data?.find((service) => service.service === 'queue') + const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]) - return [ - { - id: 'storage', - label: storage?.displayName ?? 'Storage', - status: storage?.availability ?? (cloud === 'gcp' ? 'coming_soon' : 'available'), - count: storageResourcesQuery.data?.length, - icon: Database, - route: `/cloud-explorer/${cloud}/storage`, - meta: serviceMetaLabel(status, storageResourcesQuery.isLoading, 'resources'), - }, - { - id: 'k8s', - label: k8s?.displayName ?? 'k8s Engine', - status: k8s?.availability ?? 'coming_soon', - count: k8sResourcesQuery.data?.length, - icon: Cpu, - route: `/cloud-explorer/${cloud}/k8s`, - meta: serviceMetaLabel(status, k8sResourcesQuery.isLoading, 'clusters'), - }, - { - id: 'database', - label: database?.displayName ?? 'Database', - status: database?.availability ?? 'coming_soon', - count: databaseResourcesQuery.data?.length, - icon: Table2, - route: `/cloud-explorer/${cloud}/database`, - meta: serviceMetaLabel(status, databaseResourcesQuery.isLoading, 'instances'), - }, - ...(cloud === 'aws' ? [{ - id: 'secretsmanager', - label: 'Secrets Manager', - status: 'available' as const, - count: secretsQuery.data?.length, - icon: KeyRound, - route: '/secretsmanager', - meta: serviceMetaLabel(status, secretsQuery.isLoading, 'secrets'), - }, { - id: 'queue', - label: queue?.displayName ?? 'Queue', - status: queue?.availability ?? 'coming_soon', - count: queueResourcesQuery.data?.length, - icon: MessageSquare, - route: `/cloud-explorer/${cloud}/queue`, - meta: serviceMetaLabel(status, queueResourcesQuery.isLoading, 'queues'), - }] : []), - ...SERVICE_PLACEHOLDERS.map((service) => ({ - ...service, - status: 'coming_soon' as const, - count: undefined, - route: undefined, - meta: 'not wired yet', - })), - ] - }, [ - databaseResourcesQuery.data, - databaseResourcesQuery.isLoading, - cloud, - k8sResourcesQuery.data, - k8sResourcesQuery.isLoading, - queueResourcesQuery.data, - queueResourcesQuery.isLoading, - secretsQuery.data, - secretsQuery.isLoading, - servicesQuery.data, - status, - storageResourcesQuery.data, - storageResourcesQuery.isLoading, - ]) + // Services on their own route are counted through the generic list endpoint; + // a legacy absolute-route page has no such endpoint, so it shows no count. + const countable = useMemo( + () => services.filter((service) => service.availability === 'available' && !service.route.startsWith('/')), + [services], + ) + + const runtimeReachable = status?.runtime === 'reachable' + const countQueries = useQueries({ + queries: countable.map((service) => ({ + queryKey: cloudQueryKeys.resources(cloud, service.service), + queryFn: ({signal}: {signal?: AbortSignal}) => + listCloudResources(cloud, service.service, undefined, signal), + enabled: runtimeReachable, + staleTime: 30_000, + })), + }) + + const countsByService = useMemo(() => { + const map = new Map() + countable.forEach((service, index) => { + const query = countQueries[index] + map.set(service.service, { + count: query?.data?.length, + isLoading: query?.isLoading ?? false, + isError: query?.isError ?? false, + }) + }) + return map + }, [countable, countQueries]) + + const serviceCards = useMemo( + () => + services.map((service): ConsoleServiceCard => { + const counts = countsByService.get(service.service) + const isLegacyPage = service.route.startsWith('/') + return { + id: service.service, + label: service.displayName, + status: service.availability, + count: counts?.count, + icon: serviceIcon(service.iconKey), + route: + service.availability === 'available' + ? isLegacyPage + ? service.route + : `/cloud-explorer/${cloud}/${service.route}` + : undefined, + meta: + service.availability === 'available' + ? isLegacyPage + ? 'open service' + : serviceMetaLabel(status, counts?.isLoading ?? false, 'resources') + : 'coming soon', + } + }), + [cloud, countsByService, services, status], + ) - const resourcesLoading = storageResourcesQuery.isLoading - || k8sResourcesQuery.isLoading - || databaseResourcesQuery.isLoading - || (cloud === 'aws' && (secretsQuery.isLoading || queueResourcesQuery.isLoading)) - const resourcesError = storageResourcesQuery.isError - || k8sResourcesQuery.isError - || databaseResourcesQuery.isError - || (cloud === 'aws' && (secretsQuery.isError || queueResourcesQuery.isError)) + const resourcesLoading = countQueries.some((query) => query.isLoading) + const resourcesError = countQueries.some((query) => query.isError) + const resourceCount = countQueries.reduce((total, query) => total + (query.data?.length ?? 0), 0) + const activeServices = services.filter((service) => service.availability === 'available').length return { cloudsQuery, status, - runtimeLabel: runtimeEndpointLabel(cloud, status), + runtimeLabel: runtimeEndpointLabel(status), runtimeState: runtimeLabelFor(status, statusQuery.isLoading), runtimeClass: runtimeClassFor(status, statusQuery.isLoading), runtimeDetail: status?.error ?? runtimeDetailFor(cloud, status), - activeServices: serviceCards.filter((service) => service.status === 'available').length, - activeServicesDetail: activeServicesDetailFor(cloud), - resourceCount: (storageResourcesQuery.data?.length ?? 0) - + (k8sResourcesQuery.data?.length ?? 0) - + (databaseResourcesQuery.data?.length ?? 0) - + (cloud === 'aws' ? (secretsQuery.data?.length ?? 0) + (queueResourcesQuery.data?.length ?? 0) : 0), - resourceDetail: resourceDetailFor(cloud, status, statusQuery.isLoading, resourcesLoading, resourcesError), + activeServices, + // Counted rather than asserted: the old copy claimed a fixed service list. + activeServicesDetail: servicesQuery.isSuccess + ? `${activeServices} of ${services.length} services available` + : 'Loading services', + resourceCount, + resourceDetail: resourceDetailFor(status, statusQuery.isLoading, resourcesLoading, resourcesError), serviceCards, } } diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index 5046cfc..7fd22c7 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -3347,3 +3347,74 @@ button.console-service-card { color: var(--text-2); } +/* ── Server-driven service nav ─────────────────────────────────────────────── */ + +.nav-group { + margin: 0 0 2px; +} + +.nav-group-label { + display: block; + padding: 6px 16px 3px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); +} + +.nav-link.nav-error { + color: var(--text-3); +} + +.nav-retry { + display: block; + margin: 2px 16px 6px; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-2); + font-size: 11px; + font-family: inherit; + cursor: pointer; +} + +.nav-retry:hover:not(:disabled) { + background: var(--hover); + color: var(--text); +} + +.nav-retry:disabled { + cursor: progress; + opacity: 0.6; +} + +.nav-link.nav-skeleton { + cursor: default; + opacity: 1; +} + +.skeleton-bar { + display: block; + width: 60%; + height: 9px; + border-radius: 4px; + background: var(--border); + animation: skeleton-pulse 1.4s ease-in-out infinite; +} + +.nav-link.nav-skeleton:nth-child(even) .skeleton-bar { + width: 74%; +} + +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 0.75; } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-bar { + animation: none; + } +} diff --git a/packages/frontend/src/lib/capabilities.ts b/packages/frontend/src/lib/capabilities.ts index 78ad36c..c1cd137 100644 --- a/packages/frontend/src/lib/capabilities.ts +++ b/packages/frontend/src/lib/capabilities.ts @@ -14,6 +14,11 @@ const actionLabels: Record = { download: 'Download', createFolder: 'Create folder', copy: 'Copy object', + invoke: 'Invoke', + start: 'Start', + stop: 'Stop', + reboot: 'Reboot', + updateTags: 'Edit tags', } export function normalizeCapabilities(capabilities: Array> = []): Array> { diff --git a/packages/frontend/src/lib/columnFormat.tsx b/packages/frontend/src/lib/columnFormat.tsx new file mode 100644 index 0000000..b1e1c88 --- /dev/null +++ b/packages/frontend/src/lib/columnFormat.tsx @@ -0,0 +1,35 @@ +import type {ReactNode} from 'react' +import type {TableColumnSchema} from '@/types/schema' +import {formatBytes, formatDateTime, formatRelativeTime, slugify} from '@/lib/format' + +/** + * Render one table cell from a schema-declared column. + * + * Formats are server-declared, so an unknown one degrades to plain text rather + * than breaking the table. + */ +export function renderColumnValue(value: unknown, column: TableColumnSchema): ReactNode { + const empty = column.emptyText ?? '-' + if (value === null || value === undefined || value === '') return empty + + switch (column.format) { + case 'datetime': + return formatDateTime(value) ?? empty + case 'relative': + return formatRelativeTime(value) ?? empty + case 'bytes': + return typeof value === 'number' ? formatBytes(value) : empty + case 'boolean': + return value ? 'Yes' : 'No' + case 'badge': + return {String(value)} + case 'code': + return {String(value)} + case 'list': + return Array.isArray(value) ? value.join(', ') || empty : String(value) + case 'text': + default: + return String(value) + } +} + diff --git a/packages/frontend/src/lib/format.ts b/packages/frontend/src/lib/format.ts new file mode 100644 index 0000000..7de2bcf --- /dev/null +++ b/packages/frontend/src/lib/format.ts @@ -0,0 +1,50 @@ +/** Shared value formatters for tables, inspectors and the object browser. */ + +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '-' + if (bytes === 0) return '0 B' + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1) + return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${BYTE_UNITS[index]}` +} + +/** Locale date-time, or null when the value is not a usable timestamp. */ +export function formatDateTime(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toLocaleString() +} + +const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ['year', 31_536_000_000], + ['month', 2_592_000_000], + ['day', 86_400_000], + ['hour', 3_600_000], + ['minute', 60_000], + ['second', 1_000], +] + +/** "3 minutes ago", or null when the value is not a usable timestamp. */ +export function formatRelativeTime(value: unknown, now: number = Date.now()): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + const deltaMs = date.getTime() - now + const formatter = new Intl.RelativeTimeFormat(undefined, {numeric: 'auto'}) + for (const [unit, unitMs] of RELATIVE_UNITS) { + if (Math.abs(deltaMs) >= unitMs) { + return formatter.format(Math.round(deltaMs / unitMs), unit) + } + } + return formatter.format(0, 'second') +} + +/** Stable class-name fragment for a status value. */ +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} diff --git a/packages/frontend/src/lib/resourcePath.ts b/packages/frontend/src/lib/resourcePath.ts new file mode 100644 index 0000000..2468d45 --- /dev/null +++ b/packages/frontend/src/lib/resourcePath.ts @@ -0,0 +1,28 @@ +/** + * Read a dotted path out of a resource. + * + * Table columns default to a top-level lookup, but most provider detail lives + * under `metadata`, so a column like `metadata.runtime` needs to walk. Returns + * undefined on any non-object hop rather than throwing, because schemas are + * server-supplied and may name a field a given runtime does not populate. + */ +export function getPath(source: unknown, path: string): unknown { + if (!path) return undefined + + let current = source + for (const segment of path.split('.')) { + if (current === null || current === undefined) return undefined + if (typeof current !== 'object') return undefined + + if (Array.isArray(current)) { + const index = Number(segment) + if (!Number.isInteger(index)) return undefined + current = current[index] + continue + } + + current = (current as Record)[segment] + } + + return current +} diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx index 83a04a3..05c3394 100644 --- a/packages/frontend/src/pages/CloudExplorerPage.tsx +++ b/packages/frontend/src/pages/CloudExplorerPage.tsx @@ -2,9 +2,12 @@ import {useMemo, useState} from 'react' import {Cloud, X} from 'lucide-react' import {Navigate, useNavigate, useParams} from 'react-router-dom' import {useQuery} from '@tanstack/react-query' -import {getCloudStatus, getServiceSchema, listClouds, listCloudServices} from '@/api/cloudProxyClient' +import {Link} from 'react-router-dom' +import {getServiceSchema} from '@/api/cloudProxyClient' +import {useCloudServicesQuery, useCloudStatusQuery, useCloudsQuery} from '@/api/queries/cloudQueries' import {CloudSelector} from '@/components/CloudSelector' import {DynamicResourceView} from '@/components/DynamicResourceView' +import {EmptyState} from '@/components/EmptyState' import {normalizeCapabilities, withRuntimeState, withServiceAvailability} from '@/lib/capabilities' import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud' import type {ServiceSchema} from '@/types/schema' @@ -13,29 +16,17 @@ export function CloudExplorerPage() { const navigate = useNavigate() const params = useParams() const routeCloud = normalizeCloud(params.cloud) - const routeService = normalizeService(params.service) const cloud = routeCloud ?? 'aws' - const service = routeService ?? 'storage' + const service = params.service ?? '' const [infoOpen, setInfoOpen] = useState(false) - const cloudsQuery = useQuery({ - queryKey: ['clouds'], - queryFn: ({signal}) => listClouds(signal), - }) - - const servicesQuery = useQuery({ - queryKey: ['cloud-services', cloud], - queryFn: ({signal}) => listCloudServices(cloud, signal), - }) - - const statusQuery = useQuery({ - queryKey: ['cloud-status', cloud], - queryFn: ({signal}) => getCloudStatus(cloud, signal), - refetchInterval: 10_000, - }) + const cloudsQuery = useCloudsQuery() + const servicesQuery = useCloudServicesQuery(cloud) + const statusQuery = useCloudStatusQuery(cloud) const schemaQuery = useQuery({ queryKey: ['cloud-schema', cloud, service], queryFn: ({signal}) => getServiceSchema(cloud, service, signal), + enabled: Boolean(service), }) const selectedService = useMemo( @@ -43,10 +34,21 @@ export function CloudExplorerPage() { [service, servicesQuery.data], ) - if (!routeCloud || !routeService) { + // Until the catalog resolves, availability is unknown — not "coming soon". + // Treating it as unknown keeps a registered service from flashing the + // adapter-coming-soon notice on every load. + const catalogPending = servicesQuery.isPending + + if (!routeCloud) { return } + // Never redirect while the catalog is still loading: doing so silently sent + // any unknown or slow-loading service to storage. + if (servicesQuery.isSuccess && !selectedService) { + return + } + return ( <>
@@ -60,7 +62,7 @@ export function CloudExplorerPage() {
@@ -101,10 +104,6 @@ function normalizeCloud(value?: string): CloudProvider | null { return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null } -function normalizeService(value?: string): CloudServiceType | null { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' || value === 'queue' ? value : null -} - function ServiceInfoDialog({ cloud, service, @@ -142,7 +141,7 @@ function ServiceInfoDialog({

Service Information

-

{schema?.displayName ?? descriptor?.displayName ?? serviceLabel(service)}

+

{schema?.displayName ?? descriptor?.displayName ?? service}

- - + + @@ -174,10 +173,12 @@ function ServiceInfoDialog({

{actionFallback}

)}
-
-

Current Limitations

-

{limitationCopy(cloud, service)}

-
+ {descriptor?.reason && ( +
+

Availability

+

{descriptor.reason}

+
+ )}
) @@ -208,11 +209,8 @@ function serviceAvailability(service?: CloudServiceDescriptor): string { return service.availability === 'available' ? 'Schema available' : 'Coming soon' } -function runtimeValue(cloud: CloudProvider, status?: CloudStatus): string { - if (status?.endpoint) return status.endpoint.replace(/^https?:\/\//, '') - if (cloud === 'aws') return 'localhost:4566' - if (cloud === 'azure') return 'localhost:4577' - return 'localhost:4588' +function runtimeValue(status?: CloudStatus): string { + return status?.endpoint?.replace(/^https?:\/\//, '') ?? 'Unknown' } function runtimeDetail(status?: CloudStatus, loading?: boolean): string { @@ -253,18 +251,53 @@ function connectionValue(status?: CloudStatus, loading?: boolean): string { return 'Coming soon' } -function serviceLabel(service: CloudServiceType): string { - if (service === 'k8s') return 'k8s Engine' - if (service === 'serverless') return 'Serverless' - if (service === 'queue') return 'Queue' - return service.charAt(0).toUpperCase() + service.slice(1) -} -function limitationCopy(cloud: CloudProvider, service: CloudServiceType): string { - if (cloud === 'aws' && service === 'storage') return 'Advanced S3 workflows such as bulk actions, version browsing, and richer object lifecycle controls still live outside the normalized surface.' - if (cloud === 'azure' && service === 'storage') return 'Blob Storage is wired through the normalized contract, but advanced metadata, tags, and access-policy workflows are still limited.' - if (cloud === 'azure' && service === 'database') return 'Cosmos DB uses a richer panel below for databases, containers, items, and SQL queries; a fully normalized database model is still evolving.' - if (cloud === 'aws' && service === 'compute') return 'Compute workflows still rely on AWS-specific forms for dependent infrastructure choices such as VPC, subnet, and security group.' - if (cloud === 'aws' && service === 'networking') return 'Networking uses AWS-specific operational panels because many actions require nested workflows instead of a flat generic form.' - return 'This service is available through the current adapter, but the normalized contract is still expanding.' + +/** + * A slug that is not in the server's catalog. Previously this silently redirected + * to storage, so a typo or a stale bookmark looked like a working page. + */ +function UnknownServiceNotice({ + cloud, + service, + services, +}: { + cloud: CloudProvider + service: string + services?: CloudServiceDescriptor[] +}) { + const available = (services ?? []).filter((item) => item.availability === 'available') + + return ( + <> +
+
+ +
+

Cloud Explorer

+

Unified local runtime console

+
+
+
+
+ +
+ {available.map((item) => ( + + {item.displayName} + + ))} + Console Home +
+
+ + ) } diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts index bf2ad5f..783792c 100644 --- a/packages/frontend/src/types/cloud.ts +++ b/packages/frontend/src/types/cloud.ts @@ -1,6 +1,33 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' export type CloudAvailability = 'available' | 'coming_soon' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' | 'queue' + +export type KnownCloudServiceType = + | 'storage' + | 'k8s' + | 'database' + | 'compute' + | 'networking' + | 'serverless' + | 'secrets' + +/** + * Deliberately open where the API's own type is closed. + * + * The service catalog lives on the server, so a newly registered service must be + * able to appear in the UI without a frontend type edit. The known literals are + * kept for autocompletion and so existing `service === 'compute'` comparisons + * keep type-checking. + */ +export type CloudServiceType = KnownCloudServiceType | (string & {}) + +export type ServiceGroup = + | 'Compute' + | 'Storage' + | 'Databases' + | 'Networking' + | 'Integration' + | 'Security' + | 'Observability' export interface CloudDescriptor { id: CloudProvider @@ -8,18 +35,42 @@ export interface CloudDescriptor { availability: CloudAvailability } +/** Everything the nav needs to render a service, supplied by the server. */ export interface CloudServiceDescriptor { cloud: CloudProvider service: CloudServiceType displayName: string availability: CloudAvailability + /** Why the service is unavailable; always present when coming_soon. */ + reason?: string + /** Route slug, or an absolute path for a page outside Cloud Explorer. */ + route: string + iconKey: string + group: ServiceGroup + order: number } +export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon' + export interface CloudStatus { cloud: CloudProvider adapterRegistered: boolean - runtime: 'reachable' | 'unavailable' | 'coming_soon' + runtime: RuntimeReachability + endpoint: string | null + checkedAt: string + error: string | null + services?: CloudServiceStatus[] +} + +export interface CloudServiceStatus { + cloud: CloudProvider + service: CloudServiceType + adapterRegistered: boolean + runtime: RuntimeReachability endpoint: string | null checkedAt: string + latencyMs: number | null error: string | null + /** Mapped error code, so "not implemented" reads differently from "down". */ + errorCode: string | null } diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index a9010ad..178c10d 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -5,7 +5,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function' | 'queue'; + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function' | 'secret' | 'queue'; region: string | null createdAt: string | null status?: string | null diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index 06e4985..c064dea 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -1,8 +1,19 @@ import type {CloudProvider, CloudServiceType} from './cloud' -export type FieldType = 'text' | 'select' +export type FieldType = 'text' | 'password' | 'select' export type ActionSchema = 'list' | 'create' | 'delete' | 'inspect' -export type ResourceActionName = 'list' | 'create' | 'delete' | 'inspect' +// Mirrors packages/api/src/cloud-spi/types.ts. Lifecycle verbs can be advertised +// in a capability block even though they are not table-level controls. +export type ResourceActionName = + | 'list' + | 'create' + | 'delete' + | 'inspect' + | 'invoke' + | 'start' + | 'stop' + | 'reboot' + | 'updateTags' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' @@ -32,9 +43,16 @@ export interface FieldSchema { options?: Array<{label: string; value: string}> } +export type ColumnFormat = 'text' | 'datetime' | 'relative' | 'bytes' | 'boolean' | 'badge' | 'code' | 'list' + export interface TableColumnSchema { name: string label: string + /** Dotted accessor, defaulting to `name`; needed to reach `metadata.*`. */ + path?: string + format?: ColumnFormat + emptyText?: string + width?: string } export interface ServiceSchema {