Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eee0b7e9b | |||
| 69d80c60a3 | |||
| 26b635db0b | |||
| efaa2e6826 | |||
| 48114da772 | |||
| 70cfd4bc96 | |||
| 12b50f4868 | |||
| 6e0bb98844 | |||
| d4726950da | |||
| eeadeef664 | |||
| 9f1ea32183 | |||
| 811791603c | |||
| 67660858eb | |||
| bb5ca85a88 | |||
| 6acd3656a1 | |||
| 6dd33b8f65 | |||
| f4b3e45f3d | |||
| 91ce11274d | |||
| 7669b5387f | |||
| a86bee443b | |||
| 9cb164f211 |
@@ -1,26 +1,23 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
|
<img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
|
||||||
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">TheCloud</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Any Cloud. Locally.</strong><br />
|
<strong>Any Cloud. Locally.</strong><br />
|
||||||
A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators.
|
A self-hosted cloud console for local AWS, Azure and GCP runtimes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
|
|
||||||
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
|
|
||||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
|
|
||||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
|
|
||||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
|
<img src="docs/images/thecloud-console.png" alt="TheCloud's Cloud Explorer showing S3 buckets and the selected bucket's details" width="900" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet.
|
TheCloud is an AWS-Console-style web console for locally running cloud runtimes. It is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`, and it renders only real data returned by the runtimes, with explicit placeholders for work that is not wired yet.
|
||||||
|
|
||||||
No fake resources, no demo rows, and no mock operational data are shown in normal mode.
|
No fake resources, no demo rows, and no mock operational data are shown in normal mode.
|
||||||
|
|
||||||
@@ -42,9 +39,9 @@ Open [http://localhost:4500](http://localhost:4500).
|
|||||||
|
|
||||||
## Exposing To The Internet
|
## Exposing To The Internet
|
||||||
|
|
||||||
Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
TheCloud requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
||||||
|
|
||||||
Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
|
Put only TheCloud's console behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind TheCloud's AWS runtime (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
|
||||||
|
|
||||||
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access.
|
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access.
|
||||||
|
|
||||||
@@ -61,6 +58,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Group | Service | AWS | Azure | GCP |
|
| Group | Service | AWS | Azure | GCP |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
||||||
|
| Compute | EC2 Auto Scaling / Auto Scaling | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
||||||
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
||||||
@@ -72,6 +70,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
|
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
|
||||||
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
|
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Storage | EFS / File Systems | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
|
||||||
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
|
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
|
||||||
@@ -84,12 +83,15 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
||||||
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
|
||||||
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
|
||||||
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
|
| Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
|
||||||
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
| Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
|
||||||
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
|
||||||
|
| Analytics | Glue Data Catalog / Data Catalog | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Analytics | Athena / Query | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
|
||||||
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
| Security | Identity | Yes (list, create, delete, inspect) | No | No |
|
||||||
@@ -97,25 +99,23 @@ cd packages/api && bun run scripts/service-matrix.ts
|
|||||||
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||||
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Security | Certificate Manager / Certificates | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
|
||||||
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
||||||
|
|
||||||
Console Home is available for all three clouds.
|
Console Home is available for all three clouds.
|
||||||
|
|
||||||
Runtime gaps — an adapter exists but the local runtime does not implement it:
|
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.
|
- Azure Serverless: TheCloud's Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.
|
||||||
|
|
||||||
Services marked `No` render as a disabled sidebar row whose tooltip carries the
|
Services marked `No` render as a disabled sidebar row whose tooltip carries the
|
||||||
server-supplied reason. Adding one is a catalog row in
|
server-supplied reason. Adding one is a catalog row in
|
||||||
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
|
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
|
||||||
|
|
||||||
<p align="center">
|
With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
|
||||||
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
|
`AKS` for that provider, and every unavailable service carries a reason: Serverless
|
||||||
</p>
|
reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
|
||||||
|
|
||||||
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.
|
even though an adapter is registered.
|
||||||
|
|
||||||
## Current Capability Snapshot
|
## Current Capability Snapshot
|
||||||
@@ -158,7 +158,7 @@ All three clouds, through the unified shell.
|
|||||||
Current gaps:
|
Current gaps:
|
||||||
|
|
||||||
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
|
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
|
||||||
the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s
|
the shipped Azure runtime (`floci-az`) config runs AKS unmocked with no Docker socket to start k3s
|
||||||
with, so a created cluster never leaves `provisioningState: Failed`.
|
with, so a created cluster never leaves `provisioningState: Failed`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -168,7 +168,7 @@ Current gaps:
|
|||||||
|
|
||||||
Relational and document database workflows across providers:
|
Relational and document database workflows across providers:
|
||||||
|
|
||||||
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because the current local Floci RDS emulator does not support modifying them.
|
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because TheCloud's RDS runtime does not support modifying them yet.
|
||||||
- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
|
- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
|
||||||
- Azure Cosmos DB NoSQL: database, container, and document workflows.
|
- Azure Cosmos DB NoSQL: database, container, and document workflows.
|
||||||
- AWS DynamoDB: table management, item browsing, and Add record.
|
- AWS DynamoDB: table management, item browsing, and Add record.
|
||||||
@@ -195,8 +195,8 @@ Frontend regression tests use mocked `/api/*` responses and need no running emul
|
|||||||
|
|
||||||
Current gaps:
|
Current gaps:
|
||||||
|
|
||||||
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but TheCloud's runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
||||||
- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime.
|
- AWS RDS instance stop/start operations are not implemented in TheCloud's runtime yet.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -258,14 +258,18 @@ Current gaps:
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>API Gateway</strong></summary>
|
<summary><strong>API Gateway</strong></summary>
|
||||||
|
|
||||||
AWS only, through the generic apigateway service category.
|
AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column.
|
||||||
|
|
||||||
- List and inspect REST APIs.
|
- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage.
|
||||||
- Create and delete REST APIs.
|
- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers.
|
||||||
|
- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies.
|
||||||
|
|
||||||
|
Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip.
|
||||||
|
|
||||||
Current gaps:
|
Current gaps:
|
||||||
|
|
||||||
- Resources, methods, deployments, and stages are not yet exposed.
|
- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed.
|
||||||
|
- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed.
|
||||||
- No Azure or GCP API Gateway adapter yet.
|
- No Azure or GCP API Gateway adapter yet.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -275,7 +279,7 @@ Current gaps:
|
|||||||
|
|
||||||
AWS SES email capture through the unified Cloud Explorer.
|
AWS SES email capture through the unified Cloud Explorer.
|
||||||
|
|
||||||
- Lists emails actually captured by Floci SES.
|
- Lists emails actually captured by TheCloud's SES.
|
||||||
- Filters by subject, sender, and recipient.
|
- Filters by subject, sender, and recipient.
|
||||||
- Inspects sender, recipients, timestamp, and message type.
|
- Inspects sender, recipients, timestamp, and message type.
|
||||||
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
|
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
|
||||||
@@ -292,10 +296,10 @@ aws ses send-email \
|
|||||||
--endpoint-url http://localhost:4566 \
|
--endpoint-url http://localhost:4566 \
|
||||||
--from sender@example.test \
|
--from sender@example.test \
|
||||||
--destination 'ToAddresses=recipient@example.test' \
|
--destination 'ToAddresses=recipient@example.test' \
|
||||||
--message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
--message 'Subject={Data="TheCloud SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from TheCloud</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
The email is captured by the local Floci runtime; it is not delivered externally. Open
|
The email is captured by TheCloud's runtime, and is delivered externally only when an SMTP relay is configured. Open
|
||||||
`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and
|
`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and
|
||||||
Raw views. You can also inspect the captured messages directly with:
|
Raw views. You can also inspect the captured messages directly with:
|
||||||
|
|
||||||
@@ -323,7 +327,7 @@ AWS and GCP, both through the unified shell.
|
|||||||
|
|
||||||
Current gaps:
|
Current gaps:
|
||||||
|
|
||||||
- Azure Functions is registered but the Floci-AZ runtime answers 501 NotImplemented,
|
- Azure Functions is registered but the Azure runtime answers 501 NotImplemented,
|
||||||
so it reports `coming_soon` with that reason rather than appearing available.
|
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
|
- GCP Cloud Functions invoke is not wired yet; the capability is advertised as
|
||||||
`coming_soon` instead of being silently missing.
|
`coming_soon` instead of being silently missing.
|
||||||
@@ -376,7 +380,7 @@ Current gaps:
|
|||||||
|
|
||||||
## Product Direction
|
## Product Direction
|
||||||
|
|
||||||
Floci UI is evolving toward a metadata-driven, cloud-aware console where one web app can render multiple local runtimes through the same shell.
|
TheCloud is a metadata-driven, cloud-aware console: one web app renders multiple local runtimes through the same shell.
|
||||||
|
|
||||||
The guiding rules are:
|
The guiding rules are:
|
||||||
|
|
||||||
@@ -388,7 +392,7 @@ The guiding rules are:
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
|
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
|
||||||
|
|
||||||
@@ -430,9 +434,9 @@ Browser
|
|||||||
|
|
||||||
Default compose stack:
|
Default compose stack:
|
||||||
|
|
||||||
- `floci-ui` on `http://localhost:4500`
|
- `floci-ui` (the console) on `http://localhost:4500`
|
||||||
- `floci-api` on `http://localhost:4501`
|
- `floci-api` (TheCloud's API) on `http://localhost:4501`
|
||||||
- `floci` on `http://localhost:4566`
|
- `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
|
||||||
|
|
||||||
Start AWS-only:
|
Start AWS-only:
|
||||||
|
|
||||||
@@ -462,7 +466,7 @@ Prerequisites:
|
|||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- pnpm 9+
|
- pnpm 9+
|
||||||
- Bun
|
- Bun
|
||||||
- A running local runtime: Floci core, and optionally Floci-AZ / Floci-GCP
|
- A running local runtime: TheCloud's AWS runtime, and optionally the Azure and GCP runtimes
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
@@ -478,29 +482,18 @@ cp .env.example packages/api/.env
|
|||||||
|
|
||||||
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
|
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
|
||||||
|
|
||||||
Start Floci AWS Core with Docker:
|
Start TheCloud's AWS runtime from its repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name floci \
|
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../floci
|
||||||
-p 4566:4566 \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-e FLOCI_DEFAULT_REGION=us-east-1 \
|
|
||||||
-u root \
|
|
||||||
floci/floci:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Or from a local clone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/floci-io/floci.git ../floci
|
|
||||||
cd ../floci
|
cd ../floci
|
||||||
./mvnw clean quarkus:dev
|
docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional local runtimes:
|
Optional local runtimes:
|
||||||
|
|
||||||
- Floci-AZ on `http://localhost:4577`
|
- Azure runtime (`floci-az`) on `http://localhost:4577`
|
||||||
- Floci-GCP on `http://localhost:4588`
|
- GCP runtime (`floci-gcp`) on `http://localhost:4588`
|
||||||
|
|
||||||
Start the UI stack:
|
Start the UI stack:
|
||||||
|
|
||||||
@@ -609,18 +602,6 @@ When adding new UI surface:
|
|||||||
- Keep placeholders explicit instead of inventing fake data.
|
- Keep placeholders explicit instead of inventing fake data.
|
||||||
- Update this README when the visible UI surface changes.
|
- Update this README when the visible UI surface changes.
|
||||||
|
|
||||||
## Community Projects
|
|
||||||
|
|
||||||
Floci UI is the first-party console, but it is not the only one. The community builds
|
|
||||||
consoles for Floci too:
|
|
||||||
|
|
||||||
- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard
|
|
||||||
for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image
|
|
||||||
and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also
|
|
||||||
covers Azure and GCP, so pick whichever fits your stack.
|
|
||||||
|
|
||||||
Building something for Floci? Open a PR to add it here.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem.
|
[MIT](LICENSE). TheCloud builds on the open-source [Floci](https://floci.io) project.
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
|
|
||||||
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
|
|
||||||
<defs>
|
|
||||||
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
|
||||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="glow"/>
|
|
||||||
<feMergeNode in="SourceGraphic"/>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
|
|
||||||
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
<style>
|
|
||||||
.st0 {
|
|
||||||
fill: #151515;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st1 {
|
|
||||||
fill: #5559A7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st2 {
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st3 {
|
|
||||||
fill: #151515;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st4 {
|
|
||||||
fill: #262425;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logoBuild {
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: logoBreath 5s ease-in-out 2.7s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-piece {
|
|
||||||
opacity: 0;
|
|
||||||
animation: reveal .5s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-a { animation-delay: .55s; }
|
|
||||||
.cloud-b { animation-delay: .75s; }
|
|
||||||
.word-a { animation-delay: 1.72s; }
|
|
||||||
.word-b { animation-delay: 1.92s; }
|
|
||||||
.word-c { animation-delay: 2.12s; }
|
|
||||||
.word-d { animation-delay: 2.32s; }
|
|
||||||
|
|
||||||
.guide-path {
|
|
||||||
fill: none;
|
|
||||||
opacity: 1;
|
|
||||||
stroke-opacity: .9;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-dasharray: 1;
|
|
||||||
stroke-dashoffset: 1;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
|
|
||||||
guideFade .42s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-dark {
|
|
||||||
stroke: #151515;
|
|
||||||
stroke-width: 2.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-word {
|
|
||||||
stroke: #262425;
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-cloud-a { animation-delay: .08s, 1.25s; }
|
|
||||||
.guide-cloud-b { animation-delay: .24s, 1.45s; }
|
|
||||||
.guide-word-a { animation-delay: 1.18s, 2.28s; }
|
|
||||||
.guide-word-b { animation-delay: 1.36s, 2.48s; }
|
|
||||||
.guide-word-c { animation-delay: 1.54s, 2.68s; }
|
|
||||||
.guide-word-d { animation-delay: 1.72s, 2.88s; }
|
|
||||||
|
|
||||||
.dot-pop {
|
|
||||||
opacity: 0;
|
|
||||||
filter: url(#logoGlow);
|
|
||||||
transform: scale(.15);
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
|
|
||||||
softPulse 3.2s ease-in-out 2.2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot-cloud { animation-delay: 1.45s, 3.1s; }
|
|
||||||
.dot-i { animation-delay: 2.62s, 3.45s; }
|
|
||||||
|
|
||||||
.cloud-shadow {
|
|
||||||
opacity: 0;
|
|
||||||
animation: shadowIn .8s ease 1.2s forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shine {
|
|
||||||
opacity: 0;
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes drawLine {
|
|
||||||
to { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes guideFade {
|
|
||||||
from { opacity: 1; stroke-opacity: .9; }
|
|
||||||
to { opacity: 0; stroke-opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes reveal {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dotPop {
|
|
||||||
0% { opacity: 0; transform: scale(.15); }
|
|
||||||
62% { opacity: 1; transform: scale(1.18); }
|
|
||||||
100% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes softPulse {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.08); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shadowIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px) scale(.94); }
|
|
||||||
to { opacity: .14; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shineSweep {
|
|
||||||
0%, 18% { opacity: 0; transform: translateX(-220px); }
|
|
||||||
30% { opacity: .7; }
|
|
||||||
54% { opacity: 0; transform: translateX(560px); }
|
|
||||||
100% { opacity: 0; transform: translateX(560px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoBreath {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.008); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
#logoBuild,
|
|
||||||
#logoBuild *,
|
|
||||||
.shine {
|
|
||||||
animation: none !important;
|
|
||||||
opacity: 1;
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-path {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shine {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<g id="logoBuild">
|
|
||||||
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
|
|
||||||
<g id="finalWordmark" class="st2">
|
|
||||||
<g class="st2">
|
|
||||||
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
|
||||||
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
|
||||||
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
|
||||||
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g id="finalMark">
|
|
||||||
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
|
||||||
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
|
||||||
</g>
|
|
||||||
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
|
||||||
<g id="drawGuides">
|
|
||||||
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
|
||||||
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
|
||||||
</g>
|
|
||||||
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,200 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
|
|
||||||
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
|
|
||||||
<defs>
|
|
||||||
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
|
||||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="glow"/>
|
|
||||||
<feMergeNode in="SourceGraphic"/>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
|
|
||||||
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
<style>
|
|
||||||
.st0 {
|
|
||||||
fill: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st1 {
|
|
||||||
fill: #5559A7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st2 {
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st3 {
|
|
||||||
fill: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.st4 {
|
|
||||||
fill: #FFFFFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logoBuild {
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: logoBreath 5s ease-in-out 2.7s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-piece {
|
|
||||||
opacity: 0;
|
|
||||||
animation: reveal .5s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-a { animation-delay: .55s; }
|
|
||||||
.cloud-b { animation-delay: .75s; }
|
|
||||||
.word-a { animation-delay: 1.72s; }
|
|
||||||
.word-b { animation-delay: 1.92s; }
|
|
||||||
.word-c { animation-delay: 2.12s; }
|
|
||||||
.word-d { animation-delay: 2.32s; }
|
|
||||||
|
|
||||||
.guide-path {
|
|
||||||
fill: none;
|
|
||||||
opacity: 1;
|
|
||||||
stroke-opacity: .9;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-dasharray: 1;
|
|
||||||
stroke-dashoffset: 1;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
|
|
||||||
guideFade .42s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-dark {
|
|
||||||
stroke: #FFFFFF;
|
|
||||||
stroke-width: 2.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-word {
|
|
||||||
stroke: #FFFFFF;
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-cloud-a { animation-delay: .08s, 1.25s; }
|
|
||||||
.guide-cloud-b { animation-delay: .24s, 1.45s; }
|
|
||||||
.guide-word-a { animation-delay: 1.18s, 2.28s; }
|
|
||||||
.guide-word-b { animation-delay: 1.36s, 2.48s; }
|
|
||||||
.guide-word-c { animation-delay: 1.54s, 2.68s; }
|
|
||||||
.guide-word-d { animation-delay: 1.72s, 2.88s; }
|
|
||||||
|
|
||||||
.dot-pop {
|
|
||||||
opacity: 0;
|
|
||||||
filter: url(#logoGlow);
|
|
||||||
transform: scale(.15);
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
|
|
||||||
softPulse 3.2s ease-in-out 2.2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot-cloud { animation-delay: 1.45s, 3.1s; }
|
|
||||||
.dot-i { animation-delay: 2.62s, 3.45s; }
|
|
||||||
|
|
||||||
.cloud-shadow {
|
|
||||||
opacity: 0;
|
|
||||||
animation: shadowIn .8s ease 1.2s forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shine {
|
|
||||||
opacity: 0;
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes drawLine {
|
|
||||||
to { stroke-dashoffset: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes guideFade {
|
|
||||||
from { opacity: 1; stroke-opacity: .9; }
|
|
||||||
to { opacity: 0; stroke-opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes reveal {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dotPop {
|
|
||||||
0% { opacity: 0; transform: scale(.15); }
|
|
||||||
62% { opacity: 1; transform: scale(1.18); }
|
|
||||||
100% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes softPulse {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.08); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shadowIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px) scale(.94); }
|
|
||||||
to { opacity: .14; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shineSweep {
|
|
||||||
0%, 18% { opacity: 0; transform: translateX(-220px); }
|
|
||||||
30% { opacity: .7; }
|
|
||||||
54% { opacity: 0; transform: translateX(560px); }
|
|
||||||
100% { opacity: 0; transform: translateX(560px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoBreath {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.008); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
#logoBuild,
|
|
||||||
#logoBuild *,
|
|
||||||
.shine {
|
|
||||||
animation: none !important;
|
|
||||||
opacity: 1;
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-path {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shine {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<g id="logoBuild">
|
|
||||||
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
|
|
||||||
<g id="finalWordmark" class="st2">
|
|
||||||
<g class="st2">
|
|
||||||
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
|
||||||
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
|
||||||
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
|
||||||
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g id="finalMark">
|
|
||||||
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
|
||||||
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
|
||||||
</g>
|
|
||||||
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
|
||||||
<g id="drawGuides">
|
|
||||||
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
|
||||||
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
|
||||||
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
|
||||||
</g>
|
|
||||||
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 620" width="960" height="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">TheCloud architecture</title>
|
||||||
|
<desc id="desc">The browser talks only to TheCloud's API, which dispatches each request through one proxy service and an adapter registry to a provider adapter, which calls the local AWS, Azure or GCP runtime with that cloud's real API.</desc>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M0,0 L10,5 L0,10 z" fill="#5b6473"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="620" rx="16" fill="#f7f4ee"/>
|
||||||
|
|
||||||
|
<text x="480" y="44" text-anchor="middle" font-size="24" font-weight="700" fill="#1d2330">TheCloud architecture</text>
|
||||||
|
<text x="480" y="70" text-anchor="middle" font-size="14" fill="#5b6473">One console for local AWS, Azure and GCP runtimes, using each cloud's real API</text>
|
||||||
|
|
||||||
|
<!-- Browser -->
|
||||||
|
<rect x="250" y="96" width="460" height="74" rx="10" fill="#ffffff" stroke="#c9b48a" stroke-width="2"/>
|
||||||
|
<text x="270" y="124" font-size="16" font-weight="700" fill="#1d2330">Console (React, port 4500)</text>
|
||||||
|
<text x="270" y="148" font-size="13" fill="#5b6473">Nav and pages rendered from each service's schema; calls only /api/*</text>
|
||||||
|
|
||||||
|
<line x1="480" y1="170" x2="480" y2="202" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<!-- API -->
|
||||||
|
<rect x="160" y="206" width="640" height="186" rx="10" fill="#ffffff" stroke="#1d2330" stroke-width="2"/>
|
||||||
|
<text x="180" y="234" font-size="16" font-weight="700" fill="#1d2330">TheCloud API (Bun and Hono, port 4501)</text>
|
||||||
|
<text x="180" y="256" font-size="13" fill="#5b6473">Sign-in, then the generic /api/clouds/:cloud/services/:service routes</text>
|
||||||
|
|
||||||
|
<rect x="180" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
|
||||||
|
<text x="196" y="294" font-size="14" font-weight="600" fill="#1d2330">CloudProxyService</text>
|
||||||
|
<text x="196" y="312" font-size="12" fill="#5b6473">One dispatcher for every request</text>
|
||||||
|
|
||||||
|
<rect x="500" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
|
||||||
|
<text x="516" y="294" font-size="14" font-weight="600" fill="#1d2330">Adapter registry and service catalog</text>
|
||||||
|
<text x="516" y="312" font-size="12" fill="#5b6473">Registered adapters decide what is shown</text>
|
||||||
|
|
||||||
|
<line x1="460" y1="297" x2="496" y2="297" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<text x="180" y="350" font-size="13" fill="#1d2330"><tspan font-weight="600">Schemas</tspan> describe fields, columns and capabilities; a capability</text>
|
||||||
|
<text x="180" y="370" font-size="13" fill="#1d2330">is only advertised when its adapter can perform it (checked by tests).</text>
|
||||||
|
|
||||||
|
<line x1="240" y1="392" x2="200" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<line x1="480" y1="392" x2="480" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<line x1="720" y1="392" x2="760" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<!-- Adapters -->
|
||||||
|
<rect x="60" y="442" width="270" height="60" rx="10" fill="#fff3e3" stroke="#e08a2c" stroke-width="2"/>
|
||||||
|
<text x="76" y="468" font-size="15" font-weight="700" fill="#a85a0c">AWS adapters</text>
|
||||||
|
<text x="76" y="488" font-size="12" fill="#5b6473">AWS SDK v3, one per service</text>
|
||||||
|
|
||||||
|
<rect x="345" y="442" width="270" height="60" rx="10" fill="#e8f0fd" stroke="#2f6fd6" stroke-width="2"/>
|
||||||
|
<text x="361" y="468" font-size="15" font-weight="700" fill="#1f4fa3">Azure adapters</text>
|
||||||
|
<text x="361" y="488" font-size="12" fill="#5b6473">Azure runtime client</text>
|
||||||
|
|
||||||
|
<rect x="630" y="442" width="270" height="60" rx="10" fill="#e8f6ec" stroke="#2f9a55" stroke-width="2"/>
|
||||||
|
<text x="646" y="468" font-size="15" font-weight="700" fill="#1f7140">GCP adapters</text>
|
||||||
|
<text x="646" y="488" font-size="12" fill="#5b6473">GCP runtime client</text>
|
||||||
|
|
||||||
|
<line x1="195" y1="502" x2="195" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<line x1="480" y1="502" x2="480" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
<line x1="765" y1="502" x2="765" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<!-- Runtimes -->
|
||||||
|
<rect x="60" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||||
|
<text x="76" y="558" font-size="14" font-weight="700" fill="#f7f4ee">TheCloud AWS runtime</text>
|
||||||
|
<text x="76" y="578" font-size="12" fill="#c9cfd8">port 4566</text>
|
||||||
|
|
||||||
|
<rect x="345" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||||
|
<text x="361" y="558" font-size="14" font-weight="700" fill="#f7f4ee">Azure runtime</text>
|
||||||
|
<text x="361" y="578" font-size="12" fill="#c9cfd8">port 4577, optional</text>
|
||||||
|
|
||||||
|
<rect x="630" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||||
|
<text x="646" y="558" font-size="14" font-weight="700" fill="#f7f4ee">GCP runtime</text>
|
||||||
|
<text x="646" y="578" font-size="12" fill="#c9cfd8">port 4588, optional</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -10,8 +10,12 @@
|
|||||||
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
|
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-acm": "^3.1146.0",
|
||||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
||||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-athena": "^3.1146.0",
|
||||||
|
"@aws-sdk/client-auto-scaling": "^3.1146.0",
|
||||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||||
"@aws-sdk/client-cloudwatch": "^3.1076.0",
|
"@aws-sdk/client-cloudwatch": "^3.1076.0",
|
||||||
@@ -24,10 +28,12 @@
|
|||||||
"@aws-sdk/client-ec2": "^3.1076.0",
|
"@aws-sdk/client-ec2": "^3.1076.0",
|
||||||
"@aws-sdk/client-ecr": "^3.1076.0",
|
"@aws-sdk/client-ecr": "^3.1076.0",
|
||||||
"@aws-sdk/client-ecs": "^3.1076.0",
|
"@aws-sdk/client-ecs": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-efs": "^3.1146.0",
|
||||||
"@aws-sdk/client-eks": "^3.1076.0",
|
"@aws-sdk/client-eks": "^3.1076.0",
|
||||||
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
|
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
|
||||||
"@aws-sdk/client-elasticache": "^3.1146.0",
|
"@aws-sdk/client-elasticache": "^3.1146.0",
|
||||||
"@aws-sdk/client-eventbridge": "^3.1076.0",
|
"@aws-sdk/client-eventbridge": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-glue": "^3.1146.0",
|
||||||
"@aws-sdk/client-iam": "^3.1076.0",
|
"@aws-sdk/client-iam": "^3.1076.0",
|
||||||
"@aws-sdk/client-kafka": "^3.1146.0",
|
"@aws-sdk/client-kafka": "^3.1146.0",
|
||||||
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
|
"@aws-sdk/client-kafkaconnect": "^3.1146.0",
|
||||||
@@ -35,6 +41,7 @@
|
|||||||
"@aws-sdk/client-kms": "^3.1096.0",
|
"@aws-sdk/client-kms": "^3.1096.0",
|
||||||
"@aws-sdk/client-lambda": "^3.1076.0",
|
"@aws-sdk/client-lambda": "^3.1076.0",
|
||||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||||
|
"@aws-sdk/client-pipes": "^3.1146.0",
|
||||||
"@aws-sdk/client-rds": "^3.1076.0",
|
"@aws-sdk/client-rds": "^3.1076.0",
|
||||||
"@aws-sdk/client-rds-data": "^3.1076.0",
|
"@aws-sdk/client-rds-data": "^3.1076.0",
|
||||||
"@aws-sdk/client-route-53": "^3.1146.0",
|
"@aws-sdk/client-route-53": "^3.1146.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type APIGatewayClient,
|
type APIGatewayClient,
|
||||||
} from '@aws-sdk/client-api-gateway'
|
} from '@aws-sdk/client-api-gateway'
|
||||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
|
import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||||
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
|
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
|
||||||
|
|
||||||
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
|
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
|
||||||
@@ -32,6 +33,18 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
|
|||||||
} as unknown as LambdaClient
|
} as unknown as LambdaClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */
|
||||||
|
const noHttpApis = {
|
||||||
|
send: async (command: unknown) => {
|
||||||
|
if (command instanceof GetApisCommand) return {Items: []}
|
||||||
|
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||||
|
},
|
||||||
|
} as unknown as ApiGatewayV2Client
|
||||||
|
|
||||||
|
function restOnlyAdapter(client: APIGatewayClient) {
|
||||||
|
return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis)
|
||||||
|
}
|
||||||
|
|
||||||
const orders = {
|
const orders = {
|
||||||
id: 'a1b2c3',
|
id: 'a1b2c3',
|
||||||
name: 'orders-api',
|
name: 'orders-api',
|
||||||
@@ -44,7 +57,7 @@ const orders = {
|
|||||||
|
|
||||||
describe('AwsApiGatewayAdapter', () => {
|
describe('AwsApiGatewayAdapter', () => {
|
||||||
test('lists every page and maps REST APIs to normalized resources', async () => {
|
test('lists every page and maps REST APIs to normalized resources', async () => {
|
||||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||||
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
|
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
|
||||||
if (!command.input.position) return {items: [orders], position: 'next'}
|
if (!command.input.position) return {items: [orders], position: 'next'}
|
||||||
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
|
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
|
||||||
@@ -70,7 +83,7 @@ describe('AwsApiGatewayAdapter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('filters APIs by search term', async () => {
|
test('filters APIs by search term', async () => {
|
||||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
|
const adapter = restOnlyAdapter(fakeClient(() => ({
|
||||||
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
|
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
|
||||||
})))
|
})))
|
||||||
|
|
||||||
@@ -92,7 +105,7 @@ describe('AwsApiGatewayAdapter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('returns null when API Gateway reports a missing REST API', async () => {
|
test('returns null when API Gateway reports a missing REST API', async () => {
|
||||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
|
const adapter = restOnlyAdapter(fakeClient(() => {
|
||||||
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -123,7 +136,7 @@ describe('AwsApiGatewayAdapter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('deletes the requested REST API', async () => {
|
test('deletes the requested REST API', async () => {
|
||||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||||
expect(command).toBeInstanceOf(DeleteRestApiCommand)
|
expect(command).toBeInstanceOf(DeleteRestApiCommand)
|
||||||
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
|
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ import {
|
|||||||
type RestApi,
|
type RestApi,
|
||||||
} from '@aws-sdk/client-api-gateway'
|
} from '@aws-sdk/client-api-gateway'
|
||||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
|
import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||||
|
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
import {awsEndpoint} from '../service/runtimeProbe'
|
import {awsEndpoint} from '../service/runtimeProbe'
|
||||||
|
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
|
||||||
|
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types'
|
||||||
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||||
@@ -37,7 +40,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
private readonly lambda: LambdaClient = defaultLambda,
|
private readonly lambda: LambdaClient = defaultLambda,
|
||||||
private readonly accountId = defaultAccountId(),
|
private readonly accountId = defaultAccountId(),
|
||||||
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
|
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
|
||||||
) {}
|
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
|
||||||
|
) {
|
||||||
|
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
|
||||||
|
private readonly httpApis: AwsHttpApiOperations
|
||||||
|
|
||||||
schema(): ServiceSchema {
|
schema(): ServiceSchema {
|
||||||
return awsApiGatewaySchema()
|
return awsApiGatewaySchema()
|
||||||
@@ -53,7 +62,17 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
position = res.position
|
position = res.position
|
||||||
} while (position)
|
} while (position)
|
||||||
|
|
||||||
return filterBySearch(apis.map(toResource), query.search)
|
return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A runtime without API Gateway v2 still lists its REST APIs. */
|
||||||
|
private async listHttpApis(): Promise<CloudResource[]> {
|
||||||
|
try {
|
||||||
|
return await this.httpApis.list()
|
||||||
|
} catch (error) {
|
||||||
|
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<CloudResource | null> {
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
@@ -61,12 +80,15 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
|
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
|
||||||
return toResource(res)
|
return toResource(res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (hasHttpStatus(error, 404)) return null
|
if (!hasHttpStatus(error, 404)) throw error
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
return this.httpApis.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const protocol = stringValue(input.values.protocol) || 'REST'
|
||||||
|
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
|
||||||
|
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
|
||||||
const name = stringValue(input.values.name)
|
const name = stringValue(input.values.name)
|
||||||
const description = stringValue(input.values.description)
|
const description = stringValue(input.values.description)
|
||||||
if (!name) throw new ValidationError('name is required')
|
if (!name) throw new ValidationError('name is required')
|
||||||
@@ -79,9 +101,54 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
|
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
|
||||||
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
|
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
|
||||||
|
return this.httpApis.describe(apiId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||||
|
return this.httpApis.createRoute(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
|
||||||
|
return this.httpApis.deleteRoute(apiId, routeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||||
|
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||||
|
return this.httpApis.createStage(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
|
||||||
|
return this.httpApis.deleteStage(apiId, stageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||||
|
return this.httpApis.deploy(apiId, stageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||||
|
return this.httpApis.createAuthorizer(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||||
|
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||||
|
return this.httpApis.updateCors(apiId, cors)
|
||||||
|
}
|
||||||
|
|
||||||
|
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||||
|
return this.httpApis.testWebSocket(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
||||||
const method = input.method.toUpperCase()
|
const method = input.method.toUpperCase()
|
||||||
const stage = stageName(input.stage)
|
const stage = stageName(input.stage)
|
||||||
@@ -470,7 +537,8 @@ function childPath(parentPath: string, pathPart: string): string {
|
|||||||
|
|
||||||
function stageName(stage: string): string {
|
function stageName(stage: string): string {
|
||||||
const value = stringValue(stage)
|
const value = stringValue(stage)
|
||||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
|
||||||
|
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
|
||||||
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
|
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
@@ -518,6 +586,7 @@ function toResource(api: RestApi): CloudResource {
|
|||||||
metadata: {
|
metadata: {
|
||||||
provider: 'aws',
|
provider: 'aws',
|
||||||
apigatewayService: 'rest',
|
apigatewayService: 'rest',
|
||||||
|
protocol: 'REST',
|
||||||
description: api.description,
|
description: api.description,
|
||||||
version: api.version,
|
version: api.version,
|
||||||
endpointTypes: api.endpointConfiguration?.types,
|
endpointTypes: api.endpointConfiguration?.types,
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AutoScalingClient,
|
||||||
|
CreateAutoScalingGroupCommand,
|
||||||
|
DeleteAutoScalingGroupCommand,
|
||||||
|
DescribeAutoScalingGroupsCommand,
|
||||||
|
DescribeScalingActivitiesCommand,
|
||||||
|
TerminateInstanceInAutoScalingGroupCommand,
|
||||||
|
UpdateAutoScalingGroupCommand,
|
||||||
|
} from '@aws-sdk/client-auto-scaling'
|
||||||
|
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||||
|
import {AwsAutoScalingAdapter} from './AwsAutoScalingAdapter'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = {
|
||||||
|
AutoScalingGroupName: 'web-asg',
|
||||||
|
AutoScalingGroupARN: 'arn:aws:autoscaling:us-east-1:000000000000:autoScalingGroup:web-asg',
|
||||||
|
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
||||||
|
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
||||||
|
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
||||||
|
HealthCheckType: 'EC2',
|
||||||
|
Instances: [
|
||||||
|
{InstanceId: 'i-b8736c8b4a8c129a3', AvailabilityZone: 'us-east-1a', LifecycleState: 'InService', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
||||||
|
{InstanceId: 'i-a3155c876867976b1', AvailabilityZone: 'us-east-1b', LifecycleState: 'Pending', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
||||||
|
],
|
||||||
|
CreatedTime: new Date('2026-10-25T16:49:54Z'),
|
||||||
|
}
|
||||||
|
const activity = {ActivityId: 'a1', Description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', Cause: 'An instance was started in response to a desired capacity change.', StatusCode: 'Successful', StartTime: new Date('2026-10-25T16:50:09Z')}
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | undefined) {
|
||||||
|
const autoscaling = stub((command) => handler(command) ?? (
|
||||||
|
command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: [group]}
|
||||||
|
: command instanceof DescribeScalingActivitiesCommand ? {Activities: [activity]}
|
||||||
|
: {}))
|
||||||
|
const ec2 = stub((command) => command instanceof DescribeSubnetsCommand ? {Subnets: [{SubnetId: 'subnet-default-b'}, {SubnetId: 'subnet-default-a'}]} : {})
|
||||||
|
return {
|
||||||
|
sent: autoscaling.sent,
|
||||||
|
ec2Sent: ec2.sent,
|
||||||
|
adapter: new AwsAutoScalingAdapter(autoscaling.client as unknown as AutoScalingClient, ec2.client as unknown as EC2Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsAutoScalingAdapter', () => {
|
||||||
|
test('lists groups across pages with their capacity and template', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (!(command instanceof DescribeAutoScalingGroupsCommand)) return undefined
|
||||||
|
return command.input.NextToken
|
||||||
|
? {AutoScalingGroups: [{...group, AutoScalingGroupName: 'jobs', LaunchTemplate: {LaunchTemplateName: 'jobs-lt'}, Instances: []}]}
|
||||||
|
: {AutoScalingGroups: [group], NextToken: 'n'}
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = await adapter.list()
|
||||||
|
expect(groups.map((g) => [g.name, g.metadata.capacity, g.metadata.desiredCapacity, g.metadata.launchTemplateName])).toEqual([
|
||||||
|
['web-asg', '1 in service', 2, 'web-lt'],
|
||||||
|
['jobs', '0 in service', 2, 'jobs-lt'],
|
||||||
|
])
|
||||||
|
expect((await adapter.list({search: 'jobs-lt'})).map((g) => g.name)).toEqual(['jobs'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes the instances and recent scaling activity', async () => {
|
||||||
|
const {adapter} = adapterWith(() => undefined)
|
||||||
|
const detail = await adapter.get('web-asg')
|
||||||
|
expect(detail?.metadata.instances).toEqual([
|
||||||
|
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
||||||
|
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'Pending', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
||||||
|
])
|
||||||
|
expect(detail?.metadata.activities).toEqual([{id: 'a1', description: activity.Description, cause: activity.Cause, status: 'Successful', statusMessage: null, startTime: '2026-10-25T16:50:09.000Z'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the group readable when its activities cannot be read', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (command instanceof DescribeScalingActivitiesCommand) throw new Error('throttled')
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
expect((await adapter.get('web-asg'))?.metadata.activitiesUnavailable).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing group is null', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: []} : undefined)
|
||||||
|
expect(await adapter.get('nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a group in the default subnets when none are given', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => undefined)
|
||||||
|
|
||||||
|
await adapter.create({values: {name: 'web-asg', launchTemplate: 'web-lt', minSize: '1', desiredCapacity: '2', maxSize: '3'}})
|
||||||
|
expect((sent[0] as CreateAutoScalingGroupCommand).input).toEqual({
|
||||||
|
AutoScalingGroupName: 'web-asg',
|
||||||
|
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
||||||
|
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
||||||
|
VPCZoneIdentifier: 'subnet-default-a,subnet-default-b',
|
||||||
|
HealthCheckType: 'EC2',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates from a template ID with given subnets, target groups and ELB health checks', async () => {
|
||||||
|
const {adapter, sent, ec2Sent} = adapterWith(() => undefined)
|
||||||
|
const targetGroup = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/web/abc'
|
||||||
|
|
||||||
|
await adapter.create({values: {name: 'web-asg', launchTemplate: 'lt-ba034c5aa8346f602', launchTemplateVersion: '2', minSize: '0', maxSize: '2', subnets: 'subnet-a, subnet-b', targetGroupArns: targetGroup, healthCheckType: 'ELB'}})
|
||||||
|
expect((sent[0] as CreateAutoScalingGroupCommand).input).toMatchObject({
|
||||||
|
LaunchTemplate: {LaunchTemplateId: 'lt-ba034c5aa8346f602', Version: '2'},
|
||||||
|
DesiredCapacity: 0,
|
||||||
|
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
||||||
|
TargetGroupARNs: [targetGroup],
|
||||||
|
HealthCheckType: 'ELB',
|
||||||
|
HealthCheckGracePeriod: 300,
|
||||||
|
})
|
||||||
|
expect(ec2Sent).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses capacities AWS would reject, before calling it', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => undefined)
|
||||||
|
const base = {name: 'g', launchTemplate: 'web-lt'}
|
||||||
|
await expect(adapter.create({values: {...base, minSize: '3', maxSize: '1'}})).rejects.toThrow('minSize cannot be larger than maxSize')
|
||||||
|
await expect(adapter.create({values: {...base, minSize: '1', desiredCapacity: '5', maxSize: '3'}})).rejects.toThrow('desiredCapacity must be between')
|
||||||
|
await expect(adapter.create({values: {...base, minSize: '0', maxSize: '500'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {...base, minSize: '', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {...base, minSize: '1', maxSize: '1', launchTemplateVersion: 'latest'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {...base, name: 'a:b', minSize: '1', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
expect(sent).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('changes capacity, keeping the values not given', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => undefined)
|
||||||
|
|
||||||
|
await adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 3})
|
||||||
|
expect((sent.find((command) => command instanceof UpdateAutoScalingGroupCommand) as UpdateAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', MinSize: 1, DesiredCapacity: 3, MaxSize: 3})
|
||||||
|
await expect(adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 4})).rejects.toThrow('desiredCapacity must be between')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('terminates an instance of the group, replacing it or shrinking the group', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => undefined)
|
||||||
|
|
||||||
|
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', true)
|
||||||
|
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', false)
|
||||||
|
const terminations = sent.filter((command): command is TerminateInstanceInAutoScalingGroupCommand => command instanceof TerminateInstanceInAutoScalingGroupCommand)
|
||||||
|
expect(terminations.map((command) => command.input.ShouldDecrementDesiredCapacity)).toEqual([false, true])
|
||||||
|
await expect(adapter.terminateAutoScalingInstance('web-asg', 'i-0000000000000000', true)).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a group together with its instances', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => undefined)
|
||||||
|
await adapter.delete('web-asg')
|
||||||
|
expect((sent[0] as DeleteAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', ForceDelete: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
type Activity,
|
||||||
|
type AutoScalingClient,
|
||||||
|
type AutoScalingGroup,
|
||||||
|
CreateAutoScalingGroupCommand,
|
||||||
|
DeleteAutoScalingGroupCommand,
|
||||||
|
DescribeAutoScalingGroupsCommand,
|
||||||
|
DescribeScalingActivitiesCommand,
|
||||||
|
TerminateInstanceInAutoScalingGroupCommand,
|
||||||
|
UpdateAutoScalingGroupCommand,
|
||||||
|
} from '@aws-sdk/client-auto-scaling'
|
||||||
|
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||||
|
import {AUTO_SCALING_GROUP_NAME_PATTERN, AUTO_SCALING_MAX_INSTANCES, awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||||
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import type {AutoScalingCapacityInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** The most recent activities a group's detail shows. */
|
||||||
|
const ACTIVITY_LIMIT = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to EC2 Auto Scaling, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Groups really scale: a reconciler runs every 10 seconds and launches or
|
||||||
|
* terminates EC2 instances (containers) until the group matches its desired
|
||||||
|
* capacity, registering them with the group's target groups, and records each
|
||||||
|
* launch and termination as a scaling activity.
|
||||||
|
* - Scaling policies are stored but never run: the runtime's alarms drive only
|
||||||
|
* Application Auto Scaling (ECS services), so this adapter does not offer them.
|
||||||
|
*
|
||||||
|
* Groups are keyed by name, which is unique per region.
|
||||||
|
*/
|
||||||
|
export class AwsAutoScalingAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'autoscaling' as const
|
||||||
|
|
||||||
|
constructor(private readonly autoscaling: AutoScalingClient, private readonly ec2: EC2Client) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsAutoScalingSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const groups: AutoScalingGroup[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({MaxRecords: 100, ...(next ? {NextToken: next} : {})}))
|
||||||
|
groups.push(...(res.AutoScalingGroups ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return groups.map((group) => toResource(group)).filter((group) => !search
|
||||||
|
|| group.name.toLowerCase().includes(search)
|
||||||
|
|| String(group.metadata.launchTemplateName ?? '').toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
const group = await this.describe(id)
|
||||||
|
if (!group) return null
|
||||||
|
return toResource(group, await this.activities(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (!new RegExp(AUTO_SCALING_GROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without a colon')
|
||||||
|
|
||||||
|
const template = text(values.launchTemplate)
|
||||||
|
if (!template) throw new ValidationError('launchTemplate is required')
|
||||||
|
const version = text(values.launchTemplateVersion) || '$Latest'
|
||||||
|
if (!/^(\d+|\$Latest|\$Default)$/.test(version)) throw new ValidationError('launchTemplateVersion must be a version number, $Latest or $Default')
|
||||||
|
|
||||||
|
const minSize = count(values.minSize, 'minSize')
|
||||||
|
const maxSize = count(values.maxSize, 'maxSize')
|
||||||
|
const desiredCapacity = text(values.desiredCapacity) ? count(values.desiredCapacity, 'desiredCapacity') : minSize
|
||||||
|
checkOrder({minSize, desiredCapacity, maxSize})
|
||||||
|
|
||||||
|
const subnets = text(values.subnets).split(',').map((subnet) => subnet.trim()).filter(Boolean)
|
||||||
|
if (subnets.some((subnet) => !/^subnet-[\w-]+$/.test(subnet))) throw new ValidationError('subnets must be subnet IDs separated by commas')
|
||||||
|
const targetGroupArns = text(values.targetGroupArns).split(/\s+/).filter(Boolean)
|
||||||
|
if (targetGroupArns.some((arn) => !/^arn:aws[a-z-]*:elasticloadbalancing:.+:targetgroup\/.+$/.test(arn))) {
|
||||||
|
throw new ValidationError('targetGroupArns must be target group ARNs, one per line')
|
||||||
|
}
|
||||||
|
const healthCheckType = values.healthCheckType === 'ELB' ? 'ELB' : 'EC2'
|
||||||
|
|
||||||
|
await this.autoscaling.send(new CreateAutoScalingGroupCommand({
|
||||||
|
AutoScalingGroupName: name,
|
||||||
|
LaunchTemplate: {...(template.startsWith('lt-') ? {LaunchTemplateId: template} : {LaunchTemplateName: template}), Version: version},
|
||||||
|
MinSize: minSize,
|
||||||
|
MaxSize: maxSize,
|
||||||
|
DesiredCapacity: desiredCapacity,
|
||||||
|
VPCZoneIdentifier: (subnets.length > 0 ? subnets : await this.defaultSubnets()).join(','),
|
||||||
|
...(targetGroupArns.length > 0 ? {TargetGroupARNs: targetGroupArns} : {}),
|
||||||
|
HealthCheckType: healthCheckType,
|
||||||
|
// AWS's default grace period when a load balancer decides health.
|
||||||
|
...(healthCheckType === 'ELB' ? {HealthCheckGracePeriod: 300} : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`EC2 Auto Scaling did not return the new group ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ForceDelete terminates the group's instances too, which is what deleting a group in the console means. */
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.autoscaling.send(new DeleteAutoScalingGroupCommand({AutoScalingGroupName: id, ForceDelete: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAutoScalingCapacity(id: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||||
|
const group = await this.describe(id)
|
||||||
|
if (!group) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
minSize: input.minSize === undefined ? group.MinSize ?? 0 : count(input.minSize, 'minSize'),
|
||||||
|
desiredCapacity: input.desiredCapacity === undefined ? group.DesiredCapacity ?? 0 : count(input.desiredCapacity, 'desiredCapacity'),
|
||||||
|
maxSize: input.maxSize === undefined ? group.MaxSize ?? 0 : count(input.maxSize, 'maxSize'),
|
||||||
|
}
|
||||||
|
checkOrder(next)
|
||||||
|
|
||||||
|
await this.autoscaling.send(new UpdateAutoScalingGroupCommand({
|
||||||
|
AutoScalingGroupName: id,
|
||||||
|
MinSize: next.minSize,
|
||||||
|
DesiredCapacity: next.desiredCapacity,
|
||||||
|
MaxSize: next.maxSize,
|
||||||
|
}))
|
||||||
|
const updated = await this.get(id)
|
||||||
|
if (!updated) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateAutoScalingInstance(id: string, instanceId: string, replace: boolean): Promise<void> {
|
||||||
|
if (!/^i-[0-9a-f]+$/.test(instanceId)) throw new ValidationError('instanceId must be an EC2 instance ID such as i-0abc1234')
|
||||||
|
const group = await this.describe(id)
|
||||||
|
if (!group?.Instances?.some((instance) => instance.InstanceId === instanceId)) {
|
||||||
|
throw new NotFoundError(`Instance ${instanceId} is not in Auto Scaling group ${id}`)
|
||||||
|
}
|
||||||
|
await this.autoscaling.send(new TerminateInstanceInAutoScalingGroupCommand({InstanceId: instanceId, ShouldDecrementDesiredCapacity: !replace}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async describe(name: string): Promise<AutoScalingGroup | null> {
|
||||||
|
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({AutoScalingGroupNames: [name]}))
|
||||||
|
return res.AutoScalingGroups?.find((group) => group.AutoScalingGroupName === name) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activities are enrichment: a failure leaves the group readable rather than failing the request. */
|
||||||
|
private async activities(name: string): Promise<Activity[] | 'unavailable'> {
|
||||||
|
try {
|
||||||
|
const res = await this.autoscaling.send(new DescribeScalingActivitiesCommand({AutoScalingGroupName: name, MaxRecords: ACTIVITY_LIMIT}))
|
||||||
|
return res.Activities ?? []
|
||||||
|
} catch {
|
||||||
|
return 'unavailable'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AWS needs subnets or zones; like its console, fall back to the default VPC's subnets. */
|
||||||
|
private async defaultSubnets(): Promise<string[]> {
|
||||||
|
const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]}))
|
||||||
|
const subnets = (res.Subnets ?? []).map((subnet) => subnet.SubnetId).filter((id): id is string => Boolean(id)).sort()
|
||||||
|
if (subnets.length === 0) throw new ValidationError('There is no default VPC to launch into; enter subnet IDs')
|
||||||
|
return subnets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(group: AutoScalingGroup, activities?: Activity[] | 'unavailable'): CloudResource {
|
||||||
|
const name = group.AutoScalingGroupName ?? ''
|
||||||
|
const instances = group.Instances ?? []
|
||||||
|
const template = group.LaunchTemplate ?? group.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'autoscaling',
|
||||||
|
type: 'autoScalingGroup',
|
||||||
|
region: group.AutoScalingGroupARN?.split(':')[3] || null,
|
||||||
|
createdAt: group.CreatedTime ? group.CreatedTime.toISOString() : null,
|
||||||
|
status: group.Status ?? null,
|
||||||
|
metadata: {
|
||||||
|
arn: group.AutoScalingGroupARN,
|
||||||
|
minSize: group.MinSize ?? 0,
|
||||||
|
maxSize: group.MaxSize ?? 0,
|
||||||
|
desiredCapacity: group.DesiredCapacity ?? 0,
|
||||||
|
capacity: `${instances.filter((instance) => instance.LifecycleState === 'InService').length} in service`,
|
||||||
|
launchTemplateName: template?.LaunchTemplateName ?? template?.LaunchTemplateId ?? group.LaunchConfigurationName ?? null,
|
||||||
|
launchTemplateVersion: template?.Version ?? null,
|
||||||
|
subnets: (group.VPCZoneIdentifier ?? '').split(',').filter(Boolean),
|
||||||
|
targetGroupArns: group.TargetGroupARNs ?? [],
|
||||||
|
healthCheckType: group.HealthCheckType ?? null,
|
||||||
|
suspendedProcesses: (group.SuspendedProcesses ?? []).map((process) => process.ProcessName).filter(Boolean),
|
||||||
|
instances: instances.map((instance) => ({
|
||||||
|
id: instance.InstanceId ?? '',
|
||||||
|
availabilityZone: instance.AvailabilityZone ?? null,
|
||||||
|
lifecycleState: instance.LifecycleState ?? null,
|
||||||
|
health: instance.HealthStatus ?? null,
|
||||||
|
instanceType: instance.InstanceType ?? null,
|
||||||
|
protectedFromScaleIn: instance.ProtectedFromScaleIn ?? false,
|
||||||
|
})),
|
||||||
|
...(activities === 'unavailable' ? {activitiesUnavailable: true} : activities ? {
|
||||||
|
activities: activities.map((activity) => ({
|
||||||
|
id: activity.ActivityId ?? '',
|
||||||
|
description: activity.Description ?? '',
|
||||||
|
cause: activity.Cause ?? '',
|
||||||
|
status: activity.StatusCode ?? null,
|
||||||
|
statusMessage: activity.StatusMessage ?? null,
|
||||||
|
startTime: activity.StartTime ? activity.StartTime.toISOString() : null,
|
||||||
|
})),
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkOrder({minSize, desiredCapacity, maxSize}: Required<AutoScalingCapacityInput>): void {
|
||||||
|
if (minSize > maxSize) throw new ValidationError('minSize cannot be larger than maxSize')
|
||||||
|
if (desiredCapacity < minSize || desiredCapacity > maxSize) throw new ValidationError('desiredCapacity must be between minSize and maxSize')
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(value: unknown, field: string): number {
|
||||||
|
const raw = typeof value === 'number' ? value : Number(text(value))
|
||||||
|
if (!Number.isInteger(raw) || raw < 0 || raw > AUTO_SCALING_MAX_INSTANCES || (typeof value === 'string' && !text(value))) {
|
||||||
|
throw new ValidationError(`${field} must be a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ describe('AwsCdnAdapter distributions', () => {
|
|||||||
|
|
||||||
const [listed] = await cdn.list()
|
const [listed] = await cdn.list()
|
||||||
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
|
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
|
||||||
expect(listed.metadata).toMatchObject({viewerUrl: 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/', enabled: true, comment: 'Docs site', originDomains: ['site.s3.amazonaws.com']})
|
expect(listed.metadata).toMatchObject({viewerUrl: 'http://e7cpm0p0g0vc8d.cloudfront.localhost:4566/', enabled: true, comment: 'Docs site', originDomains: ['site.s3.amazonaws.com']})
|
||||||
expect(await cdn.list({search: 'site.s3'})).toHaveLength(1)
|
expect(await cdn.list({search: 'site.s3'})).toHaveLength(1)
|
||||||
expect(await cdn.list({search: 'nothing'})).toHaveLength(0)
|
expect(await cdn.list({search: 'nothing'})).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ const RECENT_INVALIDATIONS = 5
|
|||||||
* - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled`
|
* - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled`
|
||||||
* (409), as on AWS, so delete disables it first.
|
* (409), as on AWS, so delete disables it first.
|
||||||
* - A missing distribution is `NoSuchDistribution` with HTTP 404.
|
* - A missing distribution is `NoSuchDistribution` with HTTP 404.
|
||||||
* - Viewer requests reach a distribution at `{id}.cloudfront.localhost.floci.io` on the
|
* - Viewer requests reach a distribution at `{id}.cloudfront.localhost` on the runtime's
|
||||||
* runtime's port, a name that resolves to 127.0.0.1 without any hosts-file entry.
|
* port. Browsers and curl resolve every `.localhost` name to the machine itself (RFC 6761),
|
||||||
|
* so no DNS or hosts-file entry is needed.
|
||||||
*/
|
*/
|
||||||
export class AwsCdnAdapter implements CloudServiceAdapter {
|
export class AwsCdnAdapter implements CloudServiceAdapter {
|
||||||
readonly cloud = 'aws' as const
|
readonly cloud = 'aws' as const
|
||||||
@@ -246,7 +247,7 @@ export class AwsCdnAdapter implements CloudServiceAdapter {
|
|||||||
/** Where a browser on this machine reaches the distribution through the runtime. */
|
/** Where a browser on this machine reaches the distribution through the runtime. */
|
||||||
private viewerUrl(id: string): string {
|
private viewerUrl(id: string): string {
|
||||||
const port = new URL(this.endpoint).port || '4566'
|
const port = new URL(this.endpoint).port || '4566'
|
||||||
return `http://${id.toLowerCase()}.cloudfront.localhost.floci.io:${port}/`
|
return `http://${id.toLowerCase()}.cloudfront.localhost:${port}/`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
type ACMClient,
|
||||||
|
DeleteCertificateCommand,
|
||||||
|
DescribeCertificateCommand,
|
||||||
|
ExportCertificateCommand,
|
||||||
|
GetCertificateCommand,
|
||||||
|
ImportCertificateCommand,
|
||||||
|
ListCertificatesCommand,
|
||||||
|
RequestCertificateCommand,
|
||||||
|
RevokeCertificateCommand,
|
||||||
|
} from '@aws-sdk/client-acm'
|
||||||
|
import {AwsCertificatesAdapter} from './AwsCertificatesAdapter'
|
||||||
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
} as unknown as ACMClient
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
function awsError(name: string, status = 400): Error {
|
||||||
|
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
||||||
|
const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}`
|
||||||
|
const detail = {
|
||||||
|
CertificateArn: ARN,
|
||||||
|
DomainName: 'notes.thecloud.localhost',
|
||||||
|
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'],
|
||||||
|
DomainValidationOptions: [{
|
||||||
|
DomainName: 'notes.thecloud.localhost',
|
||||||
|
ValidationStatus: 'SUCCESS',
|
||||||
|
ValidationMethod: 'DNS',
|
||||||
|
ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'},
|
||||||
|
}],
|
||||||
|
Status: 'ISSUED',
|
||||||
|
Type: 'AMAZON_ISSUED',
|
||||||
|
KeyAlgorithm: 'RSA-2048',
|
||||||
|
Issuer: 'CN=Floci Local CA',
|
||||||
|
CreatedAt: new Date('2026-10-25T16:28:12Z'),
|
||||||
|
NotAfter: new Date('2027-10-25T16:28:12Z'),
|
||||||
|
InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'],
|
||||||
|
Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'},
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const stub = stubAcm(handler)
|
||||||
|
return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsCertificatesAdapter', () => {
|
||||||
|
test('lists every key type across pages and searches the alternative names too', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => {
|
||||||
|
if (!(command instanceof ListCertificatesCommand)) return {}
|
||||||
|
return command.input.NextToken
|
||||||
|
? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]}
|
||||||
|
: {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'}
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = await adapter.list()
|
||||||
|
expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([
|
||||||
|
[ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'],
|
||||||
|
['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'],
|
||||||
|
])
|
||||||
|
// Without Includes, AWS leaves EC certificates out of the list.
|
||||||
|
expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1')
|
||||||
|
expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes a certificate with its users and the DNS records validation asks for', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => ({Certificate: detail}))
|
||||||
|
|
||||||
|
const certificate = await adapter.get(ID)
|
||||||
|
expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN)
|
||||||
|
expect(certificate?.metadata).toMatchObject({
|
||||||
|
inUse: 'Yes',
|
||||||
|
inUseBy: detail.InUseBy,
|
||||||
|
exportable: true,
|
||||||
|
issuer: 'CN=Floci Local CA',
|
||||||
|
notAfter: '2027-10-25T16:28:12.000Z',
|
||||||
|
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') })
|
||||||
|
expect(await adapter.get(ID)).toBeNull()
|
||||||
|
await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
expect(sent).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requests a certificate for the domain and its extra names', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail})
|
||||||
|
|
||||||
|
const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}})
|
||||||
|
expect(created.id).toBe(ID)
|
||||||
|
expect((sent[0] as RequestCertificateCommand).input).toEqual({
|
||||||
|
DomainName: 'notes.thecloud.localhost',
|
||||||
|
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'],
|
||||||
|
ValidationMethod: 'DNS',
|
||||||
|
KeyAlgorithm: 'EC_prime256v1',
|
||||||
|
Options: {Export: 'ENABLED'},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a request ACM would reject before calling it', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => ({}))
|
||||||
|
await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name')
|
||||||
|
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
expect(sent).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('imports PEM text as bytes, with the chain when given', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}})
|
||||||
|
const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
|
||||||
|
const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----'
|
||||||
|
|
||||||
|
const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}})
|
||||||
|
expect(created.metadata.certificateType).toBe('Imported')
|
||||||
|
const input = (sent[0] as ImportCertificateCommand).input
|
||||||
|
expect(new TextDecoder().decode(input.Certificate)).toBe(certificate)
|
||||||
|
expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey)
|
||||||
|
expect(input.CertificateChain).toBeDefined()
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('downloads, exports and revokes by the rebuilt ARN', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => {
|
||||||
|
if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'}
|
||||||
|
if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'}
|
||||||
|
if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN}
|
||||||
|
return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'})
|
||||||
|
expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'})
|
||||||
|
expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame')
|
||||||
|
const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED')
|
||||||
|
expect(revoked.status).toBe('REVOKED')
|
||||||
|
expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'})
|
||||||
|
|
||||||
|
await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => {
|
||||||
|
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') })
|
||||||
|
await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException')
|
||||||
|
expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
type ACMClient,
|
||||||
|
type CertificateDetail,
|
||||||
|
type CertificateSummary,
|
||||||
|
DeleteCertificateCommand,
|
||||||
|
DescribeCertificateCommand,
|
||||||
|
ExportCertificateCommand,
|
||||||
|
GetCertificateCommand,
|
||||||
|
ImportCertificateCommand,
|
||||||
|
type KeyAlgorithm,
|
||||||
|
ListCertificatesCommand,
|
||||||
|
RequestCertificateCommand,
|
||||||
|
RevokeCertificateCommand,
|
||||||
|
} from '@aws-sdk/client-acm'
|
||||||
|
import {
|
||||||
|
awsCertificatesSchema,
|
||||||
|
CERTIFICATE_DOMAIN_PATTERN,
|
||||||
|
CERTIFICATE_KEY_ALGORITHMS,
|
||||||
|
CERTIFICATE_REVOCATION_REASONS,
|
||||||
|
CERTIFICATE_VALIDATION_METHODS,
|
||||||
|
} from '../cloud-spi/certificatesSchema'
|
||||||
|
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import type {
|
||||||
|
CertificatePem,
|
||||||
|
CertificateRevocationReason,
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
ExportedCertificate,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListCertificates returns only RSA certificates unless asked for other key
|
||||||
|
* types, so a list without this would silently leave out every EC certificate.
|
||||||
|
*/
|
||||||
|
const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1']
|
||||||
|
/** ACM's limit on names per certificate, the domain name included. */
|
||||||
|
const MAX_NAMES = 100
|
||||||
|
const CERTIFICATE_TYPE_LABEL: Record<string, string> = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to AWS Certificate Manager, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Requested certificates are signed by the runtime's own local CA and issued at
|
||||||
|
* once (its validation wait defaults to 0), but DescribeCertificate still lists
|
||||||
|
* the CNAME records DNS validation would ask for, as AWS does.
|
||||||
|
* - DeleteCertificate refuses with ResourceInUseException while a load balancer,
|
||||||
|
* distribution or API domain uses the certificate, as on AWS.
|
||||||
|
*
|
||||||
|
* Resources are keyed by the certificate's UUID, the last part of its ARN, because
|
||||||
|
* an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the
|
||||||
|
* account and region this adapter serves.
|
||||||
|
*/
|
||||||
|
export class AwsCertificatesAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'certificates' as const
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly acm: ACMClient,
|
||||||
|
private readonly accountId: string,
|
||||||
|
private readonly region = process.env.AWS_REGION || 'us-east-1',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsCertificatesSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const summaries: CertificateSummary[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})}))
|
||||||
|
summaries.push(...(res.CertificateSummaryList ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search
|
||||||
|
|| certificate.name.toLowerCase().includes(search)
|
||||||
|
|| certificate.id.includes(search)
|
||||||
|
|| (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)}))
|
||||||
|
return res.Certificate ? this.fromDetail(res.Certificate) : null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'ResourceNotFoundException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const source = input.values.source ?? 'request'
|
||||||
|
const arn = source === 'import' ? await this.importCertificate(input.values)
|
||||||
|
: source === 'request' ? await this.requestCertificate(input.values)
|
||||||
|
: null
|
||||||
|
if (arn === null) throw new ValidationError('source must be request or import')
|
||||||
|
|
||||||
|
const created = await this.get(idFromArn(arn))
|
||||||
|
if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCertificatePem(id: string): Promise<CertificatePem> {
|
||||||
|
const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)}))
|
||||||
|
if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate')
|
||||||
|
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS exports only private certificates and public ones requested as
|
||||||
|
* exportable; the private key comes back encrypted with the passphrase.
|
||||||
|
*/
|
||||||
|
async exportCertificate(id: string, passphrase: string): Promise<ExportedCertificate> {
|
||||||
|
if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) {
|
||||||
|
throw new ValidationError('passphrase must be 4 to 128 characters')
|
||||||
|
}
|
||||||
|
if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %')
|
||||||
|
|
||||||
|
const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)}))
|
||||||
|
if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate')
|
||||||
|
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise<CloudResource> {
|
||||||
|
if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) {
|
||||||
|
throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`)
|
||||||
|
}
|
||||||
|
await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason}))
|
||||||
|
const revoked = await this.get(id)
|
||||||
|
if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`)
|
||||||
|
return revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestCertificate(values: Record<string, unknown>): Promise<string> {
|
||||||
|
const domainName = text(values.domainName).toLowerCase()
|
||||||
|
if (!domainName) throw new ValidationError('domainName is required')
|
||||||
|
const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN)
|
||||||
|
if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com')
|
||||||
|
|
||||||
|
const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))]
|
||||||
|
const invalid = alternatives.find((name) => !pattern.test(name))
|
||||||
|
if (invalid) throw new ValidationError(`${invalid} is not a domain name`)
|
||||||
|
if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`)
|
||||||
|
|
||||||
|
const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod')
|
||||||
|
const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm')
|
||||||
|
const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export')
|
||||||
|
|
||||||
|
const res = await this.acm.send(new RequestCertificateCommand({
|
||||||
|
DomainName: domainName,
|
||||||
|
...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}),
|
||||||
|
ValidationMethod: validationMethod,
|
||||||
|
KeyAlgorithm: keyAlgorithm,
|
||||||
|
Options: {Export: exportOption},
|
||||||
|
}))
|
||||||
|
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate')
|
||||||
|
return res.CertificateArn
|
||||||
|
}
|
||||||
|
|
||||||
|
private async importCertificate(values: Record<string, unknown>): Promise<string> {
|
||||||
|
const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE')
|
||||||
|
const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY')
|
||||||
|
const chain = text(values.certificateChain)
|
||||||
|
if (chain) pem(chain, 'certificateChain', 'CERTIFICATE')
|
||||||
|
|
||||||
|
const encode = (value: string) => new TextEncoder().encode(value)
|
||||||
|
const res = await this.acm.send(new ImportCertificateCommand({
|
||||||
|
Certificate: encode(certificate),
|
||||||
|
PrivateKey: encode(privateKey),
|
||||||
|
...(chain ? {CertificateChain: encode(chain)} : {}),
|
||||||
|
}))
|
||||||
|
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate')
|
||||||
|
return res.CertificateArn
|
||||||
|
}
|
||||||
|
|
||||||
|
private arn(id: string): string {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN')
|
||||||
|
return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromSummary(summary: CertificateSummary): CloudResource {
|
||||||
|
const arn = summary.CertificateArn ?? ''
|
||||||
|
return {
|
||||||
|
id: idFromArn(arn),
|
||||||
|
name: summary.DomainName ?? '',
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'certificates',
|
||||||
|
type: 'certificate',
|
||||||
|
region: arn.split(':')[3] || this.region,
|
||||||
|
createdAt: iso(summary.CreatedAt ?? summary.ImportedAt),
|
||||||
|
status: summary.Status ?? null,
|
||||||
|
metadata: {
|
||||||
|
arn,
|
||||||
|
certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null,
|
||||||
|
inUse: summary.InUse ? 'Yes' : 'No',
|
||||||
|
keyAlgorithm: summary.KeyAlgorithm ?? null,
|
||||||
|
notAfter: iso(summary.NotAfter),
|
||||||
|
subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromDetail(detail: CertificateDetail): CloudResource {
|
||||||
|
const arn = detail.CertificateArn ?? ''
|
||||||
|
const inUseBy = detail.InUseBy ?? []
|
||||||
|
return {
|
||||||
|
id: idFromArn(arn),
|
||||||
|
name: detail.DomainName ?? '',
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'certificates',
|
||||||
|
type: 'certificate',
|
||||||
|
region: arn.split(':')[3] || this.region,
|
||||||
|
createdAt: iso(detail.CreatedAt ?? detail.ImportedAt),
|
||||||
|
status: detail.Status ?? null,
|
||||||
|
metadata: {
|
||||||
|
arn,
|
||||||
|
certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null,
|
||||||
|
inUse: inUseBy.length > 0 ? 'Yes' : 'No',
|
||||||
|
inUseBy,
|
||||||
|
keyAlgorithm: detail.KeyAlgorithm ?? null,
|
||||||
|
signatureAlgorithm: detail.SignatureAlgorithm ?? null,
|
||||||
|
notBefore: iso(detail.NotBefore),
|
||||||
|
notAfter: iso(detail.NotAfter),
|
||||||
|
issuedAt: iso(detail.IssuedAt),
|
||||||
|
importedAt: iso(detail.ImportedAt),
|
||||||
|
revokedAt: iso(detail.RevokedAt),
|
||||||
|
revocationReason: detail.RevocationReason ?? null,
|
||||||
|
failureReason: detail.FailureReason ?? null,
|
||||||
|
subject: detail.Subject ?? null,
|
||||||
|
issuer: detail.Issuer ?? null,
|
||||||
|
serial: detail.Serial ?? null,
|
||||||
|
subjectAlternativeNames: detail.SubjectAlternativeNames ?? [],
|
||||||
|
exportable: detail.Options?.Export === 'ENABLED',
|
||||||
|
transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null,
|
||||||
|
renewalEligibility: detail.RenewalEligibility ?? null,
|
||||||
|
domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({
|
||||||
|
domainName: validation.DomainName ?? '',
|
||||||
|
status: validation.ValidationStatus ?? null,
|
||||||
|
method: validation.ValidationMethod ?? null,
|
||||||
|
record: validation.ResourceRecord
|
||||||
|
? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''}
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `arn:aws:acm:us-east-1:000000000000:certificate/<uuid>` — the UUID is what the console keys on. */
|
||||||
|
function idFromArn(arn: string): string {
|
||||||
|
return arn.slice(arn.lastIndexOf('/') + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value: Date | undefined): string | null {
|
||||||
|
return value ? value.toISOString() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function pem(value: unknown, field: string, label: string): string {
|
||||||
|
const body = text(value)
|
||||||
|
if (!body) throw new ValidationError(`${field} is required`)
|
||||||
|
if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) {
|
||||||
|
throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] {
|
||||||
|
const raw = text(value)
|
||||||
|
if (!raw) return allowed[0] as T[number]
|
||||||
|
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||||
|
return raw as T[number]
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
CreateDatabaseCommand,
|
||||||
|
CreateTableCommand,
|
||||||
|
DeleteDatabaseCommand,
|
||||||
|
DeleteTableCommand,
|
||||||
|
GetDatabaseCommand,
|
||||||
|
GetDatabasesCommand,
|
||||||
|
GetTablesCommand,
|
||||||
|
type GlueClient,
|
||||||
|
} from '@aws-sdk/client-glue'
|
||||||
|
import {AwsDataCatalogAdapter, describeTable} from './AwsDataCatalogAdapter'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
const database = {Name: 'shop', Description: 'Orders and customers', LocationUri: 's3://data/shop/', CatalogId: '000000000000', CreateTime: new Date('2026-10-25T18:00:00Z')}
|
||||||
|
const ordersTable = {
|
||||||
|
Name: 'orders',
|
||||||
|
TableType: 'EXTERNAL_TABLE',
|
||||||
|
Parameters: {classification: 'csv', 'skip.header.line.count': '1'},
|
||||||
|
StorageDescriptor: {
|
||||||
|
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
|
||||||
|
Location: 's3://data/orders/',
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
|
||||||
|
},
|
||||||
|
PartitionKeys: [{Name: 'day', Type: 'string'}],
|
||||||
|
}
|
||||||
|
const eventsTable = {
|
||||||
|
Name: 'events',
|
||||||
|
StorageDescriptor: {Columns: [{Name: 'kind', Type: 'string'}], Location: 's3://data/events/', SerdeInfo: {SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'}},
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
const result = handler(command)
|
||||||
|
if (result) return result
|
||||||
|
if (command instanceof GetDatabasesCommand) return {DatabaseList: [database]}
|
||||||
|
if (command instanceof GetDatabaseCommand) return {Database: database}
|
||||||
|
if (command instanceof GetTablesCommand) return {TableList: [ordersTable, eventsTable]}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {sent, adapter: new AwsDataCatalogAdapter(client as unknown as GlueClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsDataCatalogAdapter', () => {
|
||||||
|
test('lists databases across pages with their table counts', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (command instanceof GetDatabasesCommand) {
|
||||||
|
return command.input.NextToken ? {DatabaseList: [{Name: 'logs'}]} : {DatabaseList: [database], NextToken: 'n'}
|
||||||
|
}
|
||||||
|
if (command instanceof GetTablesCommand && command.input.DatabaseName === 'logs') return {TableList: []}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const listed = await adapter.list()
|
||||||
|
expect(listed.map((db) => [db.name, db.metadata.tableCount, db.metadata.description])).toEqual([['shop', 2, 'Orders and customers'], ['logs', 0, null]])
|
||||||
|
expect((await adapter.list({search: 'customers'})).map((db) => db.name)).toEqual(['shop'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes each table with its format, location and columns, sorted by name', async () => {
|
||||||
|
const {adapter} = adapterWith()
|
||||||
|
const detail = await adapter.get('shop')
|
||||||
|
const tables = detail?.metadata.tables as Array<Record<string, unknown>>
|
||||||
|
|
||||||
|
expect(tables.map((table) => table.name)).toEqual(['events', 'orders'])
|
||||||
|
expect(tables[1]).toMatchObject({format: 'csv', delimiter: ';', headerRow: true, location: 's3://data/orders/', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [{name: 'day', type: 'string'}]})
|
||||||
|
expect(tables[0]).toMatchObject({format: 'json', delimiter: null, headerRow: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads the format the way the query engine does', () => {
|
||||||
|
expect(describeTable({Name: 'p', StorageDescriptor: {InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat'}}).format).toBe('parquet')
|
||||||
|
expect(describeTable({Name: 'i', Parameters: {table_type: 'ICEBERG'}}).format).toBe('iceberg')
|
||||||
|
expect(describeTable({Name: 'c'}).format).toBe('csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a database that does not exist', async () => {
|
||||||
|
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
|
||||||
|
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
|
||||||
|
expect(await adapter.get('nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a database with an optional description and location', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'shop', description: 'Orders', locationUri: 's3://data/shop/'}})
|
||||||
|
expect((sent.find((command) => command instanceof CreateDatabaseCommand) as CreateDatabaseCommand).input).toEqual({DatabaseInput: {Name: 'shop', Description: 'Orders', LocationUri: 's3://data/shop/'}})
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {name: 'Shop'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'shop', locationUri: 'https://example.com'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a CSV table Athena can read, with its delimiter and header row', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.createCatalogTable('shop', {name: 'orders', location: 's3://data/orders', format: 'csv', delimiter: ';', headerRow: true, columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'DOUBLE'}]})
|
||||||
|
|
||||||
|
const create = sent.find((command) => command instanceof CreateTableCommand) as CreateTableCommand
|
||||||
|
expect(create.input).toEqual({
|
||||||
|
DatabaseName: 'shop',
|
||||||
|
TableInput: {
|
||||||
|
Name: 'orders',
|
||||||
|
TableType: 'EXTERNAL_TABLE',
|
||||||
|
Parameters: {classification: 'csv', EXTERNAL: 'TRUE', 'skip.header.line.count': '1'},
|
||||||
|
StorageDescriptor: {
|
||||||
|
Columns: [{Name: 'id', Type: 'int'}, {Name: 'amount', Type: 'double'}],
|
||||||
|
Location: 's3://data/orders/',
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
|
||||||
|
SerdeInfo: {SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe', Parameters: {'field.delim': ';'}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates JSON and Parquet tables with their own SerDe', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.createCatalogTable('shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
|
||||||
|
await adapter.createCatalogTable('shop', {name: 'facts', location: 's3://data/facts/', format: 'parquet', columns: [{name: 'total', type: 'decimal(12,2)'}]})
|
||||||
|
|
||||||
|
const [json, parquet] = sent.filter((command) => command instanceof CreateTableCommand) as CreateTableCommand[]
|
||||||
|
expect(json.input.TableInput?.StorageDescriptor?.SerdeInfo).toEqual({SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe'})
|
||||||
|
expect(parquet.input.TableInput?.StorageDescriptor?.InputFormat).toBe('org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat')
|
||||||
|
expect(parquet.input.TableInput?.StorageDescriptor?.Columns).toEqual([{Name: 'total', Type: 'decimal(12,2)'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses tables Athena could not query', async () => {
|
||||||
|
const {adapter} = adapterWith()
|
||||||
|
const table = {name: 'orders', location: 's3://data/orders/', format: 'csv' as const, columns: [{name: 'id', type: 'int'}]}
|
||||||
|
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, columns: []})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'int'}, {name: 'id', type: 'string'}]})).rejects.toThrow(/listed twice/)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, columns: [{name: 'id', type: 'uuid'}]})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, location: '/tmp/orders'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, delimiter: '||'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createCatalogTable('shop', {...table, format: 'avro' as never})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creating a table in a missing database is a not-found', async () => {
|
||||||
|
const missing = Object.assign(new Error('missing'), {name: 'EntityNotFoundException'})
|
||||||
|
const {adapter} = adapterWith((command) => { if (command instanceof GetDatabaseCommand) throw missing; return undefined })
|
||||||
|
await expect(adapter.createCatalogTable('nope', {name: 'orders', location: 's3://data/orders/', format: 'json', columns: [{name: 'id', type: 'int'}]})).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes tables and databases', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.deleteCatalogTable('shop', 'orders')
|
||||||
|
await adapter.delete('shop')
|
||||||
|
expect((sent.find((command) => command instanceof DeleteTableCommand) as DeleteTableCommand).input).toEqual({DatabaseName: 'shop', Name: 'orders'})
|
||||||
|
expect((sent.find((command) => command instanceof DeleteDatabaseCommand) as DeleteDatabaseCommand).input).toEqual({Name: 'shop'})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
CreateDatabaseCommand,
|
||||||
|
CreateTableCommand,
|
||||||
|
type Database,
|
||||||
|
DeleteDatabaseCommand,
|
||||||
|
DeleteTableCommand,
|
||||||
|
GetDatabaseCommand,
|
||||||
|
GetDatabasesCommand,
|
||||||
|
GetTablesCommand,
|
||||||
|
type GlueClient,
|
||||||
|
type StorageDescriptor,
|
||||||
|
type Table,
|
||||||
|
} from '@aws-sdk/client-glue'
|
||||||
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {awsDataCatalogSchema, CATALOG_COLUMN_TYPES, CATALOG_NAME_PATTERN, CATALOG_TABLE_FORMATS} from '../cloud-spi/dataCatalogSchema'
|
||||||
|
import type {CatalogTableInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
|
||||||
|
const DECIMAL_TYPE = /^decimal\(\d{1,2},\d{1,2}\)$/
|
||||||
|
|
||||||
|
/** The Hive storage settings Athena expects for each format, as the Glue console writes them. */
|
||||||
|
const STORAGE_FOR_FORMAT = {
|
||||||
|
csv: {
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
|
||||||
|
SerializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe',
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
InputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
|
||||||
|
SerializationLibrary: 'org.openx.data.jsonserde.JsonSerDe',
|
||||||
|
},
|
||||||
|
parquet: {
|
||||||
|
InputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat',
|
||||||
|
OutputFormat: 'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat',
|
||||||
|
SerializationLibrary: 'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to the AWS Glue Data Catalog, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - The catalog is what TheCloud's Athena reads: a table over CSV, JSON lines
|
||||||
|
* or Parquet files in S3 can be queried as soon as it exists.
|
||||||
|
* - Glue jobs and crawlers are stored but never run, so the console offers
|
||||||
|
* only the catalog: databases and the tables in them.
|
||||||
|
* - DeleteDatabase removes the database's tables too, as it does in AWS.
|
||||||
|
*/
|
||||||
|
export class AwsDataCatalogAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'datacatalog' as const
|
||||||
|
|
||||||
|
constructor(private readonly glue: GlueClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsDataCatalogSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: Database[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.glue.send(new GetDatabasesCommand({MaxResults: 100, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.DatabaseList ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
const matching = found.filter((database) => !search
|
||||||
|
|| (database.Name ?? '').toLowerCase().includes(search)
|
||||||
|
|| (database.Description ?? '').toLowerCase().includes(search))
|
||||||
|
return Promise.all(matching.map(async (database) => toResource(database, {tableCount: (await this.tables(database.Name ?? '')).length})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
const database = await this.database(id)
|
||||||
|
if (!database) return null
|
||||||
|
const tables = await this.tables(id)
|
||||||
|
return toResource(database, {tableCount: tables.length, tables})
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = catalogName(values.name, 'name')
|
||||||
|
const description = text(values.description)
|
||||||
|
const locationUri = text(values.locationUri)
|
||||||
|
if (locationUri && !S3_URI.test(locationUri)) throw new ValidationError('locationUri must be an S3 URI such as s3://my-bucket/warehouse/')
|
||||||
|
|
||||||
|
await this.glue.send(new CreateDatabaseCommand({
|
||||||
|
DatabaseInput: {
|
||||||
|
Name: name,
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
...(locationUri ? {LocationUri: locationUri} : {}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`Glue did not return the new database ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.glue.send(new DeleteDatabaseCommand({Name: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCatalogTable(databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
|
||||||
|
const name = catalogName(input.name, 'name')
|
||||||
|
const location = text(input.location)
|
||||||
|
if (!S3_URI.test(location)) throw new ValidationError('location must be an S3 URI such as s3://my-bucket/orders/')
|
||||||
|
const format = text(input.format) as CatalogTableInput['format']
|
||||||
|
if (!(CATALOG_TABLE_FORMATS as readonly string[]).includes(format)) throw new ValidationError(`format must be one of ${CATALOG_TABLE_FORMATS.join(', ')}`)
|
||||||
|
const columns = tableColumns(input.columns)
|
||||||
|
const delimiter = typeof input.delimiter === 'string' && input.delimiter !== '' ? input.delimiter : ','
|
||||||
|
if (format === 'csv' && [...delimiter].length !== 1) throw new ValidationError('delimiter must be a single character')
|
||||||
|
const description = text(input.description)
|
||||||
|
if (!await this.database(databaseName)) throw new NotFoundError(`Database ${databaseName} not found`)
|
||||||
|
|
||||||
|
const storage = STORAGE_FOR_FORMAT[format]
|
||||||
|
const csv = format === 'csv'
|
||||||
|
await this.glue.send(new CreateTableCommand({
|
||||||
|
DatabaseName: databaseName,
|
||||||
|
TableInput: {
|
||||||
|
Name: name,
|
||||||
|
TableType: 'EXTERNAL_TABLE',
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
Parameters: {
|
||||||
|
classification: format,
|
||||||
|
EXTERNAL: 'TRUE',
|
||||||
|
...(csv && input.headerRow ? {'skip.header.line.count': '1'} : {}),
|
||||||
|
},
|
||||||
|
StorageDescriptor: {
|
||||||
|
Columns: columns.map((column) => ({Name: column.name, Type: column.type})),
|
||||||
|
// Athena reads every file under the prefix, so the location is a directory.
|
||||||
|
Location: location.endsWith('/') ? location : `${location}/`,
|
||||||
|
InputFormat: storage.InputFormat,
|
||||||
|
OutputFormat: storage.OutputFormat,
|
||||||
|
SerdeInfo: {
|
||||||
|
SerializationLibrary: storage.SerializationLibrary,
|
||||||
|
...(csv ? {Parameters: {'field.delim': delimiter}} : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return this.detail(databaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCatalogTable(databaseName: string, tableName: string): Promise<CloudResource> {
|
||||||
|
await this.glue.send(new DeleteTableCommand({DatabaseName: databaseName, Name: tableName}))
|
||||||
|
return this.detail(databaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async database(name: string): Promise<Database | null> {
|
||||||
|
try {
|
||||||
|
return (await this.glue.send(new GetDatabaseCommand({Name: name}))).Database ?? null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'EntityNotFoundException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detail(name: string): Promise<CloudResource> {
|
||||||
|
const detail = await this.get(name)
|
||||||
|
if (!detail) throw new NotFoundError(`Database ${name} not found`)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tables(databaseName: string): Promise<Table[]> {
|
||||||
|
const found: Table[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.glue.send(new GetTablesCommand({DatabaseName: databaseName, MaxResults: 100, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.TableList ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
return found.sort((a, b) => (a.Name ?? '').localeCompare(b.Name ?? ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(database: Database, detail: {tableCount: number; tables?: Table[]}): CloudResource {
|
||||||
|
const name = database.Name ?? ''
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'datacatalog',
|
||||||
|
type: 'glueDatabase',
|
||||||
|
region: null,
|
||||||
|
createdAt: database.CreateTime ? database.CreateTime.toISOString() : null,
|
||||||
|
metadata: {
|
||||||
|
description: database.Description || null,
|
||||||
|
locationUri: database.LocationUri || null,
|
||||||
|
catalogId: database.CatalogId ?? null,
|
||||||
|
tableCount: detail.tableCount,
|
||||||
|
...(detail.tables ? {tables: detail.tables.map(describeTable)} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the panel shows for a table: its files, how they are read, and its columns. */
|
||||||
|
export function describeTable(table: Table) {
|
||||||
|
const storage: StorageDescriptor = table.StorageDescriptor ?? {}
|
||||||
|
const format = tableFormat(table)
|
||||||
|
return {
|
||||||
|
name: table.Name ?? '',
|
||||||
|
description: table.Description || null,
|
||||||
|
format,
|
||||||
|
location: storage.Location ?? null,
|
||||||
|
delimiter: format === 'csv' ? storage.SerdeInfo?.Parameters?.['field.delim'] ?? ',' : null,
|
||||||
|
headerRow: table.Parameters?.['skip.header.line.count'] === '1',
|
||||||
|
columns: (storage.Columns ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
partitionKeys: (table.PartitionKeys ?? []).map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
tableType: table.TableType ?? null,
|
||||||
|
createdAt: table.CreateTime ? table.CreateTime.toISOString() : null,
|
||||||
|
updatedAt: table.UpdateTime ? table.UpdateTime.toISOString() : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same order the runtime's Athena engine checks a table's storage in. */
|
||||||
|
function tableFormat(table: Table): string {
|
||||||
|
const classification = table.Parameters?.classification?.toLowerCase()
|
||||||
|
if (classification === 'iceberg' || table.Parameters?.table_type?.toLowerCase() === 'iceberg') return 'iceberg'
|
||||||
|
const storage = table.StorageDescriptor
|
||||||
|
const hints = `${storage?.InputFormat ?? ''} ${storage?.SerdeInfo?.SerializationLibrary ?? ''}`.toLowerCase()
|
||||||
|
if (hints.includes('parquet')) return 'parquet'
|
||||||
|
if (hints.includes('json')) return 'json'
|
||||||
|
return classification || 'csv'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableColumns(value: unknown): Array<{name: string; type: string}> {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) throw new ValidationError('columns must list at least one column')
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return value.map((column, index) => {
|
||||||
|
const entry = typeof column === 'object' && column !== null ? column as Record<string, unknown> : {}
|
||||||
|
const name = catalogName(entry.name, `columns[${index}].name`)
|
||||||
|
if (seen.has(name)) throw new ValidationError(`column ${name} is listed twice`)
|
||||||
|
seen.add(name)
|
||||||
|
const type = text(entry.type).toLowerCase()
|
||||||
|
if (!(CATALOG_COLUMN_TYPES as readonly string[]).includes(type) && !DECIMAL_TYPE.test(type)) {
|
||||||
|
throw new ValidationError(`columns[${index}].type must be one of ${CATALOG_COLUMN_TYPES.join(', ')}`)
|
||||||
|
}
|
||||||
|
return {name, type}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogName(value: unknown, field: string): string {
|
||||||
|
const name = text(value)
|
||||||
|
if (!new RegExp(CATALOG_NAME_PATTERN).test(name)) throw new ValidationError(`${field} must be lowercase letters, numbers and underscores, starting with a letter or underscore`)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
CreateAccessPointCommand,
|
||||||
|
CreateFileSystemCommand,
|
||||||
|
CreateMountTargetCommand,
|
||||||
|
DeleteAccessPointCommand,
|
||||||
|
DeleteFileSystemCommand,
|
||||||
|
DeleteMountTargetCommand,
|
||||||
|
DescribeAccessPointsCommand,
|
||||||
|
DescribeFileSystemsCommand,
|
||||||
|
DescribeMountTargetsCommand,
|
||||||
|
type EFSClient,
|
||||||
|
} from '@aws-sdk/client-efs'
|
||||||
|
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||||
|
import {GetFunctionConfigurationCommand, type LambdaClient, ListFunctionsCommand, UpdateFunctionConfigurationCommand} from '@aws-sdk/client-lambda'
|
||||||
|
import {AwsFileSystemsAdapter} from './AwsFileSystemsAdapter'
|
||||||
|
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stub(handler: (command: object) => SendResult | undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command) ?? {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {client, sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessPointArn = 'arn:aws:elasticfilesystem:us-east-1:000000000000:access-point/fsap-1'
|
||||||
|
const fileSystem = {
|
||||||
|
FileSystemId: 'fs-1',
|
||||||
|
FileSystemArn: 'arn:aws:elasticfilesystem:us-east-1:000000000000:file-system/fs-1',
|
||||||
|
Name: 'shared',
|
||||||
|
LifeCycleState: 'available',
|
||||||
|
NumberOfMountTargets: 1,
|
||||||
|
PerformanceMode: 'generalPurpose',
|
||||||
|
ThroughputMode: 'bursting',
|
||||||
|
Encrypted: true,
|
||||||
|
CreationTime: new Date('2026-10-25T17:09:01Z'),
|
||||||
|
}
|
||||||
|
const mountTarget = {MountTargetId: 'fsmt-1', FileSystemId: 'fs-1', SubnetId: 'subnet-a', AvailabilityZoneName: 'us-east-1a', IpAddress: '10.0.0.10', VpcId: 'vpc-1', LifeCycleState: 'available'}
|
||||||
|
const accessPoint = {
|
||||||
|
AccessPointId: 'fsap-1', AccessPointArn: accessPointArn, FileSystemId: 'fs-1', Name: 'app',
|
||||||
|
PosixUser: {Uid: 1000, Gid: 1000}, RootDirectory: {Path: '/app', CreationInfo: {OwnerUid: 1000, OwnerGid: 1000, Permissions: '755'}}, LifeCycleState: 'available',
|
||||||
|
}
|
||||||
|
const writer = {FunctionName: 'writer', FileSystemConfigs: [{Arn: accessPointArn, LocalMountPath: '/mnt/share'}]}
|
||||||
|
|
||||||
|
function adapterWith({efs = () => undefined, lambda = () => undefined, functions = [writer]}: {
|
||||||
|
efs?: (command: object) => SendResult | undefined
|
||||||
|
lambda?: (command: object) => SendResult | undefined
|
||||||
|
functions?: object[]
|
||||||
|
} = {}) {
|
||||||
|
const efsStub = stub((command) => efs(command) ?? (
|
||||||
|
command instanceof DescribeFileSystemsCommand ? {FileSystems: [fileSystem]}
|
||||||
|
: command instanceof DescribeMountTargetsCommand ? {MountTargets: [mountTarget]}
|
||||||
|
: command instanceof DescribeAccessPointsCommand ? {AccessPoints: [accessPoint]}
|
||||||
|
: command instanceof CreateFileSystemCommand ? {FileSystemId: 'fs-1'}
|
||||||
|
: undefined))
|
||||||
|
const lambdaStub = stub((command) => lambda(command) ?? (command instanceof ListFunctionsCommand ? {Functions: functions} : undefined))
|
||||||
|
const ec2Stub = stub((command) => command instanceof DescribeSubnetsCommand
|
||||||
|
? {Subnets: [{SubnetId: 'subnet-b', AvailabilityZone: 'us-east-1b'}, {SubnetId: 'subnet-a', AvailabilityZone: 'us-east-1a'}, {SubnetId: 'subnet-a2', AvailabilityZone: 'us-east-1a'}]}
|
||||||
|
: command instanceof DescribeSecurityGroupsCommand ? {SecurityGroups: [{GroupId: 'sg-default'}]}
|
||||||
|
: undefined)
|
||||||
|
return {
|
||||||
|
efsSent: efsStub.sent,
|
||||||
|
lambdaSent: lambdaStub.sent,
|
||||||
|
adapter: new AwsFileSystemsAdapter(efsStub.client as unknown as EFSClient, lambdaStub.client as unknown as LambdaClient, ec2Stub.client as unknown as EC2Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsFileSystemsAdapter', () => {
|
||||||
|
test('lists file systems across pages and searches by name or id', async () => {
|
||||||
|
const {adapter} = adapterWith({
|
||||||
|
efs: (command) => command instanceof DescribeFileSystemsCommand
|
||||||
|
? command.input.Marker ? {FileSystems: [{...fileSystem, FileSystemId: 'fs-2', Name: undefined, Tags: [{Key: 'Name', Value: 'logs'}]}]} : {FileSystems: [fileSystem], NextMarker: 'm'}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const listed = await adapter.list()
|
||||||
|
expect(listed.map((fs) => [fs.id, fs.name, fs.status, fs.metadata.encrypted])).toEqual([['fs-1', 'shared', 'available', true], ['fs-2', 'logs', 'available', true]])
|
||||||
|
expect((await adapter.list({search: 'fs-2'})).map((fs) => fs.name)).toEqual(['logs'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes mount targets, access points and the functions that mount it', async () => {
|
||||||
|
const {adapter} = adapterWith({functions: [writer, {FunctionName: 'other'}]})
|
||||||
|
const detail = await adapter.get('fs-1')
|
||||||
|
|
||||||
|
expect(detail?.metadata.mountTargets).toEqual([{id: 'fsmt-1', subnetId: 'subnet-a', availabilityZone: 'us-east-1a', ipAddress: '10.0.0.10', vpcId: 'vpc-1', state: 'available'}])
|
||||||
|
expect(detail?.metadata.accessPoints).toEqual([{id: 'fsap-1', arn: accessPointArn, name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '755', state: 'available'}])
|
||||||
|
expect(detail?.metadata.functions).toEqual([{name: 'writer', accessPointId: 'fsap-1', mountPath: '/mnt/share'}])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a file system that does not exist', async () => {
|
||||||
|
const missing = Object.assign(new Error('not found'), {name: 'FileSystemNotFound'})
|
||||||
|
const {adapter} = adapterWith({efs: (command) => { if (command instanceof DescribeFileSystemsCommand) throw missing; return undefined }})
|
||||||
|
expect(await adapter.get('fs-nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an encrypted file system with one mount target per default zone', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'shared', performanceMode: 'generalPurpose', throughputMode: 'elastic', encrypted: 'true', mountTargets: 'default'}})
|
||||||
|
|
||||||
|
const create = efsSent.find((command) => command instanceof CreateFileSystemCommand) as CreateFileSystemCommand
|
||||||
|
expect(create.input).toMatchObject({PerformanceMode: 'generalPurpose', ThroughputMode: 'elastic', Encrypted: true, Tags: [{Key: 'Name', Value: 'shared'}]})
|
||||||
|
const mounts = efsSent.filter((command) => command instanceof CreateMountTargetCommand) as CreateMountTargetCommand[]
|
||||||
|
expect(mounts.map((command) => command.input.SubnetId)).toEqual(['subnet-a', 'subnet-b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can create a file system without mount targets, and refuses unknown modes', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.create({values: {mountTargets: 'none', encrypted: 'false'}})
|
||||||
|
expect(efsSent.some((command) => command instanceof CreateMountTargetCommand)).toBe(false)
|
||||||
|
expect((efsSent.find((command) => command instanceof CreateFileSystemCommand) as CreateFileSystemCommand).input.Encrypted).toBe(false)
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {throughputMode: 'provisioned'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes access points and mount targets before the file system', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith({functions: []})
|
||||||
|
await adapter.delete('fs-1')
|
||||||
|
|
||||||
|
const order = efsSent.filter((command) => command instanceof DeleteAccessPointCommand || command instanceof DeleteMountTargetCommand || command instanceof DeleteFileSystemCommand)
|
||||||
|
.map((command) => command.constructor.name)
|
||||||
|
expect(order).toEqual(['DeleteAccessPointCommand', 'DeleteMountTargetCommand', 'DeleteFileSystemCommand'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses to delete a file system or access point a function still mounts', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await expect(adapter.delete('fs-1')).rejects.toBeInstanceOf(ConflictError)
|
||||||
|
await expect(adapter.deleteAccessPoint('fs-1', 'fsap-1')).rejects.toBeInstanceOf(ConflictError)
|
||||||
|
expect(efsSent.some((command) => command instanceof DeleteFileSystemCommand || command instanceof DeleteAccessPointCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an access point whose root EFS makes for its POSIX user', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.createAccessPoint('fs-1', {name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '750'})
|
||||||
|
|
||||||
|
const create = efsSent.find((command) => command instanceof CreateAccessPointCommand) as CreateAccessPointCommand
|
||||||
|
expect(create.input).toMatchObject({
|
||||||
|
FileSystemId: 'fs-1',
|
||||||
|
PosixUser: {Uid: 1000, Gid: 1000},
|
||||||
|
RootDirectory: {Path: '/app', CreationInfo: {OwnerUid: 1000, OwnerGid: 1000, Permissions: '750'}},
|
||||||
|
Tags: [{Key: 'Name', Value: 'app'}],
|
||||||
|
})
|
||||||
|
await expect(adapter.createAccessPoint('fs-1', {path: 'app', uid: 1000, gid: 1000, permissions: '755'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createAccessPoint('fs-1', {path: '/app', uid: -1, gid: 1000, permissions: '755'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.createAccessPoint('fs-1', {path: '/app', uid: 1000, gid: 1000, permissions: '999'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds and removes mount targets that belong to the file system', async () => {
|
||||||
|
const {adapter, efsSent} = adapterWith()
|
||||||
|
await adapter.createMountTarget('fs-1', 'subnet-c')
|
||||||
|
expect((efsSent.find((command) => command instanceof CreateMountTargetCommand) as CreateMountTargetCommand).input).toEqual({FileSystemId: 'fs-1', SubnetId: 'subnet-c'})
|
||||||
|
|
||||||
|
await adapter.deleteMountTarget('fs-1', 'fsmt-1')
|
||||||
|
expect(efsSent.some((command) => command instanceof DeleteMountTargetCommand)).toBe(true)
|
||||||
|
await expect(adapter.deleteMountTarget('fs-1', 'fsmt-other')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
await expect(adapter.createMountTarget('fs-1', 'not-a-subnet')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaching puts a function without a VPC in the mount target subnets', async () => {
|
||||||
|
const {adapter, lambdaSent} = adapterWith({lambda: (command) => command instanceof GetFunctionConfigurationCommand ? {FunctionName: 'reader'} : undefined})
|
||||||
|
await adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/share'})
|
||||||
|
|
||||||
|
const update = lambdaSent.find((command) => command instanceof UpdateFunctionConfigurationCommand) as UpdateFunctionConfigurationCommand
|
||||||
|
expect(update.input).toEqual({
|
||||||
|
FunctionName: 'reader',
|
||||||
|
FileSystemConfigs: [{Arn: accessPointArn, LocalMountPath: '/mnt/share'}],
|
||||||
|
VpcConfig: {SubnetIds: ['subnet-a'], SecurityGroupIds: ['sg-default']},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaching keeps a function that is already in a VPC where it is', async () => {
|
||||||
|
const {adapter, lambdaSent} = adapterWith({lambda: (command) => command instanceof GetFunctionConfigurationCommand ? {FunctionName: 'reader', VpcConfig: {SubnetIds: ['subnet-z']}} : undefined})
|
||||||
|
await adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/share'})
|
||||||
|
|
||||||
|
const update = lambdaSent.find((command) => command instanceof UpdateFunctionConfigurationCommand) as UpdateFunctionConfigurationCommand
|
||||||
|
expect(update.input.VpcConfig).toBeUndefined()
|
||||||
|
await expect(adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/data'})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-x', functionName: 'reader', mountPath: '/mnt/share'})).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cannot attach before the file system has a mount target', async () => {
|
||||||
|
const {adapter} = adapterWith({
|
||||||
|
efs: (command) => command instanceof DescribeMountTargetsCommand ? {MountTargets: []} : undefined,
|
||||||
|
lambda: (command) => command instanceof GetFunctionConfigurationCommand ? {FunctionName: 'reader'} : undefined,
|
||||||
|
})
|
||||||
|
await expect(adapter.attachFileSystemToFunction('fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/share'})).rejects.toThrow(/Add a mount target first/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detaching clears the function file systems', async () => {
|
||||||
|
const {adapter, lambdaSent} = adapterWith()
|
||||||
|
await adapter.detachFileSystemFromFunction('fs-1', 'writer')
|
||||||
|
expect((lambdaSent.find((command) => command instanceof UpdateFunctionConfigurationCommand) as UpdateFunctionConfigurationCommand).input).toEqual({FunctionName: 'writer', FileSystemConfigs: []})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import {
|
||||||
|
type AccessPointDescription,
|
||||||
|
CreateAccessPointCommand,
|
||||||
|
CreateFileSystemCommand,
|
||||||
|
CreateMountTargetCommand,
|
||||||
|
DeleteAccessPointCommand,
|
||||||
|
DeleteFileSystemCommand,
|
||||||
|
DeleteMountTargetCommand,
|
||||||
|
DescribeAccessPointsCommand,
|
||||||
|
DescribeFileSystemsCommand,
|
||||||
|
DescribeMountTargetsCommand,
|
||||||
|
type EFSClient,
|
||||||
|
type FileSystemDescription,
|
||||||
|
type MountTargetDescription,
|
||||||
|
} from '@aws-sdk/client-efs'
|
||||||
|
import {DescribeSecurityGroupsCommand, DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||||
|
import {type FunctionConfiguration, GetFunctionConfigurationCommand, type LambdaClient, ListFunctionsCommand, UpdateFunctionConfigurationCommand} from '@aws-sdk/client-lambda'
|
||||||
|
import {ConflictError, NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {
|
||||||
|
ACCESS_POINT_PATH_PATTERN,
|
||||||
|
awsFileSystemsSchema,
|
||||||
|
FILE_SYSTEM_NAME_PATTERN,
|
||||||
|
FILE_SYSTEM_PERFORMANCE_MODES,
|
||||||
|
FILE_SYSTEM_THROUGHPUT_MODES,
|
||||||
|
LAMBDA_MOUNT_PATH_PATTERN,
|
||||||
|
} from '../cloud-spi/fileSystemsSchema'
|
||||||
|
import type {
|
||||||
|
AccessPointInput,
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** POSIX ids EFS accepts for an access point's user and directory owner. */
|
||||||
|
const MAX_POSIX_ID = 4_294_967_295
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Amazon EFS, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Mounts are real: a Lambda function given an access point gets a Docker
|
||||||
|
* volume at its mount path, so two functions on one access point share files.
|
||||||
|
* - Lambda refuses a file system without a VPC config, as AWS does, so
|
||||||
|
* attaching puts an un-networked function in its file system's subnets.
|
||||||
|
* - Metered size is always reported as 0 bytes, so the table does not show it.
|
||||||
|
* - DeleteFileSystem refuses while mount targets exist, so delete removes them
|
||||||
|
* (and the access points) first, like the AWS console.
|
||||||
|
*/
|
||||||
|
export class AwsFileSystemsAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'filesystems' as const
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly efs: EFSClient,
|
||||||
|
private readonly lambda: LambdaClient,
|
||||||
|
private readonly ec2: EC2Client,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsFileSystemsSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: FileSystemDescription[] = []
|
||||||
|
let marker: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.efs.send(new DescribeFileSystemsCommand({MaxItems: 100, ...(marker ? {Marker: marker} : {})}))
|
||||||
|
found.push(...(res.FileSystems ?? []))
|
||||||
|
marker = res.NextMarker
|
||||||
|
} while (marker)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return found.map((fileSystem) => toResource(fileSystem)).filter((fileSystem) => !search
|
||||||
|
|| fileSystem.name.toLowerCase().includes(search)
|
||||||
|
|| fileSystem.id.toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
const fileSystem = await this.describe(id)
|
||||||
|
if (!fileSystem) return null
|
||||||
|
const [mountTargets, accessPoints] = await Promise.all([this.mountTargets(id), this.accessPoints(id)])
|
||||||
|
const accessPointArns = new Set(accessPoints.map((accessPoint) => accessPoint.AccessPointArn))
|
||||||
|
const functions = (await this.functions()).filter((fn) => (fn.FileSystemConfigs ?? []).some((config) => accessPointArns.has(config.Arn)))
|
||||||
|
return toResource(fileSystem, {mountTargets, accessPoints, functions})
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (name && !new RegExp(FILE_SYSTEM_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 256 characters')
|
||||||
|
const performanceMode = oneOf(text(values.performanceMode) || 'generalPurpose', FILE_SYSTEM_PERFORMANCE_MODES, 'performanceMode')
|
||||||
|
const throughputMode = oneOf(text(values.throughputMode) || 'bursting', FILE_SYSTEM_THROUGHPUT_MODES, 'throughputMode')
|
||||||
|
|
||||||
|
const created = await this.efs.send(new CreateFileSystemCommand({
|
||||||
|
CreationToken: crypto.randomUUID(),
|
||||||
|
PerformanceMode: performanceMode,
|
||||||
|
ThroughputMode: throughputMode,
|
||||||
|
Encrypted: values.encrypted !== 'false',
|
||||||
|
...(name ? {Tags: [{Key: 'Name', Value: name}]} : {}),
|
||||||
|
}))
|
||||||
|
const fileSystemId = created.FileSystemId
|
||||||
|
if (!fileSystemId) throw new RuntimeError('EFS did not return the new file system id')
|
||||||
|
|
||||||
|
if (text(values.mountTargets) !== 'none') {
|
||||||
|
for (const subnetId of await this.defaultSubnets()) {
|
||||||
|
await this.efs.send(new CreateMountTargetCommand({FileSystemId: fileSystemId, SubnetId: subnetId}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await this.get(fileSystemId)
|
||||||
|
if (!detail) throw new RuntimeError(`EFS did not return the new file system ${fileSystemId}`)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const accessPoints = await this.accessPoints(id)
|
||||||
|
const accessPointArns = new Set(accessPoints.map((accessPoint) => accessPoint.AccessPointArn))
|
||||||
|
const users = (await this.functions()).filter((fn) => (fn.FileSystemConfigs ?? []).some((config) => accessPointArns.has(config.Arn)))
|
||||||
|
if (users.length > 0) {
|
||||||
|
throw new ConflictError(`Detach the file system from ${users.map((fn) => fn.FunctionName).join(', ')} before deleting it`)
|
||||||
|
}
|
||||||
|
for (const accessPoint of accessPoints) {
|
||||||
|
await this.efs.send(new DeleteAccessPointCommand({AccessPointId: accessPoint.AccessPointId}))
|
||||||
|
}
|
||||||
|
for (const mountTarget of await this.mountTargets(id)) {
|
||||||
|
await this.efs.send(new DeleteMountTargetCommand({MountTargetId: mountTarget.MountTargetId}))
|
||||||
|
}
|
||||||
|
await this.efs.send(new DeleteFileSystemCommand({FileSystemId: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMountTarget(fileSystemId: string, subnetId: string): Promise<CloudResource> {
|
||||||
|
const subnet = subnetId.trim()
|
||||||
|
if (!/^subnet-[a-zA-Z0-9-]+$/.test(subnet)) throw new ValidationError('subnetId must be a subnet ID such as subnet-0abc')
|
||||||
|
await this.requireFileSystem(fileSystemId)
|
||||||
|
await this.efs.send(new CreateMountTargetCommand({FileSystemId: fileSystemId, SubnetId: subnet}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMountTarget(fileSystemId: string, mountTargetId: string): Promise<CloudResource> {
|
||||||
|
const owned = (await this.mountTargets(fileSystemId)).some((mountTarget) => mountTarget.MountTargetId === mountTargetId)
|
||||||
|
if (!owned) throw new NotFoundError(`Mount target ${mountTargetId} is not part of ${fileSystemId}`)
|
||||||
|
await this.efs.send(new DeleteMountTargetCommand({MountTargetId: mountTargetId}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccessPoint(fileSystemId: string, input: AccessPointInput): Promise<CloudResource> {
|
||||||
|
const path = text(input.path)
|
||||||
|
if (!new RegExp(ACCESS_POINT_PATH_PATTERN).test(path)) throw new ValidationError('path must start with / and be up to 100 characters')
|
||||||
|
const uid = posixId(input.uid, 'uid')
|
||||||
|
const gid = posixId(input.gid, 'gid')
|
||||||
|
const permissions = text(input.permissions)
|
||||||
|
if (!/^[0-7]{3,4}$/.test(permissions)) throw new ValidationError('permissions must be octal, such as 755')
|
||||||
|
const name = text(input.name)
|
||||||
|
await this.requireFileSystem(fileSystemId)
|
||||||
|
|
||||||
|
await this.efs.send(new CreateAccessPointCommand({
|
||||||
|
ClientToken: crypto.randomUUID(),
|
||||||
|
FileSystemId: fileSystemId,
|
||||||
|
PosixUser: {Uid: uid, Gid: gid},
|
||||||
|
RootDirectory: {Path: path, CreationInfo: {OwnerUid: uid, OwnerGid: gid, Permissions: permissions}},
|
||||||
|
...(name ? {Tags: [{Key: 'Name', Value: name}]} : {}),
|
||||||
|
}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccessPoint(fileSystemId: string, accessPointId: string): Promise<CloudResource> {
|
||||||
|
const accessPoint = (await this.accessPoints(fileSystemId)).find((candidate) => candidate.AccessPointId === accessPointId)
|
||||||
|
if (!accessPoint) throw new NotFoundError(`Access point ${accessPointId} is not part of ${fileSystemId}`)
|
||||||
|
const users = (await this.functions()).filter((fn) => (fn.FileSystemConfigs ?? []).some((config) => config.Arn === accessPoint.AccessPointArn))
|
||||||
|
if (users.length > 0) {
|
||||||
|
throw new ConflictError(`Detach ${users.map((fn) => fn.FunctionName).join(', ')} from this access point before deleting it`)
|
||||||
|
}
|
||||||
|
await this.efs.send(new DeleteAccessPointCommand({AccessPointId: accessPointId}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async attachFileSystemToFunction(fileSystemId: string, input: FileSystemAttachmentInput): Promise<CloudResource> {
|
||||||
|
const mountPath = text(input.mountPath)
|
||||||
|
if (!new RegExp(LAMBDA_MOUNT_PATH_PATTERN).test(mountPath)) throw new ValidationError('mountPath must be under /mnt, such as /mnt/data')
|
||||||
|
const functionName = text(input.functionName)
|
||||||
|
if (!functionName) throw new ValidationError('functionName is required')
|
||||||
|
const accessPoint = (await this.accessPoints(fileSystemId)).find((candidate) => candidate.AccessPointId === input.accessPointId)
|
||||||
|
if (!accessPoint?.AccessPointArn) throw new NotFoundError(`Access point ${input.accessPointId} is not part of ${fileSystemId}`)
|
||||||
|
|
||||||
|
const fn = await this.lambda.send(new GetFunctionConfigurationCommand({FunctionName: functionName}))
|
||||||
|
const inVpc = (fn.VpcConfig?.SubnetIds ?? []).length > 0
|
||||||
|
const vpcConfig = inVpc ? undefined : await this.vpcConfigFor(fileSystemId)
|
||||||
|
|
||||||
|
await this.lambda.send(new UpdateFunctionConfigurationCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
FileSystemConfigs: [{Arn: accessPoint.AccessPointArn, LocalMountPath: mountPath}],
|
||||||
|
...(vpcConfig ? {VpcConfig: vpcConfig} : {}),
|
||||||
|
}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async detachFileSystemFromFunction(fileSystemId: string, functionName: string): Promise<CloudResource> {
|
||||||
|
await this.lambda.send(new UpdateFunctionConfigurationCommand({FunctionName: functionName, FileSystemConfigs: []}))
|
||||||
|
return this.detail(fileSystemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async describe(id: string): Promise<FileSystemDescription | null> {
|
||||||
|
try {
|
||||||
|
return (await this.efs.send(new DescribeFileSystemsCommand({FileSystemId: id}))).FileSystems?.[0] ?? null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'FileSystemNotFound') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireFileSystem(id: string): Promise<void> {
|
||||||
|
if (!await this.describe(id)) throw new NotFoundError(`File system ${id} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detail(id: string): Promise<CloudResource> {
|
||||||
|
const detail = await this.get(id)
|
||||||
|
if (!detail) throw new NotFoundError(`File system ${id} not found`)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mountTargets(fileSystemId: string): Promise<MountTargetDescription[]> {
|
||||||
|
return (await this.efs.send(new DescribeMountTargetsCommand({FileSystemId: fileSystemId}))).MountTargets ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private async accessPoints(fileSystemId: string): Promise<AccessPointDescription[]> {
|
||||||
|
const found: AccessPointDescription[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.efs.send(new DescribeAccessPointsCommand({FileSystemId: fileSystemId, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.AccessPoints ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private async functions(): Promise<FunctionConfiguration[]> {
|
||||||
|
const found: FunctionConfiguration[] = []
|
||||||
|
let marker: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.lambda.send(new ListFunctionsCommand({MaxItems: 50, ...(marker ? {Marker: marker} : {})}))
|
||||||
|
found.push(...(res.Functions ?? []))
|
||||||
|
marker = res.NextMarker
|
||||||
|
} while (marker)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a function in the subnets its file system is reachable from, with the VPC's default security group. */
|
||||||
|
private async vpcConfigFor(fileSystemId: string): Promise<{SubnetIds: string[]; SecurityGroupIds: string[]}> {
|
||||||
|
const mountTargets = await this.mountTargets(fileSystemId)
|
||||||
|
const subnetIds = mountTargets.map((mountTarget) => mountTarget.SubnetId).filter((id): id is string => Boolean(id))
|
||||||
|
const vpcId = mountTargets.find((mountTarget) => mountTarget.VpcId)?.VpcId
|
||||||
|
if (subnetIds.length === 0 || !vpcId) throw new ValidationError('Add a mount target first: Lambda reaches a file system through one in its subnet')
|
||||||
|
const groups = await this.ec2.send(new DescribeSecurityGroupsCommand({Filters: [{Name: 'vpc-id', Values: [vpcId]}, {Name: 'group-name', Values: ['default']}]}))
|
||||||
|
const securityGroupId = groups.SecurityGroups?.[0]?.GroupId
|
||||||
|
if (!securityGroupId) throw new ValidationError(`The VPC ${vpcId} has no default security group to put the function in`)
|
||||||
|
return {SubnetIds: [...new Set(subnetIds)].sort(), SecurityGroupIds: [securityGroupId]}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async defaultSubnets(): Promise<string[]> {
|
||||||
|
const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]}))
|
||||||
|
const byZone = new Map<string, string>()
|
||||||
|
for (const subnet of res.Subnets ?? []) {
|
||||||
|
if (subnet.SubnetId && !byZone.has(subnet.AvailabilityZone ?? subnet.SubnetId)) byZone.set(subnet.AvailabilityZone ?? subnet.SubnetId, subnet.SubnetId)
|
||||||
|
}
|
||||||
|
if (byZone.size === 0) throw new ValidationError('There is no default VPC to put mount targets in; create the file system without them and add one later')
|
||||||
|
return [...byZone.values()].sort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSystemDetail = {
|
||||||
|
mountTargets: MountTargetDescription[]
|
||||||
|
accessPoints: AccessPointDescription[]
|
||||||
|
functions: FunctionConfiguration[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(fileSystem: FileSystemDescription, detail?: FileSystemDetail): CloudResource {
|
||||||
|
const id = fileSystem.FileSystemId ?? ''
|
||||||
|
const name = fileSystem.Name || tag(fileSystem.Tags, 'Name') || id
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'filesystems',
|
||||||
|
type: 'fileSystem',
|
||||||
|
region: fileSystem.FileSystemArn?.split(':')[3] || null,
|
||||||
|
createdAt: fileSystem.CreationTime ? fileSystem.CreationTime.toISOString() : null,
|
||||||
|
status: fileSystem.LifeCycleState ?? null,
|
||||||
|
metadata: {
|
||||||
|
fileSystemId: id,
|
||||||
|
arn: fileSystem.FileSystemArn ?? null,
|
||||||
|
numberOfMountTargets: fileSystem.NumberOfMountTargets ?? 0,
|
||||||
|
performanceMode: fileSystem.PerformanceMode ?? null,
|
||||||
|
throughputMode: fileSystem.ThroughputMode ?? null,
|
||||||
|
encrypted: fileSystem.Encrypted === true,
|
||||||
|
...(detail ? {
|
||||||
|
mountTargets: detail.mountTargets.map((mountTarget) => ({
|
||||||
|
id: mountTarget.MountTargetId ?? '',
|
||||||
|
subnetId: mountTarget.SubnetId ?? null,
|
||||||
|
availabilityZone: mountTarget.AvailabilityZoneName ?? null,
|
||||||
|
ipAddress: mountTarget.IpAddress ?? null,
|
||||||
|
vpcId: mountTarget.VpcId ?? null,
|
||||||
|
state: mountTarget.LifeCycleState ?? null,
|
||||||
|
})),
|
||||||
|
accessPoints: detail.accessPoints.map((accessPoint) => ({
|
||||||
|
id: accessPoint.AccessPointId ?? '',
|
||||||
|
arn: accessPoint.AccessPointArn ?? null,
|
||||||
|
name: accessPoint.Name || tag(accessPoint.Tags, 'Name') || null,
|
||||||
|
path: accessPoint.RootDirectory?.Path ?? '/',
|
||||||
|
uid: accessPoint.PosixUser?.Uid ?? null,
|
||||||
|
gid: accessPoint.PosixUser?.Gid ?? null,
|
||||||
|
permissions: accessPoint.RootDirectory?.CreationInfo?.Permissions ?? null,
|
||||||
|
state: accessPoint.LifeCycleState ?? null,
|
||||||
|
})),
|
||||||
|
functions: detail.functions.map((fn) => {
|
||||||
|
const config = fn.FileSystemConfigs?.[0]
|
||||||
|
return {
|
||||||
|
name: fn.FunctionName ?? '',
|
||||||
|
accessPointId: config?.Arn?.split('/').pop() ?? null,
|
||||||
|
mountPath: config?.LocalMountPath ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tag(tags: Array<{Key?: string; Value?: string}> | undefined, key: string): string | undefined {
|
||||||
|
return tags?.find((candidate) => candidate.Key === key)?.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixId(value: unknown, field: string): number {
|
||||||
|
const id = typeof value === 'number' ? value : Number(text(value))
|
||||||
|
if (!Number.isInteger(id) || id < 0 || id > MAX_POSIX_ID) throw new ValidationError(`${field} must be a whole number from 0 to ${MAX_POSIX_ID}`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends string>(value: string, allowed: readonly T[], field: string): T {
|
||||||
|
if (!(allowed as readonly string[]).includes(value)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||||
|
return value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import {afterEach, describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
CreateApiCommand,
|
||||||
|
CreateAuthorizerCommand,
|
||||||
|
CreateDeploymentCommand,
|
||||||
|
CreateIntegrationCommand,
|
||||||
|
CreateRouteCommand,
|
||||||
|
CreateRouteResponseCommand,
|
||||||
|
CreateStageCommand,
|
||||||
|
DeleteApiCommand,
|
||||||
|
DeleteCorsConfigurationCommand,
|
||||||
|
DeleteIntegrationCommand,
|
||||||
|
DeleteRouteCommand,
|
||||||
|
GetApiCommand,
|
||||||
|
GetApisCommand,
|
||||||
|
GetAuthorizersCommand,
|
||||||
|
GetIntegrationsCommand,
|
||||||
|
GetRoutesCommand,
|
||||||
|
GetStageCommand,
|
||||||
|
GetStagesCommand,
|
||||||
|
UpdateApiCommand,
|
||||||
|
UpdateRouteCommand,
|
||||||
|
type ApiGatewayV2Client,
|
||||||
|
} from '@aws-sdk/client-apigatewayv2'
|
||||||
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
|
import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations'
|
||||||
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
const CREATED = new Date('2026-10-25T10:00:00Z')
|
||||||
|
const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED}
|
||||||
|
const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED}
|
||||||
|
const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes'
|
||||||
|
|
||||||
|
interface Runtime {
|
||||||
|
apis?: Record<string, unknown>
|
||||||
|
routes?: unknown[]
|
||||||
|
integrations?: unknown[]
|
||||||
|
stages?: unknown[]
|
||||||
|
authorizers?: unknown[]
|
||||||
|
reply?: (command: object) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubRuntime(runtime: Runtime = {}) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const lambdaSent: object[] = []
|
||||||
|
const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi}
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
const custom = runtime.reply?.(command)
|
||||||
|
if (custom !== undefined) return custom
|
||||||
|
if (command instanceof GetApisCommand) return {Items: Object.values(apis)}
|
||||||
|
if (command instanceof GetApiCommand) {
|
||||||
|
const api = apis[command.input.ApiId!]
|
||||||
|
if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []}
|
||||||
|
if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []}
|
||||||
|
if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []}
|
||||||
|
if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []}
|
||||||
|
if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input}
|
||||||
|
if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input}
|
||||||
|
if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input}
|
||||||
|
if (command instanceof CreateStageCommand) return {...command.input}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
} as unknown as ApiGatewayV2Client
|
||||||
|
const lambda = {
|
||||||
|
async send(command: object) {
|
||||||
|
lambdaSent.push(command)
|
||||||
|
if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
} as unknown as LambdaClient
|
||||||
|
const discoveries: string[] = []
|
||||||
|
const fetchJson = async (url: string) => {
|
||||||
|
discoveries.push(url)
|
||||||
|
return {issuer: 'http://floci:4566/us-east-1_pool1'}
|
||||||
|
}
|
||||||
|
const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson)
|
||||||
|
const of = <T extends object>(type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[]
|
||||||
|
const lambdaOf = <T extends object>(type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[]
|
||||||
|
return {operations, sent, of, lambdaOf, discoveries}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsHttpApiOperations APIs', () => {
|
||||||
|
test('lists HTTP and WebSocket APIs with their protocol', async () => {
|
||||||
|
const {operations} = stubRuntime()
|
||||||
|
|
||||||
|
const apis = await operations.list()
|
||||||
|
expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([
|
||||||
|
['h1', 'http-api', 'HTTP', 'http'],
|
||||||
|
['w1', 'websocket-api', 'WEBSOCKET', 'websocket'],
|
||||||
|
])
|
||||||
|
expect(await operations.get('rest-id')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('quick-creates an HTTP API that sends every request to a Lambda', async () => {
|
||||||
|
const {operations, of, lambdaOf} = stubRuntime()
|
||||||
|
|
||||||
|
await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP')
|
||||||
|
|
||||||
|
expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn})
|
||||||
|
expect(of(CreateStageCommand)).toHaveLength(0)
|
||||||
|
expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({
|
||||||
|
FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gives an HTTP API without a target an auto-deployed $default stage', async () => {
|
||||||
|
const {operations, of} = stubRuntime()
|
||||||
|
|
||||||
|
await operations.create({name: 'empty'}, 'HTTP')
|
||||||
|
expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a WebSocket API with its route selection expression and a stage', async () => {
|
||||||
|
const {operations, of} = stubRuntime()
|
||||||
|
|
||||||
|
await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET')
|
||||||
|
expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'})
|
||||||
|
expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true})
|
||||||
|
await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes an API and the Lambda permissions it granted', async () => {
|
||||||
|
const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]})
|
||||||
|
|
||||||
|
await operations.delete('h1')
|
||||||
|
expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'})
|
||||||
|
expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AwsHttpApiOperations routes', () => {
|
||||||
|
test('describes routes with their integration, stages, authorizers and CORS', async () => {
|
||||||
|
const {operations} = stubRuntime({
|
||||||
|
apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}},
|
||||||
|
routes: [
|
||||||
|
{RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
||||||
|
{RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'},
|
||||||
|
],
|
||||||
|
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}],
|
||||||
|
stages: [{StageName: '$default', AutoDeploy: true}],
|
||||||
|
authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const overview = await operations.describe('h1')
|
||||||
|
expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([
|
||||||
|
['GET /notes', 'notes', null],
|
||||||
|
['POST /notes', 'notes', 'a1'],
|
||||||
|
])
|
||||||
|
expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}])
|
||||||
|
expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']})
|
||||||
|
expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds a Lambda route, reusing the integration the function already has', async () => {
|
||||||
|
const {operations, of, lambdaOf} = stubRuntime({
|
||||||
|
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'})
|
||||||
|
expect(of(CreateIntegrationCommand)).toHaveLength(0)
|
||||||
|
expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'})
|
||||||
|
expect(lambdaOf(AddPermissionCommand)).toHaveLength(1)
|
||||||
|
expect(route.integration?.functionName).toBe('notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds an HTTP proxy route with a payload 1.0 integration', async () => {
|
||||||
|
const {operations, of} = stubRuntime()
|
||||||
|
|
||||||
|
await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'})
|
||||||
|
expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => {
|
||||||
|
const {operations, of} = stubRuntime()
|
||||||
|
|
||||||
|
await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true})
|
||||||
|
expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`)
|
||||||
|
expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default')
|
||||||
|
expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checks a route before creating anything', async () => {
|
||||||
|
const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]})
|
||||||
|
const before = sent.length
|
||||||
|
|
||||||
|
await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/)
|
||||||
|
await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/)
|
||||||
|
await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError)
|
||||||
|
await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/)
|
||||||
|
await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/)
|
||||||
|
expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting a route removes the integration and permission only it used', async () => {
|
||||||
|
const integrations = [
|
||||||
|
{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn},
|
||||||
|
{IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'},
|
||||||
|
]
|
||||||
|
const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations})
|
||||||
|
await shared.operations.deleteRoute('h1', 'r1')
|
||||||
|
expect(shared.of(DeleteRouteCommand)).toHaveLength(1)
|
||||||
|
expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0)
|
||||||
|
|
||||||
|
const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations})
|
||||||
|
await last.operations.deleteRoute('h1', 'r1')
|
||||||
|
expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'})
|
||||||
|
expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('protects a route with an authorizer and removes it again', async () => {
|
||||||
|
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined})
|
||||||
|
|
||||||
|
expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1')
|
||||||
|
expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull()
|
||||||
|
expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([
|
||||||
|
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
||||||
|
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AwsHttpApiOperations stages, authorizers and CORS', () => {
|
||||||
|
test('a stage without auto-deploy is deployed once when created', async () => {
|
||||||
|
const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined})
|
||||||
|
|
||||||
|
const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false})
|
||||||
|
expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'})
|
||||||
|
expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'})
|
||||||
|
await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fills a JWT authorizer from a Cognito pool discovery document', async () => {
|
||||||
|
const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined})
|
||||||
|
|
||||||
|
const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'})
|
||||||
|
expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration')
|
||||||
|
expect(of(CreateAuthorizerCommand)[0].input).toEqual({
|
||||||
|
ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'],
|
||||||
|
JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']},
|
||||||
|
})
|
||||||
|
expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1')
|
||||||
|
await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/)
|
||||||
|
await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/)
|
||||||
|
await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('will not delete an authorizer that still protects a route', async () => {
|
||||||
|
const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]})
|
||||||
|
|
||||||
|
await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sets and removes the CORS configuration', async () => {
|
||||||
|
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined})
|
||||||
|
const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true}
|
||||||
|
|
||||||
|
expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']})
|
||||||
|
expect(await operations.updateCors('h1', null)).toBeNull()
|
||||||
|
expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1)
|
||||||
|
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/)
|
||||||
|
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/)
|
||||||
|
await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AwsHttpApiOperations WebSocket test', () => {
|
||||||
|
const endpoint = process.env.FLOCI_ENDPOINT
|
||||||
|
afterEach(() => {
|
||||||
|
if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||||
|
else process.env.FLOCI_ENDPOINT = endpoint
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connects to the stage, sends the message and collects the replies', async () => {
|
||||||
|
const paths: string[] = []
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request, upgrade) {
|
||||||
|
paths.push(new URL(request.url).pathname + new URL(request.url).search)
|
||||||
|
return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400})
|
||||||
|
},
|
||||||
|
websocket: {message(socket, message) { socket.send(`echo ${message}`) }},
|
||||||
|
})
|
||||||
|
process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}`
|
||||||
|
try {
|
||||||
|
const {operations} = stubRuntime()
|
||||||
|
const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'})
|
||||||
|
expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null})
|
||||||
|
expect(paths).toEqual(['/ws/w1/dev?token=abc'])
|
||||||
|
} finally {
|
||||||
|
server.stop(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports a refused connection', async () => {
|
||||||
|
process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1'
|
||||||
|
const {operations} = stubRuntime()
|
||||||
|
|
||||||
|
const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000)
|
||||||
|
expect(result.connected).toBe(false)
|
||||||
|
expect(result.error).toBeTruthy()
|
||||||
|
await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('route keys', () => {
|
||||||
|
test('HTTP route keys are $default or a method and a path', () => {
|
||||||
|
expect(httpRouteKey('$default')).toBe('$default')
|
||||||
|
expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}')
|
||||||
|
expect(httpRouteKey('GET /')).toBe('GET /')
|
||||||
|
for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) {
|
||||||
|
expect(() => httpRouteKey(bad), bad).toThrow(ValidationError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('WebSocket route keys are the system routes or an action name', () => {
|
||||||
|
expect(webSocketRouteKey('$connect')).toBe('$connect')
|
||||||
|
expect(webSocketRouteKey('sendMessage')).toBe('sendMessage')
|
||||||
|
expect(() => webSocketRouteKey('$other')).toThrow(ValidationError)
|
||||||
|
expect(() => webSocketRouteKey('send message')).toThrow(ValidationError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
import {
|
||||||
|
CreateApiCommand,
|
||||||
|
CreateAuthorizerCommand,
|
||||||
|
CreateDeploymentCommand,
|
||||||
|
CreateIntegrationCommand,
|
||||||
|
CreateRouteCommand,
|
||||||
|
CreateRouteResponseCommand,
|
||||||
|
CreateStageCommand,
|
||||||
|
DeleteApiCommand,
|
||||||
|
DeleteAuthorizerCommand,
|
||||||
|
DeleteCorsConfigurationCommand,
|
||||||
|
DeleteIntegrationCommand,
|
||||||
|
DeleteRouteCommand,
|
||||||
|
DeleteStageCommand,
|
||||||
|
GetApiCommand,
|
||||||
|
GetApisCommand,
|
||||||
|
GetAuthorizersCommand,
|
||||||
|
GetIntegrationsCommand,
|
||||||
|
GetRoutesCommand,
|
||||||
|
GetStageCommand,
|
||||||
|
GetStagesCommand,
|
||||||
|
UpdateApiCommand,
|
||||||
|
UpdateRouteCommand,
|
||||||
|
type Api,
|
||||||
|
type ApiGatewayV2Client,
|
||||||
|
type Authorizer,
|
||||||
|
type Cors,
|
||||||
|
type Integration,
|
||||||
|
type Route,
|
||||||
|
type Stage,
|
||||||
|
} from '@aws-sdk/client-apigatewayv2'
|
||||||
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {awsEndpoint} from '../service/runtimeProbe'
|
||||||
|
import type {
|
||||||
|
CloudResource,
|
||||||
|
HttpApiAuthorizerInput,
|
||||||
|
HttpApiAuthorizerOverview,
|
||||||
|
HttpApiCors,
|
||||||
|
HttpApiIntegrationOverview,
|
||||||
|
HttpApiOverview,
|
||||||
|
HttpApiProtocol,
|
||||||
|
HttpApiRouteInput,
|
||||||
|
HttpApiRouteOverview,
|
||||||
|
HttpApiStageInput,
|
||||||
|
HttpApiStageOverview,
|
||||||
|
HttpApiWebSocketTestInput,
|
||||||
|
HttpApiWebSocketTestResult,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY']
|
||||||
|
export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*']
|
||||||
|
export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action'
|
||||||
|
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own
|
||||||
|
* routes, integrations, stages and JWT authorizers, served by the same API Gateway page.
|
||||||
|
*/
|
||||||
|
export class AwsHttpApiOperations {
|
||||||
|
constructor(
|
||||||
|
private readonly client: ApiGatewayV2Client,
|
||||||
|
private readonly lambda: LambdaClient,
|
||||||
|
private readonly accountId: string,
|
||||||
|
private readonly region: string,
|
||||||
|
private readonly fetchJson: (url: string) => Promise<unknown> = defaultFetchJson,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<CloudResource[]> {
|
||||||
|
const apis: Api[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const page = await this.client.send(new GetApisCommand({NextToken: next}))
|
||||||
|
apis.push(...(page.Items ?? []))
|
||||||
|
next = page.NextToken
|
||||||
|
} while (next)
|
||||||
|
return apis.map(toResource)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */
|
||||||
|
async get(apiId: string): Promise<CloudResource | null> {
|
||||||
|
const api = await this.findApi(apiId)
|
||||||
|
return api ? toResource(api) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(values: Record<string, unknown>, protocol: HttpApiProtocol): Promise<CloudResource> {
|
||||||
|
const name = stringValue(values.name)
|
||||||
|
if (!name) throw new ValidationError('name is required')
|
||||||
|
const description = stringValue(values.description) || undefined
|
||||||
|
|
||||||
|
if (protocol === 'WEBSOCKET') {
|
||||||
|
const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION
|
||||||
|
if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) {
|
||||||
|
throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action')
|
||||||
|
}
|
||||||
|
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression}))
|
||||||
|
const stageName = stageNameValue(stringValue(values.stageName) || 'dev')
|
||||||
|
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true}))
|
||||||
|
return toResource(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage.
|
||||||
|
const functionName = stringValue(values.functionName)
|
||||||
|
if (functionName) {
|
||||||
|
const functionArn = await this.functionArn(functionName)
|
||||||
|
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn}))
|
||||||
|
await this.allowInvoke(api.ApiId!, functionName)
|
||||||
|
return toResource(api)
|
||||||
|
}
|
||||||
|
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'}))
|
||||||
|
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true}))
|
||||||
|
return toResource(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(apiId: string): Promise<void> {
|
||||||
|
const {integrations} = await this.describeParts(apiId)
|
||||||
|
await this.client.send(new DeleteApiCommand({ApiId: apiId}))
|
||||||
|
for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) {
|
||||||
|
await this.removeInvokePermission(apiId, functionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async describe(apiId: string): Promise<HttpApiOverview> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
const {routes, integrations, stages, authorizers} = await this.describeParts(apiId)
|
||||||
|
const protocol = protocolOf(api)
|
||||||
|
const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration]))
|
||||||
|
return {
|
||||||
|
apiId,
|
||||||
|
protocol,
|
||||||
|
apiEndpoint: api.ApiEndpoint ?? null,
|
||||||
|
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null,
|
||||||
|
routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes),
|
||||||
|
stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)),
|
||||||
|
authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)),
|
||||||
|
cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
const protocol = protocolOf(api)
|
||||||
|
const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey)
|
||||||
|
const functionName = stringValue(input.functionName)
|
||||||
|
const httpUrl = stringValue(input.httpUrl)
|
||||||
|
if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL')
|
||||||
|
if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only')
|
||||||
|
if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL')
|
||||||
|
|
||||||
|
const {routes, integrations} = await this.describeParts(apiId)
|
||||||
|
if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`)
|
||||||
|
const authorizerId = stringValue(input.authorizerId)
|
||||||
|
if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||||
|
|
||||||
|
let integration: Partial<Integration> | undefined
|
||||||
|
if (functionName) {
|
||||||
|
const functionArn = await this.functionArn(functionName)
|
||||||
|
const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn)
|
||||||
|
integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri)
|
||||||
|
?? await this.client.send(new CreateIntegrationCommand({
|
||||||
|
ApiId: apiId,
|
||||||
|
IntegrationType: 'AWS_PROXY',
|
||||||
|
IntegrationUri: uri,
|
||||||
|
IntegrationMethod: 'POST',
|
||||||
|
PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined,
|
||||||
|
}))
|
||||||
|
await this.allowInvoke(apiId, functionName)
|
||||||
|
} else {
|
||||||
|
const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0]
|
||||||
|
integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method)
|
||||||
|
?? await this.client.send(new CreateIntegrationCommand({
|
||||||
|
ApiId: apiId,
|
||||||
|
IntegrationType: 'HTTP_PROXY',
|
||||||
|
IntegrationUri: httpUrl,
|
||||||
|
IntegrationMethod: method,
|
||||||
|
PayloadFormatVersion: '1.0',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await this.client.send(new CreateRouteCommand({
|
||||||
|
ApiId: apiId,
|
||||||
|
RouteKey: routeKey,
|
||||||
|
Target: `integrations/${integration.IntegrationId}`,
|
||||||
|
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||||
|
AuthorizerId: authorizerId || undefined,
|
||||||
|
RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined,
|
||||||
|
}))
|
||||||
|
// A WebSocket route sends its integration's response back only once it has a route response.
|
||||||
|
if (protocol === 'WEBSOCKET' && input.returnsResponse) {
|
||||||
|
await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'}))
|
||||||
|
}
|
||||||
|
return toRouteOverview(route, new Map([[integration.IntegrationId, integration]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRoute(apiId: string, routeId: string): Promise<void> {
|
||||||
|
const {routes, integrations} = await this.describeParts(apiId)
|
||||||
|
const route = routes.find((candidate) => candidate.RouteId === routeId)
|
||||||
|
if (!route) throw new NotFoundError(`Route ${routeId} not found`)
|
||||||
|
await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId}))
|
||||||
|
|
||||||
|
// Clean up what only this route used: its integration, then the Lambda's permission for this API.
|
||||||
|
const integrationId = targetIntegrationId(route.Target)
|
||||||
|
const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId)
|
||||||
|
const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId)
|
||||||
|
if (!integration || stillUsed) return
|
||||||
|
await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId}))
|
||||||
|
const functionName = integrationFunctionName(integration)
|
||||||
|
const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName)
|
||||||
|
if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||||
|
const route = await this.client.send(new UpdateRouteCommand({
|
||||||
|
ApiId: apiId,
|
||||||
|
RouteId: routeId,
|
||||||
|
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||||
|
AuthorizerId: authorizerId ?? undefined,
|
||||||
|
}))
|
||||||
|
const {integrations} = await this.describeParts(apiId)
|
||||||
|
return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration])))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)}))
|
||||||
|
// A stage without auto-deploy serves nothing until its first deployment.
|
||||||
|
if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!)
|
||||||
|
return toStageOverview(apiId, protocolOf(api), stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStage(apiId: string, stageName: string): Promise<void> {
|
||||||
|
await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async deploy(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
const name = stageNameValue(stageName)
|
||||||
|
await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'}))
|
||||||
|
const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name}))
|
||||||
|
return toStageOverview(apiId, protocolOf(api), stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers')
|
||||||
|
const name = stringValue(input.name)
|
||||||
|
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores')
|
||||||
|
|
||||||
|
let issuer = stringValue(input.issuer)
|
||||||
|
let audience = (input.audience ?? []).map(stringValue).filter(Boolean)
|
||||||
|
const userPoolId = stringValue(input.userPoolId)
|
||||||
|
if (userPoolId) {
|
||||||
|
const clientId = stringValue(input.userPoolClientId)
|
||||||
|
if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts')
|
||||||
|
issuer = await this.userPoolIssuer(userPoolId)
|
||||||
|
audience = [clientId]
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc')
|
||||||
|
if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id')
|
||||||
|
|
||||||
|
const authorizer = await this.client.send(new CreateAuthorizerCommand({
|
||||||
|
ApiId: apiId,
|
||||||
|
Name: name,
|
||||||
|
AuthorizerType: 'JWT',
|
||||||
|
IdentitySource: ['$request.header.Authorization'],
|
||||||
|
JwtConfiguration: {Issuer: issuer, Audience: audience},
|
||||||
|
}))
|
||||||
|
return toAuthorizerOverview(authorizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||||
|
const {routes} = await this.describeParts(apiId)
|
||||||
|
const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey)
|
||||||
|
if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`)
|
||||||
|
await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only')
|
||||||
|
if (!cors) {
|
||||||
|
await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId}))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const configuration = toCors(cors)
|
||||||
|
const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration}))
|
||||||
|
return fromCors(updated.CorsConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a real connection to the deployed stage, sends one message and collects the replies. */
|
||||||
|
async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise<HttpApiWebSocketTestResult> {
|
||||||
|
const api = await this.requireApi(apiId)
|
||||||
|
if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API')
|
||||||
|
const stage = stageNameValue(input.stage)
|
||||||
|
const query = stringValue(input.query).replace(/^\?/, '')
|
||||||
|
const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}`
|
||||||
|
return exchangeWebSocketMessage(url, input.message, timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async describeParts(apiId: string) {
|
||||||
|
const [routes, integrations, stages, authorizers] = await Promise.all([
|
||||||
|
this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))),
|
||||||
|
this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))),
|
||||||
|
this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))),
|
||||||
|
this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))),
|
||||||
|
])
|
||||||
|
return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pages<T>(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise<T[]> {
|
||||||
|
const items: T[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const page = await fetchPage(next)
|
||||||
|
items.push(...(page.Items ?? []))
|
||||||
|
next = page.NextToken
|
||||||
|
} while (next)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findApi(apiId: string): Promise<Partial<Api> | null> {
|
||||||
|
try {
|
||||||
|
return await this.client.send(new GetApiCommand({ApiId: apiId}))
|
||||||
|
} catch (error) {
|
||||||
|
if (hasHttpStatus(error, 404)) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireApi(apiId: string): Promise<Partial<Api>> {
|
||||||
|
const api = await this.findApi(apiId)
|
||||||
|
if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
private async functionArn(functionName: string): Promise<string> {
|
||||||
|
const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
|
||||||
|
const arn = result.Configuration?.FunctionArn
|
||||||
|
if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
|
||||||
|
return arn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */
|
||||||
|
private async allowInvoke(apiId: string, functionName: string) {
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new AddPermissionCommand({
|
||||||
|
FunctionName: functionName,
|
||||||
|
StatementId: invokeStatementId(apiId),
|
||||||
|
Action: 'lambda:InvokeFunction',
|
||||||
|
Principal: 'apigateway.amazonaws.com',
|
||||||
|
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 409)) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeInvokePermission(apiId: string, functionName: string) {
|
||||||
|
try {
|
||||||
|
await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)}))
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasHttpStatus(error, 404)) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */
|
||||||
|
private async userPoolIssuer(userPoolId: string): Promise<string> {
|
||||||
|
if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id')
|
||||||
|
const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`)
|
||||||
|
const issuer = (discovery as {issuer?: unknown} | null)?.issuer
|
||||||
|
if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`)
|
||||||
|
return issuer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultFetchJson(url: string): Promise<unknown> {
|
||||||
|
const response = await globalThis.fetch(url)
|
||||||
|
if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise<HttpApiWebSocketTestResult> {
|
||||||
|
const startedAt = performance.now()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const messages: string[] = []
|
||||||
|
let connected = false
|
||||||
|
let settled = false
|
||||||
|
let socket: WebSocket
|
||||||
|
const finish = (closeCode: number | null, error: string | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000)
|
||||||
|
resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)})
|
||||||
|
}
|
||||||
|
// After the first reply, wait briefly for any more before hanging up.
|
||||||
|
let quiet: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs)
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(url)
|
||||||
|
} catch (error) {
|
||||||
|
finish(null, error instanceof Error ? error.message : 'Could not connect')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected = true
|
||||||
|
if (message) socket.send(message)
|
||||||
|
else finish(null, null)
|
||||||
|
}
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
messages.push(typeof event.data === 'string' ? event.data : String(event.data))
|
||||||
|
clearTimeout(quiet)
|
||||||
|
quiet = setTimeout(() => finish(null, null), 500)
|
||||||
|
}
|
||||||
|
socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection')
|
||||||
|
socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(api: Partial<Api>): CloudResource {
|
||||||
|
const protocol = protocolOf(api)
|
||||||
|
return {
|
||||||
|
id: api.ApiId ?? '',
|
||||||
|
name: api.Name ?? '',
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'apigateway',
|
||||||
|
type: protocol === 'HTTP' ? 'http-api' : 'websocket-api',
|
||||||
|
region: null,
|
||||||
|
createdAt: api.CreatedDate?.toISOString() ?? null,
|
||||||
|
metadata: {
|
||||||
|
provider: 'aws',
|
||||||
|
apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket',
|
||||||
|
protocol,
|
||||||
|
description: api.Description,
|
||||||
|
apiEndpoint: api.ApiEndpoint,
|
||||||
|
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function protocolOf(api: Partial<Api>): HttpApiProtocol {
|
||||||
|
return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRouteOverview(route: Partial<Route>, integrations: Map<string | undefined, Partial<Integration>>): HttpApiRouteOverview {
|
||||||
|
const integration = integrations.get(targetIntegrationId(route.Target))
|
||||||
|
return {
|
||||||
|
routeId: route.RouteId ?? '',
|
||||||
|
routeKey: route.RouteKey ?? '',
|
||||||
|
integration: integration ? toIntegrationOverview(integration) : null,
|
||||||
|
authorizationType: route.AuthorizationType ?? 'NONE',
|
||||||
|
// Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE.
|
||||||
|
authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null,
|
||||||
|
authorizationScopes: route.AuthorizationScopes ?? [],
|
||||||
|
returnsResponse: Boolean(route.RouteResponseSelectionExpression),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntegrationOverview(integration: Partial<Integration>): HttpApiIntegrationOverview {
|
||||||
|
return {
|
||||||
|
integrationId: integration.IntegrationId ?? '',
|
||||||
|
type: integration.IntegrationType ?? '',
|
||||||
|
uri: integration.IntegrationUri ?? null,
|
||||||
|
functionName: integrationFunctionName(integration),
|
||||||
|
payloadFormatVersion: integration.PayloadFormatVersion ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial<Stage>): HttpApiStageOverview {
|
||||||
|
const stageName = stage.StageName ?? ''
|
||||||
|
return {
|
||||||
|
stageName,
|
||||||
|
autoDeploy: Boolean(stage.AutoDeploy),
|
||||||
|
deploymentId: stage.DeploymentId ?? null,
|
||||||
|
lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null,
|
||||||
|
invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAuthorizerOverview(authorizer: Partial<Authorizer>): HttpApiAuthorizerOverview {
|
||||||
|
return {
|
||||||
|
authorizerId: authorizer.AuthorizerId ?? '',
|
||||||
|
name: authorizer.Name ?? '',
|
||||||
|
type: authorizer.AuthorizerType ?? '',
|
||||||
|
identitySource: authorizer.IdentitySource ?? [],
|
||||||
|
issuer: authorizer.JwtConfiguration?.Issuer ?? null,
|
||||||
|
audience: authorizer.JwtConfiguration?.Audience ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCors(cors: Cors | undefined): HttpApiCors | null {
|
||||||
|
if (!cors) return null
|
||||||
|
return {
|
||||||
|
allowOrigins: cors.AllowOrigins ?? [],
|
||||||
|
allowMethods: cors.AllowMethods ?? [],
|
||||||
|
allowHeaders: cors.AllowHeaders ?? [],
|
||||||
|
exposeHeaders: cors.ExposeHeaders ?? [],
|
||||||
|
maxAgeSeconds: cors.MaxAge ?? null,
|
||||||
|
allowCredentials: Boolean(cors.AllowCredentials),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCors(cors: HttpApiCors): Cors {
|
||||||
|
const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))]
|
||||||
|
const allowOrigins = clean(cors.allowOrigins)
|
||||||
|
const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase())
|
||||||
|
if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *')
|
||||||
|
if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) {
|
||||||
|
throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com')
|
||||||
|
}
|
||||||
|
if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`)
|
||||||
|
if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead')
|
||||||
|
const maxAge = cors.maxAgeSeconds
|
||||||
|
if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) {
|
||||||
|
throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
AllowOrigins: allowOrigins,
|
||||||
|
AllowMethods: allowMethods,
|
||||||
|
AllowHeaders: clean(cors.allowHeaders),
|
||||||
|
ExposeHeaders: clean(cors.exposeHeaders),
|
||||||
|
MaxAge: maxAge ?? undefined,
|
||||||
|
AllowCredentials: Boolean(cors.allowCredentials),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */
|
||||||
|
export function httpRouteKey(value: string): string {
|
||||||
|
const key = stringValue(value).replace(/\s+/g, ' ')
|
||||||
|
if (key === '$default') return key
|
||||||
|
const [method, path, ...rest] = key.split(' ')
|
||||||
|
if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) {
|
||||||
|
throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}')
|
||||||
|
}
|
||||||
|
const segments = path === '/' ? [] : path.split('/').slice(1)
|
||||||
|
if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment)
|
||||||
|
|| (segment.endsWith('+}') && index !== segments.length - 1))) {
|
||||||
|
throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}')
|
||||||
|
}
|
||||||
|
return `${method.toUpperCase()} ${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */
|
||||||
|
export function webSocketRouteKey(value: string): string {
|
||||||
|
const key = stringValue(value)
|
||||||
|
if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key
|
||||||
|
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
|
||||||
|
throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage')
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageNameValue(value: string): string {
|
||||||
|
const name = stringValue(value)
|
||||||
|
if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) {
|
||||||
|
throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores')
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number {
|
||||||
|
const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey
|
||||||
|
return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lambdaInvocationUri(region: string, functionArn: string): string {
|
||||||
|
return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`
|
||||||
|
}
|
||||||
|
|
||||||
|
function integrationFunctionName(integration: Partial<Integration>): string | null {
|
||||||
|
if (integration.IntegrationType !== 'AWS_PROXY') return null
|
||||||
|
return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetIntegrationId(target: string | undefined): string | undefined {
|
||||||
|
return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeStatementId(apiId: string): string {
|
||||||
|
return `apigatewayv2-${apiId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHttpStatus(error: unknown, status: number): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null) return false
|
||||||
|
return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export class AwsLogsAdapter implements CloudServiceAdapter {
|
|||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (!queryId) throw new NotFoundError(`Floci did not return a query id for ${logGroupName}.`)
|
if (!queryId) throw new NotFoundError(`The runtime did not return a query id for ${logGroupName}.`)
|
||||||
|
|
||||||
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
|
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
|
||||||
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
|
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
DeleteAlarmsCommand,
|
||||||
|
DescribeAlarmHistoryCommand,
|
||||||
|
DescribeAlarmsCommand,
|
||||||
|
DisableAlarmActionsCommand,
|
||||||
|
EnableAlarmActionsCommand,
|
||||||
|
GetMetricDataCommand,
|
||||||
|
ListMetricsCommand,
|
||||||
|
PutCompositeAlarmCommand,
|
||||||
|
PutMetricAlarmCommand,
|
||||||
|
SetAlarmStateCommand,
|
||||||
|
type CloudWatchClient,
|
||||||
|
} from '@aws-sdk/client-cloudwatch'
|
||||||
|
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
|
||||||
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
} as unknown as CloudWatchClient
|
||||||
|
return {adapter: new AwsMonitoringAdapter(client), sent}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPDATED = new Date('2026-10-25T10:00:00Z')
|
||||||
|
const metricAlarm = {
|
||||||
|
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
|
||||||
|
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||||||
|
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
|
||||||
|
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||||||
|
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||||||
|
}
|
||||||
|
const compositeAlarm = {
|
||||||
|
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
|
||||||
|
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsMonitoringAdapter alarms', () => {
|
||||||
|
test('lists metric and composite alarms with a readable condition', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
|
||||||
|
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
|
||||||
|
: {})
|
||||||
|
|
||||||
|
const alarms = await adapter.list()
|
||||||
|
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
|
||||||
|
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
|
||||||
|
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
|
||||||
|
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
|
||||||
|
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
|
||||||
|
|
||||||
|
await adapter.create({values: {
|
||||||
|
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
|
||||||
|
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
|
||||||
|
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
|
||||||
|
}})
|
||||||
|
|
||||||
|
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
|
||||||
|
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||||||
|
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||||||
|
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a composite alarm from its rule', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
|
||||||
|
|
||||||
|
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
|
||||||
|
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
|
||||||
|
expect(created.metadata.alarmType).toBe('composite')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checks the alarm before creating it', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||||||
|
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
|
||||||
|
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
|
||||||
|
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
|
||||||
|
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
|
||||||
|
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
|
||||||
|
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
|
||||||
|
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes, sets the state and toggles the actions of an alarm', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||||||
|
|
||||||
|
await adapter.delete('high-errors')
|
||||||
|
await adapter.setAlarmState('high-errors', 'ALARM', '')
|
||||||
|
await adapter.setAlarmActionsEnabled('high-errors', false)
|
||||||
|
await adapter.setAlarmActionsEnabled('high-errors', true)
|
||||||
|
|
||||||
|
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
|
||||||
|
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
|
||||||
|
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
|
||||||
|
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
|
||||||
|
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
|
||||||
|
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads the alarm history newest first', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
|
||||||
|
|
||||||
|
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||||
|
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AwsMonitoringAdapter metrics', () => {
|
||||||
|
test('lists metrics sorted by namespace and filters them', async () => {
|
||||||
|
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
|
||||||
|
? {Metrics: [
|
||||||
|
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
|
||||||
|
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
|
||||||
|
]}
|
||||||
|
: {})
|
||||||
|
|
||||||
|
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
|
||||||
|
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('charts a metric with a period that suits the range', async () => {
|
||||||
|
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
|
||||||
|
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
|
||||||
|
: {})
|
||||||
|
|
||||||
|
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
|
||||||
|
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
|
||||||
|
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
|
||||||
|
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
|
||||||
|
|
||||||
|
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
|
||||||
|
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
|
||||||
|
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
|
||||||
|
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import {
|
||||||
|
DeleteAlarmsCommand,
|
||||||
|
DescribeAlarmHistoryCommand,
|
||||||
|
DescribeAlarmsCommand,
|
||||||
|
DisableAlarmActionsCommand,
|
||||||
|
EnableAlarmActionsCommand,
|
||||||
|
GetMetricDataCommand,
|
||||||
|
ListMetricsCommand,
|
||||||
|
PutCompositeAlarmCommand,
|
||||||
|
PutMetricAlarmCommand,
|
||||||
|
SetAlarmStateCommand,
|
||||||
|
type CloudWatchClient,
|
||||||
|
type ComparisonOperator,
|
||||||
|
type CompositeAlarm,
|
||||||
|
type MetricAlarm,
|
||||||
|
type Statistic,
|
||||||
|
} from '@aws-sdk/client-cloudwatch'
|
||||||
|
import {
|
||||||
|
ALARM_COMPARISONS,
|
||||||
|
ALARM_NAME_PATTERN,
|
||||||
|
ALARM_PERIODS,
|
||||||
|
ALARM_STATES,
|
||||||
|
ALARM_STATISTICS,
|
||||||
|
ALARM_TYPES,
|
||||||
|
awsMonitoringSchema,
|
||||||
|
} from '../cloud-spi/monitoringSchema'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import type {
|
||||||
|
AlarmHistoryItem,
|
||||||
|
AlarmStateValue,
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
MetricDimension,
|
||||||
|
MetricSeries,
|
||||||
|
MetricSeriesQuery,
|
||||||
|
MetricSummary,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** CloudWatch's page and request limits. */
|
||||||
|
const ALARM_PAGE_LIMIT = 100
|
||||||
|
const METRIC_LIST_CAP = 500
|
||||||
|
const HISTORY_LIMIT = 25
|
||||||
|
const MAX_DIMENSIONS = 30
|
||||||
|
const MAX_RANGE_MINUTES = 14 * 24 * 60
|
||||||
|
const SYMBOL: Record<string, string> = {
|
||||||
|
GreaterThanThreshold: '>',
|
||||||
|
GreaterThanOrEqualToThreshold: '>=',
|
||||||
|
LessThanThreshold: '<',
|
||||||
|
LessThanOrEqualToThreshold: '<=',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to CloudWatch metrics and alarms. Alarms, metric and composite, are the resources;
|
||||||
|
* metrics are browsed and charted through the monitoring actions.
|
||||||
|
*
|
||||||
|
* Verified against TheCloud's runtime: alarm actions publish to SNS on every state change,
|
||||||
|
* `SetAlarmState` changes the state until the next evaluation, and `PutMetricData` datums
|
||||||
|
* are returned by `GetMetricData` straight away.
|
||||||
|
*/
|
||||||
|
export class AwsMonitoringAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'monitoring' as const
|
||||||
|
|
||||||
|
constructor(private readonly cloudwatch: CloudWatchClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsMonitoringSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const metricAlarms: MetricAlarm[] = []
|
||||||
|
const compositeAlarms: CompositeAlarm[] = []
|
||||||
|
let nextToken: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({
|
||||||
|
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'],
|
||||||
|
MaxRecords: ALARM_PAGE_LIMIT,
|
||||||
|
...(nextToken ? {NextToken: nextToken} : {}),
|
||||||
|
}))
|
||||||
|
metricAlarms.push(...(res.MetricAlarms ?? []))
|
||||||
|
compositeAlarms.push(...(res.CompositeAlarms ?? []))
|
||||||
|
nextToken = res.NextToken || undefined
|
||||||
|
} while (nextToken)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return [...metricAlarms.map(metricAlarmResource), ...compositeAlarms.map(compositeAlarmResource)]
|
||||||
|
.filter((alarm) => !search || [alarm.name, String(alarm.metadata.metricName ?? ''), String(alarm.metadata.namespace ?? '')].some((value) => value.toLowerCase().includes(search)))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(name: string): Promise<CloudResource | null> {
|
||||||
|
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({AlarmNames: [name], AlarmTypes: ['MetricAlarm', 'CompositeAlarm']}))
|
||||||
|
const metric = res.MetricAlarms?.[0]
|
||||||
|
if (metric) return metricAlarmResource(metric)
|
||||||
|
const composite = res.CompositeAlarms?.[0]
|
||||||
|
return composite ? compositeAlarmResource(composite) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const type = oneOf(values.alarmType ?? 'metric', ALARM_TYPES, 'alarmType')
|
||||||
|
const name = text(values.name)
|
||||||
|
if (!new RegExp(ALARM_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without control characters')
|
||||||
|
const description = text(values.description) || undefined
|
||||||
|
const topicArn = text(values.topicArn)
|
||||||
|
if (topicArn && !/^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}(\.fifo)?$/.test(topicArn)) {
|
||||||
|
throw new ValidationError('topicArn must be an SNS topic ARN, such as arn:aws:sns:us-east-1:000000000000:alerts')
|
||||||
|
}
|
||||||
|
const actions = topicArn ? [topicArn] : []
|
||||||
|
|
||||||
|
if (type === 'composite') {
|
||||||
|
const rule = text(values.alarmRule)
|
||||||
|
if (!rule) throw new ValidationError('Write the rule, such as ALARM("high-errors")')
|
||||||
|
if (rule.length > 10240) throw new ValidationError('The rule must be at most 10240 characters')
|
||||||
|
await this.cloudwatch.send(new PutCompositeAlarmCommand({
|
||||||
|
AlarmName: name,
|
||||||
|
AlarmDescription: description,
|
||||||
|
AlarmRule: rule,
|
||||||
|
AlarmActions: actions,
|
||||||
|
OKActions: actions,
|
||||||
|
ActionsEnabled: true,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
const namespace = text(values.namespace)
|
||||||
|
const metricName = text(values.metricName)
|
||||||
|
if (!namespace) throw new ValidationError('namespace is required, such as AWS/Lambda')
|
||||||
|
if (!metricName) throw new ValidationError('metricName is required, such as Errors')
|
||||||
|
const threshold = Number(text(values.threshold))
|
||||||
|
if (text(values.threshold) === '' || !Number.isFinite(threshold)) throw new ValidationError('threshold must be a number')
|
||||||
|
const evaluationPeriods = Number(text(values.evaluationPeriods) || '1')
|
||||||
|
if (!Number.isInteger(evaluationPeriods) || evaluationPeriods < 1) throw new ValidationError('evaluationPeriods must be a whole number from 1')
|
||||||
|
await this.cloudwatch.send(new PutMetricAlarmCommand({
|
||||||
|
AlarmName: name,
|
||||||
|
AlarmDescription: description,
|
||||||
|
Namespace: namespace,
|
||||||
|
MetricName: metricName,
|
||||||
|
Dimensions: parseDimensions(values.dimensions).map((dimension) => ({Name: dimension.name, Value: dimension.value})),
|
||||||
|
Statistic: oneOf(values.statistic || 'Average', ALARM_STATISTICS, 'statistic') as Statistic,
|
||||||
|
Period: Number(oneOf(String(values.period || '300'), ALARM_PERIODS, 'period')),
|
||||||
|
EvaluationPeriods: evaluationPeriods,
|
||||||
|
Threshold: threshold,
|
||||||
|
ComparisonOperator: oneOf(values.comparison || 'GreaterThanThreshold', ALARM_COMPARISONS, 'comparison') as ComparisonOperator,
|
||||||
|
TreatMissingData: 'missing',
|
||||||
|
AlarmActions: actions,
|
||||||
|
OKActions: actions,
|
||||||
|
ActionsEnabled: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new NotFoundError(`CloudWatch did not return the alarm ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(name: string): Promise<void> {
|
||||||
|
await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMetrics(query: {namespace?: string; search?: string} = {}): Promise<MetricSummary[]> {
|
||||||
|
const metrics: MetricSummary[] = []
|
||||||
|
let nextToken: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.cloudwatch.send(new ListMetricsCommand({
|
||||||
|
...(query.namespace ? {Namespace: query.namespace} : {}),
|
||||||
|
...(nextToken ? {NextToken: nextToken} : {}),
|
||||||
|
}))
|
||||||
|
for (const metric of res.Metrics ?? []) {
|
||||||
|
metrics.push({
|
||||||
|
namespace: metric.Namespace ?? '',
|
||||||
|
metricName: metric.MetricName ?? '',
|
||||||
|
dimensions: (metric.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
nextToken = res.NextToken || undefined
|
||||||
|
} while (nextToken && metrics.length < METRIC_LIST_CAP)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return metrics.slice(0, METRIC_LIST_CAP)
|
||||||
|
.filter((metric) => !search || [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => dimension.value)].some((value) => value.toLowerCase().includes(search)))
|
||||||
|
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.metricName.localeCompare(b.metricName))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetricSeries(query: MetricSeriesQuery): Promise<MetricSeries> {
|
||||||
|
const namespace = text(query?.namespace)
|
||||||
|
const metricName = text(query.metricName)
|
||||||
|
if (!namespace || !metricName) throw new ValidationError('namespace and metricName are required')
|
||||||
|
const statistic = oneOf(query.statistic || 'Average', ALARM_STATISTICS, 'statistic')
|
||||||
|
const rangeMinutes = Number(query.rangeMinutes ?? 60)
|
||||||
|
if (!Number.isFinite(rangeMinutes) || rangeMinutes < 5 || rangeMinutes > MAX_RANGE_MINUTES) throw new ValidationError('rangeMinutes must be between 5 and 20160')
|
||||||
|
// A point every minute for short ranges, coarser for long ones, staying near 60-300 points.
|
||||||
|
const periodSeconds = Number(query.periodSeconds) || (rangeMinutes <= 180 ? 60 : rangeMinutes <= 1440 ? 300 : 3600)
|
||||||
|
if (![60, 300, 900, 3600].includes(periodSeconds)) throw new ValidationError('periodSeconds must be 60, 300, 900 or 3600')
|
||||||
|
const dimensions = (query.dimensions ?? []).map((dimension) => ({Name: text(dimension?.name), Value: text(dimension?.value)}))
|
||||||
|
if (dimensions.some((dimension) => !dimension.Name)) throw new ValidationError('Every dimension needs a name')
|
||||||
|
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end.getTime() - rangeMinutes * 60_000)
|
||||||
|
const res = await this.cloudwatch.send(new GetMetricDataCommand({
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: end,
|
||||||
|
ScanBy: 'TimestampAscending',
|
||||||
|
MetricDataQueries: [{
|
||||||
|
Id: 'm1',
|
||||||
|
ReturnData: true,
|
||||||
|
MetricStat: {Metric: {Namespace: namespace, MetricName: metricName, Dimensions: dimensions}, Period: periodSeconds, Stat: statistic},
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
const result = res.MetricDataResults?.[0]
|
||||||
|
const timestamps = result?.Timestamps ?? []
|
||||||
|
const points = timestamps.map((timestamp, index) => ({timestamp: timestamp.toISOString(), value: result?.Values?.[index] ?? 0}))
|
||||||
|
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||||
|
return {label: result?.Label || metricName, statistic, periodSeconds, points}
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeAlarmHistory(name: string): Promise<AlarmHistoryItem[]> {
|
||||||
|
const res = await this.cloudwatch.send(new DescribeAlarmHistoryCommand({AlarmName: name, MaxRecords: HISTORY_LIMIT, ScanBy: 'TimestampDescending'}))
|
||||||
|
return (res.AlarmHistoryItems ?? []).map((item) => ({
|
||||||
|
timestamp: item.Timestamp?.toISOString() ?? null,
|
||||||
|
type: item.HistoryItemType ?? '',
|
||||||
|
summary: item.HistorySummary ?? '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes the state until the next evaluation, which is how alarm actions are tested on AWS. */
|
||||||
|
async setAlarmState(name: string, state: AlarmStateValue, reason: string): Promise<void> {
|
||||||
|
const value = oneOf(state, ALARM_STATES, 'state')
|
||||||
|
const why = text(reason) || `Set to ${value} from TheCloud to test the alarm's actions`
|
||||||
|
if (why.length > 1023) throw new ValidationError('reason must be at most 1023 characters')
|
||||||
|
await this.cloudwatch.send(new SetAlarmStateCommand({AlarmName: name, StateValue: value, StateReason: why}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAlarmActionsEnabled(name: string, enabled: boolean): Promise<void> {
|
||||||
|
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
|
||||||
|
await this.cloudwatch.send(enabled ? new EnableAlarmActionsCommand({AlarmNames: [name]}) : new DisableAlarmActionsCommand({AlarmNames: [name]}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricAlarmResource(alarm: MetricAlarm): CloudResource {
|
||||||
|
const dimensions = (alarm.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''}))
|
||||||
|
const period = alarm.Period ?? 0
|
||||||
|
return {
|
||||||
|
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
|
||||||
|
metadata: {
|
||||||
|
alarmType: 'metric',
|
||||||
|
arn: alarm.AlarmArn ?? null,
|
||||||
|
description: alarm.AlarmDescription ?? null,
|
||||||
|
stateReason: alarm.StateReason ?? null,
|
||||||
|
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
|
||||||
|
actionsEnabled: alarm.ActionsEnabled ?? true,
|
||||||
|
alarmActions: alarm.AlarmActions ?? [],
|
||||||
|
okActions: alarm.OKActions ?? [],
|
||||||
|
namespace: alarm.Namespace ?? null,
|
||||||
|
metricName: alarm.MetricName ?? null,
|
||||||
|
dimensions,
|
||||||
|
statistic: alarm.Statistic ?? alarm.ExtendedStatistic ?? null,
|
||||||
|
periodSeconds: period || null,
|
||||||
|
threshold: alarm.Threshold ?? null,
|
||||||
|
comparison: alarm.ComparisonOperator ?? null,
|
||||||
|
evaluationPeriods: alarm.EvaluationPeriods ?? null,
|
||||||
|
condition: alarm.MetricName
|
||||||
|
? `${alarm.MetricName} ${alarm.Statistic ?? ''} ${SYMBOL[alarm.ComparisonOperator ?? ''] ?? alarm.ComparisonOperator ?? ''} ${alarm.Threshold ?? ''} for ${alarm.EvaluationPeriods ?? 1} × ${periodLabel(period)}`.replace(/\s+/g, ' ').trim()
|
||||||
|
: 'Metric math',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compositeAlarmResource(alarm: CompositeAlarm): CloudResource {
|
||||||
|
return {
|
||||||
|
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
|
||||||
|
metadata: {
|
||||||
|
alarmType: 'composite',
|
||||||
|
arn: alarm.AlarmArn ?? null,
|
||||||
|
description: alarm.AlarmDescription ?? null,
|
||||||
|
stateReason: alarm.StateReason ?? null,
|
||||||
|
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
|
||||||
|
actionsEnabled: alarm.ActionsEnabled ?? true,
|
||||||
|
alarmActions: alarm.AlarmActions ?? [],
|
||||||
|
okActions: alarm.OKActions ?? [],
|
||||||
|
alarmRule: alarm.AlarmRule ?? null,
|
||||||
|
condition: alarm.AlarmRule ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseResource(name: string, arn: string | undefined, state: string | undefined, updated: Date | undefined) {
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws' as const,
|
||||||
|
service: 'monitoring' as const,
|
||||||
|
type: 'alarm',
|
||||||
|
region: arn?.split(':')[3] || null,
|
||||||
|
createdAt: updated?.toISOString() ?? null,
|
||||||
|
status: state ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One Name=Value per line, as the create form takes it. */
|
||||||
|
function parseDimensions(value: unknown): MetricDimension[] {
|
||||||
|
const lines = typeof value === 'string' ? value.split('\n').map((line) => line.trim()).filter(Boolean) : []
|
||||||
|
if (lines.length > MAX_DIMENSIONS) throw new ValidationError(`An alarm takes at most ${MAX_DIMENSIONS} dimensions`)
|
||||||
|
return lines.map((line) => {
|
||||||
|
const index = line.indexOf('=')
|
||||||
|
const name = index > 0 ? line.slice(0, index).trim() : ''
|
||||||
|
const dimensionValue = index > 0 ? line.slice(index + 1).trim() : ''
|
||||||
|
if (!name || !dimensionValue) throw new ValidationError(`Write each dimension as Name=Value (got "${line}")`)
|
||||||
|
return {name, value: dimensionValue}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodLabel(seconds: number): string {
|
||||||
|
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`
|
||||||
|
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
|
||||||
|
return `${seconds} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
|
||||||
|
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
|
||||||
|
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
CreatePipeCommand,
|
||||||
|
DeletePipeCommand,
|
||||||
|
DescribePipeCommand,
|
||||||
|
ListPipesCommand,
|
||||||
|
type PipesClient,
|
||||||
|
StartPipeCommand,
|
||||||
|
StopPipeCommand,
|
||||||
|
} from '@aws-sdk/client-pipes'
|
||||||
|
import {AwsPipesAdapter, describeArn} from './AwsPipesAdapter'
|
||||||
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function stubPipes(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
return handler(command)
|
||||||
|
},
|
||||||
|
} as unknown as PipesClient
|
||||||
|
return {client, sent, adapter: new AwsPipesAdapter(client)}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SRC = 'arn:aws:sqs:us-east-1:000000000000:pipe-src'
|
||||||
|
const DST = 'arn:aws:lambda:us-east-1:000000000000:function:worker'
|
||||||
|
const ROLE = 'arn:aws:iam::000000000000:role/pipe-role'
|
||||||
|
const described = {
|
||||||
|
Name: 'orders-pipe', Arn: 'arn:aws:pipes:us-east-1:000000000000:pipe/orders-pipe',
|
||||||
|
DesiredState: 'RUNNING', CurrentState: 'RUNNING', Source: SRC, Target: DST, RoleArn: ROLE,
|
||||||
|
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
|
||||||
|
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
|
||||||
|
CreationTime: new Date('2026-10-25T16:42:45Z'),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsPipesAdapter', () => {
|
||||||
|
test('lists pipes across pages with readable source and target names', async () => {
|
||||||
|
const {adapter} = stubPipes((command) => {
|
||||||
|
if (!(command instanceof ListPipesCommand)) return {}
|
||||||
|
return command.input.NextToken
|
||||||
|
? {Pipes: [{Name: 'clicks', CurrentState: 'STOPPED', Source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', Target: 'arn:aws:events:us-east-1:000000000000:event-bus/default'}]}
|
||||||
|
: {Pipes: [{Name: 'orders-pipe', CurrentState: 'RUNNING', Source: SRC, Target: DST}], NextToken: 'n'}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pipes = await adapter.list()
|
||||||
|
expect(pipes.map((pipe) => [pipe.name, pipe.status, pipe.metadata.sourceName, pipe.metadata.targetName])).toEqual([
|
||||||
|
['orders-pipe', 'RUNNING', 'pipe-src (SQS queue)', 'worker (Lambda function)'],
|
||||||
|
['clicks', 'STOPPED', 'clicks (Kinesis stream)', 'default (EventBridge event bus)'],
|
||||||
|
])
|
||||||
|
expect((await adapter.list({search: 'kinesis'})).map((pipe) => pipe.name)).toEqual(['clicks'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describes the filter, batch size, template and role', async () => {
|
||||||
|
const {adapter} = stubPipes(() => described)
|
||||||
|
expect((await adapter.get('orders-pipe'))?.metadata).toMatchObject({
|
||||||
|
filterPatterns: ['{"body":{"kind":["order"]}}'],
|
||||||
|
batchSize: 5,
|
||||||
|
inputTemplate: '{"id": <$.messageId>}',
|
||||||
|
roleArn: ROLE,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing pipe is null', async () => {
|
||||||
|
const {adapter} = stubPipes(() => { throw Object.assign(new Error('gone'), {name: 'NotFoundException'}) })
|
||||||
|
expect(await adapter.get('nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates an SQS pipe with its filter, batch size and template', async () => {
|
||||||
|
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
|
||||||
|
|
||||||
|
await adapter.create({values: {name: 'orders-pipe', source: SRC, target: DST, roleArn: ROLE, batchSize: '5', filterPattern: '{ "body": { "kind": ["order"] } }', inputTemplate: '{"id": <$.messageId>}'}})
|
||||||
|
expect((sent[0] as CreatePipeCommand).input).toEqual({
|
||||||
|
Name: 'orders-pipe',
|
||||||
|
Source: SRC,
|
||||||
|
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
|
||||||
|
Target: DST,
|
||||||
|
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
|
||||||
|
RoleArn: ROLE,
|
||||||
|
DesiredState: 'RUNNING',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gives stream and MSK sources the starting position they require', async () => {
|
||||||
|
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
|
||||||
|
|
||||||
|
await adapter.create({values: {name: 'clicks', source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', target: DST, roleArn: ROLE, startingPosition: 'TRIM_HORIZON', desiredState: 'STOPPED'}})
|
||||||
|
expect((sent[0] as CreatePipeCommand).input).toMatchObject({SourceParameters: {KinesisStreamParameters: {StartingPosition: 'TRIM_HORIZON'}}, DesiredState: 'STOPPED'})
|
||||||
|
|
||||||
|
await adapter.create({values: {name: 'k', source: 'arn:aws:kafka:us-east-1:000000000000:cluster/events/abc', target: DST, roleArn: ROLE, topicName: 'orders'}})
|
||||||
|
expect((sent[2] as CreatePipeCommand).input.SourceParameters).toEqual({ManagedStreamingKafkaParameters: {TopicName: 'orders', StartingPosition: 'LATEST'}})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses pipes the runtime could not run, before calling it', async () => {
|
||||||
|
const {adapter, sent} = stubPipes(() => ({}))
|
||||||
|
const base = {name: 'p', source: SRC, target: DST, roleArn: ROLE}
|
||||||
|
await expect(adapter.create({values: {...base, target: 'arn:aws:s3:::bucket'}})).rejects.toThrow('target must be the ARN of a')
|
||||||
|
await expect(adapter.create({values: {...base, source: 'arn:aws:sns:us-east-1:000000000000:topic'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {...base, enrichment: 'arn:aws:sqs:us-east-1:000000000000:q'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {...base, filterPattern: '{kind: order}'}})).rejects.toThrow('filterPattern must be JSON')
|
||||||
|
await expect(adapter.create({values: {...base, batchSize: '20000'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {...base, source: 'arn:aws:kafka:us-east-1:000000000000:cluster/c/1'}})).rejects.toThrow('topicName is required')
|
||||||
|
await expect(adapter.create({values: {...base, roleArn: 'pipe-role'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
expect(sent).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts, stops and deletes by name', async () => {
|
||||||
|
const {adapter, sent} = stubPipes(() => ({}))
|
||||||
|
await adapter.stop('orders-pipe')
|
||||||
|
await adapter.start('orders-pipe')
|
||||||
|
await adapter.delete('orders-pipe')
|
||||||
|
expect(sent.map((command) => command.constructor)).toEqual([StopPipeCommand, StartPipeCommand, DeletePipeCommand])
|
||||||
|
expect(sent.every((command) => (command as StopPipeCommand).input.Name === 'orders-pipe')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('names every kind of source and target ARN', () => {
|
||||||
|
expect(describeArn('arn:aws:dynamodb:us-east-1:000000000000:table/orders/stream/2026-10-25T00:00:00.000')).toBe('orders (DynamoDB stream)')
|
||||||
|
expect(describeArn('arn:aws:states:us-east-1:000000000000:stateMachine:flow')).toBe('flow (Step Functions state machine)')
|
||||||
|
expect(describeArn('arn:aws:sns:us-east-1:000000000000:alerts')).toBe('alerts (SNS topic)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
CreatePipeCommand,
|
||||||
|
DeletePipeCommand,
|
||||||
|
DescribePipeCommand,
|
||||||
|
type DescribePipeCommandOutput,
|
||||||
|
ListPipesCommand,
|
||||||
|
type Pipe,
|
||||||
|
type PipeSourceParameters,
|
||||||
|
type PipesClient,
|
||||||
|
StartPipeCommand,
|
||||||
|
StopPipeCommand,
|
||||||
|
} from '@aws-sdk/client-pipes'
|
||||||
|
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {
|
||||||
|
awsPipesSchema,
|
||||||
|
PIPE_ENRICHMENT_SERVICES,
|
||||||
|
PIPE_NAME_PATTERN,
|
||||||
|
PIPE_SOURCE_SERVICES,
|
||||||
|
PIPE_STARTING_POSITIONS,
|
||||||
|
PIPE_TARGET_SERVICES,
|
||||||
|
} from '../cloud-spi/pipesSchema'
|
||||||
|
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
const MAX_BATCH_SIZE = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to EventBridge Pipes, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Pipes really run: the runtime polls SQS, Kinesis, DynamoDB streams and MSK,
|
||||||
|
* applies the filter, enrichment and input template, and calls the target.
|
||||||
|
* - Its poller delivers only to Lambda, SQS, SNS, EventBridge buses and Step
|
||||||
|
* Functions, so create refuses other targets instead of making a pipe that
|
||||||
|
* would never deliver.
|
||||||
|
* - ListPipes returns no parameters, so the table shows only what it carries;
|
||||||
|
* the filter, template and role come from DescribePipe.
|
||||||
|
*/
|
||||||
|
export class AwsPipesAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'pipes' as const
|
||||||
|
|
||||||
|
constructor(private readonly pipes: PipesClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsPipesSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: Pipe[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.pipes.send(new ListPipesCommand({Limit: 100, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.Pipes ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return found.map((pipe) => toResource(pipe)).filter((pipe) => !search
|
||||||
|
|| pipe.name.toLowerCase().includes(search)
|
||||||
|
|| String(pipe.metadata.source).toLowerCase().includes(search)
|
||||||
|
|| String(pipe.metadata.target).toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
return toResource(await this.pipes.send(new DescribePipeCommand({Name: id})))
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'NotFoundException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (!new RegExp(PIPE_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 64 letters, numbers, dots, dashes or underscores')
|
||||||
|
|
||||||
|
const source = text(values.source)
|
||||||
|
const sourceService = supported(source, PIPE_SOURCE_SERVICES, 'source')
|
||||||
|
const target = text(values.target)
|
||||||
|
supported(target, PIPE_TARGET_SERVICES, 'target')
|
||||||
|
const enrichment = text(values.enrichment)
|
||||||
|
if (enrichment) supported(enrichment, PIPE_ENRICHMENT_SERVICES, 'enrichment')
|
||||||
|
const roleArn = text(values.roleArn)
|
||||||
|
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) throw new ValidationError('roleArn must be an IAM role ARN')
|
||||||
|
|
||||||
|
const inputTemplate = text(values.inputTemplate)
|
||||||
|
const description = text(values.description)
|
||||||
|
const desiredState = values.desiredState === 'STOPPED' ? 'STOPPED' : 'RUNNING'
|
||||||
|
|
||||||
|
await this.pipes.send(new CreatePipeCommand({
|
||||||
|
Name: name,
|
||||||
|
Source: source,
|
||||||
|
SourceParameters: sourceParameters(sourceService, values),
|
||||||
|
Target: target,
|
||||||
|
...(inputTemplate ? {TargetParameters: {InputTemplate: inputTemplate}} : {}),
|
||||||
|
...(enrichment ? {Enrichment: enrichment} : {}),
|
||||||
|
RoleArn: roleArn,
|
||||||
|
DesiredState: desiredState,
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`EventBridge Pipes did not return the new pipe ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.pipes.send(new DeletePipeCommand({Name: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(id: string): Promise<void> {
|
||||||
|
await this.pipes.send(new StartPipeCommand({Name: id}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(id: string): Promise<void> {
|
||||||
|
await this.pipes.send(new StopPipeCommand({Name: id}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each source kind carries its own parameter block, as CreatePipe expects. */
|
||||||
|
function sourceParameters(service: keyof typeof PIPE_SOURCE_SERVICES, values: Record<string, unknown>): PipeSourceParameters {
|
||||||
|
const batchSize = optionalBatchSize(values.batchSize)
|
||||||
|
const filterPattern = text(values.filterPattern)
|
||||||
|
const parameters: PipeSourceParameters = filterPattern ? {FilterCriteria: {Filters: [{Pattern: jsonObject(filterPattern, 'filterPattern')}]}} : {}
|
||||||
|
const batch = batchSize ? {BatchSize: batchSize} : {}
|
||||||
|
|
||||||
|
if (service === 'sqs') return {...parameters, SqsQueueParameters: batch}
|
||||||
|
|
||||||
|
const startingPosition = text(values.startingPosition) || 'LATEST'
|
||||||
|
if (!(PIPE_STARTING_POSITIONS as readonly string[]).includes(startingPosition)) {
|
||||||
|
throw new ValidationError(`startingPosition must be one of ${PIPE_STARTING_POSITIONS.join(', ')}`)
|
||||||
|
}
|
||||||
|
const position = startingPosition as (typeof PIPE_STARTING_POSITIONS)[number]
|
||||||
|
if (service === 'kinesis') return {...parameters, KinesisStreamParameters: {StartingPosition: position, ...batch}}
|
||||||
|
if (service === 'dynamodb') return {...parameters, DynamoDBStreamParameters: {StartingPosition: position, ...batch}}
|
||||||
|
|
||||||
|
const topicName = text(values.topicName)
|
||||||
|
if (!topicName) throw new ValidationError('topicName is required for an MSK source')
|
||||||
|
return {...parameters, ManagedStreamingKafkaParameters: {TopicName: topicName, StartingPosition: position, ...batch}}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(pipe: Pipe | DescribePipeCommandOutput): CloudResource {
|
||||||
|
const name = pipe.Name ?? ''
|
||||||
|
const detail = 'SourceParameters' in pipe ? pipe : undefined
|
||||||
|
const sourceParameters = detail?.SourceParameters
|
||||||
|
const batchSize = sourceParameters?.SqsQueueParameters?.BatchSize
|
||||||
|
?? sourceParameters?.KinesisStreamParameters?.BatchSize
|
||||||
|
?? sourceParameters?.DynamoDBStreamParameters?.BatchSize
|
||||||
|
?? sourceParameters?.ManagedStreamingKafkaParameters?.BatchSize
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'pipes',
|
||||||
|
type: 'pipe',
|
||||||
|
region: pipe.Arn?.split(':')[3] || null,
|
||||||
|
createdAt: pipe.CreationTime ? pipe.CreationTime.toISOString() : null,
|
||||||
|
status: pipe.CurrentState ?? null,
|
||||||
|
metadata: {
|
||||||
|
arn: pipe.Arn,
|
||||||
|
desiredState: pipe.DesiredState ?? null,
|
||||||
|
currentState: pipe.CurrentState ?? null,
|
||||||
|
stateReason: pipe.StateReason ?? null,
|
||||||
|
source: pipe.Source ?? null,
|
||||||
|
sourceName: describeArn(pipe.Source),
|
||||||
|
target: pipe.Target ?? null,
|
||||||
|
targetName: describeArn(pipe.Target),
|
||||||
|
enrichment: pipe.Enrichment ?? null,
|
||||||
|
lastModified: pipe.LastModifiedTime ? pipe.LastModifiedTime.toISOString() : null,
|
||||||
|
...(detail ? {
|
||||||
|
description: detail.Description ?? null,
|
||||||
|
roleArn: detail.RoleArn ?? null,
|
||||||
|
filterPatterns: (sourceParameters?.FilterCriteria?.Filters ?? []).map((filter) => filter.Pattern ?? '').filter(Boolean),
|
||||||
|
batchSize: batchSize ?? null,
|
||||||
|
startingPosition: sourceParameters?.KinesisStreamParameters?.StartingPosition
|
||||||
|
?? sourceParameters?.DynamoDBStreamParameters?.StartingPosition
|
||||||
|
?? sourceParameters?.ManagedStreamingKafkaParameters?.StartingPosition
|
||||||
|
?? null,
|
||||||
|
topicName: sourceParameters?.ManagedStreamingKafkaParameters?.TopicName ?? null,
|
||||||
|
inputTemplate: detail.TargetParameters?.InputTemplate ?? null,
|
||||||
|
enrichmentInputTemplate: detail.EnrichmentParameters?.InputTemplate ?? null,
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A readable name for an ARN: `queue (SQS queue)`. The resource part differs by
|
||||||
|
* service — `function:name`, `stream/name`, `table/name/stream/…`, `cluster/name/uuid`.
|
||||||
|
*/
|
||||||
|
export function describeArn(arn: string | undefined): string | null {
|
||||||
|
if (!arn) return null
|
||||||
|
const parts = arn.split(':')
|
||||||
|
const service = parts[2] ?? ''
|
||||||
|
const resource = parts.slice(5).join(':')
|
||||||
|
const name = service === 'lambda' || service === 'states' ? resource.split(':')[1] ?? resource
|
||||||
|
: service === 'dynamodb' || service === 'kafka' || service === 'kinesis' || service === 'events' ? resource.split('/')[1] ?? resource
|
||||||
|
: resource
|
||||||
|
const kind = (PIPE_SOURCE_SERVICES as Record<string, string>)[service] ?? (PIPE_TARGET_SERVICES as Record<string, string>)[service]
|
||||||
|
return kind ? `${name} (${kind})` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function supported<T extends Record<string, string>>(arn: string, services: T, field: string): keyof T {
|
||||||
|
const service = arn.split(':')[2] ?? ''
|
||||||
|
if (!arn.startsWith('arn:') || !(service in services)) {
|
||||||
|
throw new ValidationError(`${field} must be the ARN of a ${Object.values(services).join(', ')}`)
|
||||||
|
}
|
||||||
|
return service as keyof T
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBatchSize(value: unknown): number | undefined {
|
||||||
|
const raw = text(value)
|
||||||
|
if (!raw) return undefined
|
||||||
|
const size = Number(raw)
|
||||||
|
if (!Number.isInteger(size) || size < 1 || size > MAX_BATCH_SIZE) throw new ValidationError(`batchSize must be a whole number from 1 to ${MAX_BATCH_SIZE}`)
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonObject(value: string, field: string): string {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
throw new ValidationError(`${field} must be JSON, such as {"body": {"kind": ["order"]}}`)
|
||||||
|
}
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new ValidationError(`${field} must be a JSON object`)
|
||||||
|
return JSON.stringify(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AthenaClient,
|
||||||
|
CreateWorkGroupCommand,
|
||||||
|
DeleteWorkGroupCommand,
|
||||||
|
GetQueryExecutionCommand,
|
||||||
|
GetQueryResultsCommand,
|
||||||
|
GetWorkGroupCommand,
|
||||||
|
ListDatabasesCommand,
|
||||||
|
ListQueryExecutionsCommand,
|
||||||
|
ListTableMetadataCommand,
|
||||||
|
ListWorkGroupsCommand,
|
||||||
|
StartQueryExecutionCommand,
|
||||||
|
StopQueryExecutionCommand,
|
||||||
|
} from '@aws-sdk/client-athena'
|
||||||
|
import {AwsQueryAdapter} from './AwsQueryAdapter'
|
||||||
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
|
||||||
|
type SendResult = Record<string, unknown>
|
||||||
|
|
||||||
|
function execution(id: string, overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
QueryExecutionId: id,
|
||||||
|
Query: 'SELECT name, amount FROM orders',
|
||||||
|
StatementType: 'DML',
|
||||||
|
WorkGroup: 'analysts',
|
||||||
|
QueryExecutionContext: {Database: 'shop', Catalog: 'AwsDataCatalog'},
|
||||||
|
ResultConfiguration: {OutputLocation: `s3://results/${id}.csv`},
|
||||||
|
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date('2026-10-25T21:00:00Z'), CompletionDateTime: new Date('2026-10-25T21:00:01Z')},
|
||||||
|
Statistics: {TotalExecutionTimeInMillis: 42, DataScannedInBytes: 0},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = {Data: [{VarCharValue: 'name'}, {VarCharValue: 'amount'}]}
|
||||||
|
const columnInfo = [{Name: 'name', Label: 'name', Type: 'varchar'}, {Name: 'amount', Label: 'amount', Type: 'double'}]
|
||||||
|
const row = (name: string, amount: string) => ({Data: [{VarCharValue: name}, {VarCharValue: amount}]})
|
||||||
|
|
||||||
|
function adapterWith(handler: (command: object) => SendResult | undefined = () => undefined) {
|
||||||
|
const sent: object[] = []
|
||||||
|
const client = {
|
||||||
|
async send(command: object) {
|
||||||
|
sent.push(command)
|
||||||
|
const result = handler(command)
|
||||||
|
if (result) return result
|
||||||
|
if (command instanceof GetQueryExecutionCommand) return {QueryExecution: execution(command.input.QueryExecutionId ?? '')}
|
||||||
|
if (command instanceof GetWorkGroupCommand) {
|
||||||
|
return {WorkGroup: {Name: command.input.WorkGroup, State: 'ENABLED', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/'}, EnforceWorkGroupConfiguration: true, EngineVersion: {EffectiveEngineVersion: 'Athena engine version 3'}}}}
|
||||||
|
}
|
||||||
|
if (command instanceof StartQueryExecutionCommand) return {QueryExecutionId: 'q-new'}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {sent, adapter: new AwsQueryAdapter(client as unknown as AthenaClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AwsQueryAdapter', () => {
|
||||||
|
test('lists workgroups across pages and describes one with its result location', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof ListWorkGroupsCommand
|
||||||
|
? command.input.NextToken ? {WorkGroups: [{Name: 'analysts', State: 'ENABLED', Description: 'Team'}]} : {WorkGroups: [{Name: 'primary', State: 'ENABLED'}], NextToken: 'n'}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
expect((await adapter.list()).map((wg) => [wg.name, wg.status])).toEqual([['primary', 'ENABLED'], ['analysts', 'ENABLED']])
|
||||||
|
expect((await adapter.list({search: 'team'})).map((wg) => wg.name)).toEqual(['analysts'])
|
||||||
|
expect((await adapter.get('analysts'))?.metadata).toEqual({description: 'Team', outputLocation: 's3://results/', enforceConfiguration: true, engineVersion: 'Athena engine version 3'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a workgroup with a result folder, and keeps the primary workgroup', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await adapter.create({values: {name: 'analysts', description: 'Team', outputLocation: 's3://results/athena', enforce: 'true'}})
|
||||||
|
expect((sent.find((command) => command instanceof CreateWorkGroupCommand) as CreateWorkGroupCommand).input).toEqual({
|
||||||
|
Name: 'analysts', Description: 'Team', Configuration: {ResultConfiguration: {OutputLocation: 's3://results/athena/'}, EnforceWorkGroupConfiguration: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(adapter.create({values: {name: 'primary'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.create({values: {name: 'bad name'}})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await expect(adapter.delete('primary')).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
await adapter.delete('analysts')
|
||||||
|
expect((sent.find((command) => command instanceof DeleteWorkGroupCommand) as DeleteWorkGroupCommand).input).toEqual({WorkGroup: 'analysts', RecursiveDeleteOption: true})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts a query in the workgroup against a database', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
const started = await adapter.startQuery('analysts', {sql: ' SELECT 1 ', database: 'shop'})
|
||||||
|
|
||||||
|
expect((sent.find((command) => command instanceof StartQueryExecutionCommand) as StartQueryExecutionCommand).input).toEqual({
|
||||||
|
QueryString: 'SELECT 1', WorkGroup: 'analysts', QueryExecutionContext: {Catalog: 'AwsDataCatalog', Database: 'shop'},
|
||||||
|
})
|
||||||
|
expect(started).toMatchObject({id: 'q-new', state: 'SUCCEEDED', runtimeMs: 42, outputLocation: 's3://results/q-new.csv'})
|
||||||
|
await expect(adapter.startQuery('analysts', {sql: ' '})).rejects.toBeInstanceOf(ValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a succeeded query with its columns and rows, without the header row', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => command instanceof GetQueryResultsCommand
|
||||||
|
? {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, row('alpha', '10'), row('beta', '25')]}}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.columns).toEqual([{name: 'name', type: 'varchar'}, {name: 'amount', type: 'double'}])
|
||||||
|
expect(result.rows).toEqual([['alpha', '10'], ['beta', '25']])
|
||||||
|
expect(result.truncated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stops reading after the row limit and says the result is truncated', async () => {
|
||||||
|
const page = Array.from({length: 1000}, (_, i) => row(`n${i}`, String(i)))
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryResultsCommand
|
||||||
|
? command.input.NextToken ? {ResultSet: {Rows: page}, NextToken: 'more'} : {ResultSet: {ResultSetMetadata: {ColumnInfo: columnInfo}, Rows: [header, ...page.slice(0, 999)]}, NextToken: 'p2'}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.rows).toHaveLength(1000)
|
||||||
|
expect(result.truncated).toBe(true)
|
||||||
|
expect(sent.filter((command) => command instanceof GetQueryResultsCommand)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not read results of a query that has not succeeded', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => command instanceof GetQueryExecutionCommand
|
||||||
|
? {QueryExecution: execution('q1', {Status: {State: 'FAILED', StateChangeReason: 'Table orders not found'}})}
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
const result = await adapter.getQueryResult('analysts', 'q1')
|
||||||
|
expect(result.execution).toMatchObject({state: 'FAILED', stateReason: 'Table orders not found'})
|
||||||
|
expect(result.rows).toEqual([])
|
||||||
|
expect(sent.some((command) => command instanceof GetQueryResultsCommand)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a query from another workgroup', async () => {
|
||||||
|
const {adapter, sent} = adapterWith()
|
||||||
|
await expect(adapter.getQueryResult('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
await expect(adapter.stopQuery('primary', 'q1')).rejects.toBeInstanceOf(NotFoundError)
|
||||||
|
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(false)
|
||||||
|
|
||||||
|
await adapter.stopQuery('analysts', 'q1')
|
||||||
|
expect(sent.some((command) => command instanceof StopQueryExecutionCommand)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists the workgroup queries newest first', async () => {
|
||||||
|
const {adapter} = adapterWith((command) => {
|
||||||
|
if (command instanceof ListQueryExecutionsCommand) return {QueryExecutionIds: ['old', 'new', 'other']}
|
||||||
|
if (command instanceof GetQueryExecutionCommand) {
|
||||||
|
const id = command.input.QueryExecutionId
|
||||||
|
return {QueryExecution: execution(id ?? '', {
|
||||||
|
WorkGroup: id === 'other' ? 'primary' : 'analysts',
|
||||||
|
Status: {State: 'SUCCEEDED', SubmissionDateTime: new Date(id === 'new' ? '2026-10-25T22:00:00Z' : '2026-10-25T21:00:00Z')},
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await adapter.listQueries('analysts')).map((query) => query.id)).toEqual(['new', 'old'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists databases and the tables of the chosen one', async () => {
|
||||||
|
const {adapter, sent} = adapterWith((command) => {
|
||||||
|
if (command instanceof ListDatabasesCommand) return {DatabaseList: [{Name: 'shop'}, {Name: 'logs'}]}
|
||||||
|
if (command instanceof ListTableMetadataCommand) {
|
||||||
|
return {TableMetadataList: [{Name: 'orders', Columns: [{Name: 'id', Type: 'int'}], PartitionKeys: [{Name: 'day', Type: 'string'}]}]}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await adapter.describeQueryCatalog('shop')).toEqual({databases: ['logs', 'shop'], database: 'shop', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'day', type: 'string'}]}]})
|
||||||
|
expect((await adapter.describeQueryCatalog('missing')).database).toBe('logs')
|
||||||
|
expect((sent.filter((command) => command instanceof ListTableMetadataCommand) as ListTableMetadataCommand[]).map((command) => command.input.DatabaseName)).toEqual(['shop', 'logs'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
type AthenaClient,
|
||||||
|
CreateWorkGroupCommand,
|
||||||
|
DeleteWorkGroupCommand,
|
||||||
|
GetQueryExecutionCommand,
|
||||||
|
GetQueryResultsCommand,
|
||||||
|
type GetQueryResultsCommandOutput,
|
||||||
|
GetWorkGroupCommand,
|
||||||
|
ListDatabasesCommand,
|
||||||
|
ListQueryExecutionsCommand,
|
||||||
|
ListTableMetadataCommand,
|
||||||
|
type ListTableMetadataCommandOutput,
|
||||||
|
ListWorkGroupsCommand,
|
||||||
|
type QueryExecution,
|
||||||
|
StartQueryExecutionCommand,
|
||||||
|
StopQueryExecutionCommand,
|
||||||
|
type TableMetadata,
|
||||||
|
type WorkGroup,
|
||||||
|
type WorkGroupSummary,
|
||||||
|
} from '@aws-sdk/client-athena'
|
||||||
|
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||||
|
import {
|
||||||
|
awsQuerySchema,
|
||||||
|
PRIMARY_WORKGROUP,
|
||||||
|
QUERY_HISTORY_LIMIT,
|
||||||
|
QUERY_RESULT_ROW_LIMIT,
|
||||||
|
WORKGROUP_NAME_PATTERN,
|
||||||
|
} from '../cloud-spi/querySchema'
|
||||||
|
import type {
|
||||||
|
CloudResource,
|
||||||
|
CloudServiceAdapter,
|
||||||
|
CreateResourceInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
ResourceQuery,
|
||||||
|
ServiceSchema,
|
||||||
|
} from '../cloud-spi/types'
|
||||||
|
|
||||||
|
/** Athena's own catalog name for the Glue Data Catalog. */
|
||||||
|
const DATA_CATALOG = 'AwsDataCatalog'
|
||||||
|
/** Athena's cap on a query string. */
|
||||||
|
const MAX_QUERY_LENGTH = 262_144
|
||||||
|
const S3_URI = /^s3:\/\/[a-z0-9.-]{3,63}(\/.*)?$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to Amazon Athena, verified against Floci core (community, `latest`).
|
||||||
|
*
|
||||||
|
* Notes from probing the runtime:
|
||||||
|
* - Queries really run: TheCloud executes the SQL with DuckDB over the files a
|
||||||
|
* Glue table points at, and writes the result to S3 as CSV, as Athena does.
|
||||||
|
* - Statements that return no rows, such as SHOW TABLES, fail in the runtime,
|
||||||
|
* so the editor lists databases and tables through the catalog APIs instead.
|
||||||
|
* - Data scanned is always reported as 0 bytes, so the editor shows only the
|
||||||
|
* run time.
|
||||||
|
*/
|
||||||
|
export class AwsQueryAdapter implements CloudServiceAdapter {
|
||||||
|
readonly cloud = 'aws' as const
|
||||||
|
readonly service = 'query' as const
|
||||||
|
|
||||||
|
constructor(private readonly athena: AthenaClient) {}
|
||||||
|
|
||||||
|
schema(): ServiceSchema {
|
||||||
|
return awsQuerySchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||||
|
const found: WorkGroupSummary[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.athena.send(new ListWorkGroupsCommand({MaxResults: 50, ...(next ? {NextToken: next} : {})}))
|
||||||
|
found.push(...(res.WorkGroups ?? []))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
|
||||||
|
const search = query.search?.trim().toLowerCase()
|
||||||
|
return found.map(toResource).filter((workGroup) => !search
|
||||||
|
|| workGroup.name.toLowerCase().includes(search)
|
||||||
|
|| String(workGroup.metadata.description ?? '').toLowerCase().includes(search))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<CloudResource | null> {
|
||||||
|
try {
|
||||||
|
const workGroup = (await this.athena.send(new GetWorkGroupCommand({WorkGroup: id}))).WorkGroup
|
||||||
|
return workGroup ? toResource(workGroup) : null
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'InvalidRequestException') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||||
|
const values = input.values
|
||||||
|
const name = text(values.name)
|
||||||
|
if (!new RegExp(WORKGROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be up to 128 letters, numbers, dots, dashes or underscores')
|
||||||
|
if (name === PRIMARY_WORKGROUP) throw new ValidationError('Every account already has the primary workgroup')
|
||||||
|
const outputLocation = text(values.outputLocation)
|
||||||
|
if (outputLocation && !S3_URI.test(outputLocation)) throw new ValidationError('outputLocation must be an S3 URI such as s3://my-bucket/athena-results/')
|
||||||
|
const description = text(values.description)
|
||||||
|
|
||||||
|
await this.athena.send(new CreateWorkGroupCommand({
|
||||||
|
Name: name,
|
||||||
|
...(description ? {Description: description} : {}),
|
||||||
|
Configuration: {
|
||||||
|
...(outputLocation ? {ResultConfiguration: {OutputLocation: outputLocation.endsWith('/') ? outputLocation : `${outputLocation}/`}} : {}),
|
||||||
|
EnforceWorkGroupConfiguration: values.enforce === 'true',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const created = await this.get(name)
|
||||||
|
if (!created) throw new RuntimeError(`Athena did not return the new workgroup ${name}`)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
if (id === PRIMARY_WORKGROUP) throw new ValidationError('The primary workgroup cannot be deleted')
|
||||||
|
await this.athena.send(new DeleteWorkGroupCommand({WorkGroup: id, RecursiveDeleteOption: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async startQuery(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
|
||||||
|
const sql = typeof input.sql === 'string' ? input.sql.trim() : ''
|
||||||
|
if (!sql) throw new ValidationError('Enter a query to run')
|
||||||
|
if (sql.length > MAX_QUERY_LENGTH) throw new ValidationError(`A query can be at most ${MAX_QUERY_LENGTH} characters`)
|
||||||
|
const database = text(input.database)
|
||||||
|
|
||||||
|
const started = await this.athena.send(new StartQueryExecutionCommand({
|
||||||
|
QueryString: sql,
|
||||||
|
WorkGroup: workGroup,
|
||||||
|
...(database ? {QueryExecutionContext: {Catalog: DATA_CATALOG, Database: database}} : {}),
|
||||||
|
}))
|
||||||
|
if (!started.QueryExecutionId) throw new RuntimeError('Athena did not return a query id')
|
||||||
|
return (await this.execution(workGroup, started.QueryExecutionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueryResult(workGroup: string, queryId: string): Promise<QueryResult> {
|
||||||
|
const execution = await this.execution(workGroup, queryId)
|
||||||
|
if (execution.state !== 'SUCCEEDED' || !execution.outputLocation) return {execution, columns: [], rows: [], truncated: false}
|
||||||
|
|
||||||
|
const pages: GetQueryResultsCommandOutput[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
let rowCount = 0
|
||||||
|
do {
|
||||||
|
const page = await this.athena.send(new GetQueryResultsCommand({QueryExecutionId: queryId, MaxResults: 1000, ...(next ? {NextToken: next} : {})}))
|
||||||
|
pages.push(page)
|
||||||
|
rowCount += page.ResultSet?.Rows?.length ?? 0
|
||||||
|
next = page.NextToken
|
||||||
|
} while (next && rowCount <= QUERY_RESULT_ROW_LIMIT)
|
||||||
|
|
||||||
|
const columnInfo = pages[0]?.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []
|
||||||
|
const raw = pages.flatMap((page) => page.ResultSet?.Rows ?? []).map((row) => (row.Data ?? []).map((cell) => cell.VarCharValue ?? null))
|
||||||
|
const header = raw[0] ?? []
|
||||||
|
const columns = columnInfo.length > 0
|
||||||
|
? columnInfo.map((column) => ({name: column.Label ?? column.Name ?? '', type: column.Type ?? null}))
|
||||||
|
: header.map((name) => ({name: name ?? '', type: null}))
|
||||||
|
// Athena repeats the column names as the first row of a SELECT's results.
|
||||||
|
const body = raw.length > 0 && header.length === columns.length && header.every((name, index) => name === columns[index].name) ? raw.slice(1) : raw
|
||||||
|
return {execution, columns, rows: body.slice(0, QUERY_RESULT_ROW_LIMIT), truncated: body.length > QUERY_RESULT_ROW_LIMIT || Boolean(next)}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopQuery(workGroup: string, queryId: string): Promise<void> {
|
||||||
|
await this.execution(workGroup, queryId)
|
||||||
|
await this.athena.send(new StopQueryExecutionCommand({QueryExecutionId: queryId}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueries(workGroup: string): Promise<QueryExecutionSummary[]> {
|
||||||
|
const ids = (await this.athena.send(new ListQueryExecutionsCommand({WorkGroup: workGroup, MaxResults: QUERY_HISTORY_LIMIT}))).QueryExecutionIds ?? []
|
||||||
|
const executions = await Promise.all(ids.slice(0, QUERY_HISTORY_LIMIT).map(async (id) => {
|
||||||
|
const res = await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: id}))
|
||||||
|
return res.QueryExecution ? toExecution(res.QueryExecution) : null
|
||||||
|
}))
|
||||||
|
return executions
|
||||||
|
.filter((execution): execution is QueryExecutionSummary => execution !== null && (execution.workGroup ?? PRIMARY_WORKGROUP) === workGroup)
|
||||||
|
.sort((a, b) => (b.submittedAt ?? '').localeCompare(a.submittedAt ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeQueryCatalog(database?: string): Promise<QueryCatalog> {
|
||||||
|
const databases: string[] = []
|
||||||
|
let next: string | undefined
|
||||||
|
do {
|
||||||
|
const res = await this.athena.send(new ListDatabasesCommand({CatalogName: DATA_CATALOG, MaxResults: 50, ...(next ? {NextToken: next} : {})}))
|
||||||
|
databases.push(...(res.DatabaseList ?? []).map((entry) => entry.Name ?? '').filter(Boolean))
|
||||||
|
next = res.NextToken
|
||||||
|
} while (next)
|
||||||
|
databases.sort()
|
||||||
|
|
||||||
|
const chosen = database && databases.includes(database) ? database : databases[0] ?? null
|
||||||
|
if (!chosen) return {databases, database: null, tables: []}
|
||||||
|
|
||||||
|
const tables: TableMetadata[] = []
|
||||||
|
let tableToken: string | undefined
|
||||||
|
do {
|
||||||
|
const res: ListTableMetadataCommandOutput = await this.athena.send(new ListTableMetadataCommand({CatalogName: DATA_CATALOG, DatabaseName: chosen, MaxResults: 50, ...(tableToken ? {NextToken: tableToken} : {})}))
|
||||||
|
tables.push(...(res.TableMetadataList ?? []))
|
||||||
|
tableToken = res.NextToken
|
||||||
|
} while (tableToken)
|
||||||
|
return {
|
||||||
|
databases,
|
||||||
|
database: chosen,
|
||||||
|
tables: tables.map((table) => ({
|
||||||
|
name: table.Name ?? '',
|
||||||
|
columns: [...(table.Columns ?? []), ...(table.PartitionKeys ?? [])].map((column) => ({name: column.Name ?? '', type: column.Type ?? ''})),
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A query, refused unless it ran in this workgroup, so one workgroup's page never shows another's. */
|
||||||
|
private async execution(workGroup: string, queryId: string): Promise<QueryExecutionSummary> {
|
||||||
|
let execution: QueryExecution | undefined
|
||||||
|
try {
|
||||||
|
execution = (await this.athena.send(new GetQueryExecutionCommand({QueryExecutionId: queryId}))).QueryExecution
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'InvalidRequestException') throw new NotFoundError(`Query ${queryId} not found`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!execution || (execution.WorkGroup ?? PRIMARY_WORKGROUP) !== workGroup) throw new NotFoundError(`Query ${queryId} is not in the ${workGroup} workgroup`)
|
||||||
|
return toExecution(execution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResource(workGroup: WorkGroup | WorkGroupSummary): CloudResource {
|
||||||
|
const name = workGroup.Name ?? ''
|
||||||
|
const detail = 'Configuration' in workGroup ? workGroup : undefined
|
||||||
|
const configuration = detail?.Configuration
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'query',
|
||||||
|
type: 'athenaWorkGroup',
|
||||||
|
region: null,
|
||||||
|
createdAt: workGroup.CreationTime ? workGroup.CreationTime.toISOString() : null,
|
||||||
|
status: workGroup.State ?? null,
|
||||||
|
metadata: {
|
||||||
|
description: workGroup.Description || null,
|
||||||
|
...(detail ? {
|
||||||
|
outputLocation: configuration?.ResultConfiguration?.OutputLocation ?? null,
|
||||||
|
enforceConfiguration: configuration?.EnforceWorkGroupConfiguration === true,
|
||||||
|
engineVersion: configuration?.EngineVersion?.EffectiveEngineVersion ?? configuration?.EngineVersion?.SelectedEngineVersion ?? null,
|
||||||
|
} : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toExecution(execution: QueryExecution): QueryExecutionSummary {
|
||||||
|
const status = execution.Status
|
||||||
|
return {
|
||||||
|
id: execution.QueryExecutionId ?? '',
|
||||||
|
sql: execution.Query ?? '',
|
||||||
|
database: execution.QueryExecutionContext?.Database ?? null,
|
||||||
|
workGroup: execution.WorkGroup ?? null,
|
||||||
|
state: status?.State ?? 'QUEUED',
|
||||||
|
stateReason: status?.StateChangeReason ?? status?.AthenaError?.ErrorMessage ?? null,
|
||||||
|
statementType: execution.StatementType ?? null,
|
||||||
|
submittedAt: status?.SubmissionDateTime ? status.SubmissionDateTime.toISOString() : null,
|
||||||
|
completedAt: status?.CompletionDateTime ? status.CompletionDateTime.toISOString() : null,
|
||||||
|
runtimeMs: execution.Statistics?.TotalExecutionTimeInMillis ?? execution.Statistics?.EngineExecutionTimeInMillis ?? null,
|
||||||
|
dataScannedBytes: execution.Statistics?.DataScannedInBytes ?? null,
|
||||||
|
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(_id: string): Promise<void> {
|
async delete(_id: string): Promise<void> {
|
||||||
throw new NotSupportedError('Deleting individual captured emails is not supported by Floci SES')
|
throw new NotSupportedError('Deleting individual captured emails is not supported by TheCloud SES')
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearEmailInbox(): Promise<void> {
|
async clearEmailInbox(): Promise<void> {
|
||||||
@@ -76,10 +76,10 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
|||||||
try {
|
try {
|
||||||
body = await response.json() as FlociSesInbox
|
body = await response.json() as FlociSesInbox
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeError('Floci SES inspection returned invalid JSON', {cause: error})
|
throw new RuntimeError('TheCloud SES inspection returned invalid JSON', {cause: error})
|
||||||
}
|
}
|
||||||
if (!Array.isArray(body.messages)) {
|
if (!Array.isArray(body.messages)) {
|
||||||
throw new RuntimeError('Floci SES inspection response did not contain messages')
|
throw new RuntimeError('TheCloud SES inspection response did not contain messages')
|
||||||
}
|
}
|
||||||
return body.messages
|
return body.messages
|
||||||
}
|
}
|
||||||
@@ -89,14 +89,14 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
|||||||
try {
|
try {
|
||||||
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
|
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeUnavailableError(`Cannot reach Floci SES at ${this.endpoint}`, {cause: error})
|
throw new RuntimeUnavailableError(`Cannot reach TheCloud SES at ${this.endpoint}`, {cause: error})
|
||||||
}
|
}
|
||||||
if (response.ok) return response
|
if (response.ok) return response
|
||||||
|
|
||||||
const detail = await response.text().catch(() => '')
|
const detail = await response.text().catch(() => '')
|
||||||
throw httpStatusToCloudError(
|
throw httpStatusToCloudError(
|
||||||
response.status,
|
response.status,
|
||||||
detail || `Floci SES inspection returned HTTP ${response.status}`,
|
detail || `TheCloud SES inspection returned HTTP ${response.status}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export class AzureAksAdapter implements CloudServiceAdapter {
|
|||||||
|
|
||||||
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
||||||
const id = body?.value?.[0]?.subscriptionId
|
const id = body?.value?.[0]?.subscriptionId
|
||||||
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope cluster requests to')
|
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope cluster requests to')
|
||||||
|
|
||||||
this.subscriptionId = id
|
this.subscriptionId = id
|
||||||
return id
|
return id
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export class AzureComputeAdapter implements CloudServiceAdapter {
|
|||||||
|
|
||||||
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
||||||
const id = body?.value?.[0]?.subscriptionId
|
const id = body?.value?.[0]?.subscriptionId
|
||||||
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope compute requests to')
|
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope compute requests to')
|
||||||
|
|
||||||
this.subscriptionId = id
|
this.subscriptionId = id
|
||||||
return id
|
return id
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class AzureServerlessAdapter implements CloudServiceAdapter {
|
|||||||
descriptorOverride(): CloudServiceDescriptorOverride {
|
descriptorOverride(): CloudServiceDescriptorOverride {
|
||||||
return {
|
return {
|
||||||
availability: 'coming_soon',
|
availability: 'coming_soon',
|
||||||
reason: 'The Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.',
|
reason: 'TheCloud\'s Azure runtime returns 501 NotImplemented for the Azure Functions endpoint.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import {CloudError, type CloudErrorStatus} from '../cloud-spi/errors'
|
|||||||
export class CosmosNoSqlUnavailableError extends CloudError {
|
export class CosmosNoSqlUnavailableError extends CloudError {
|
||||||
readonly status: CloudErrorStatus = 502
|
readonly status: CloudErrorStatus = 502
|
||||||
readonly code = 'cosmos_nosql_unavailable' as const
|
readonly code = 'cosmos_nosql_unavailable' as const
|
||||||
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime'
|
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Azure runtime'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function connectionConfig(connection: SqlDataConnection): MssqlConfig {
|
|||||||
options: {
|
options: {
|
||||||
encrypt: connection.tlsMode !== 'disable',
|
encrypt: connection.tlsMode !== 'disable',
|
||||||
trustServerCertificate: connection.tlsMode !== 'verify',
|
trustServerCertificate: connection.tlsMode !== 'verify',
|
||||||
appName: 'Floci UI',
|
appName: 'TheCloud',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class PostgresDataClient implements SqlDataClient {
|
|||||||
connectionTimeoutMillis: 10_000,
|
connectionTimeoutMillis: 10_000,
|
||||||
query_timeout: 30_000,
|
query_timeout: 30_000,
|
||||||
statement_timeout: 30_000,
|
statement_timeout: 30_000,
|
||||||
application_name: 'Floci UI',
|
application_name: 'TheCloud',
|
||||||
})
|
})
|
||||||
const startedAt = performance.now()
|
const startedAt = performance.now()
|
||||||
let phase: SqlDataPhase = 'connect'
|
let phase: SqlDataPhase = 'connect'
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function loginPage(c: Context, config: AuthConfig): Response {
|
|||||||
? `<form method="get" action="/api/auth/oidc/login"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><button class="sso-button" type="submit"><span class="ready"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.5 8 11 4.6-1.5 8-6 8-11V5l-8-3Zm0 4 4 1.5V11c0 3.2-1.9 6.2-4 7.4C9.9 17.2 8 14.2 8 11V7.5L12 6Z"/></svg>Sign in with Authentik</span><span class="loading"><i></i>Redirecting...</span></button></form>`
|
? `<form method="get" action="/api/auth/oidc/login"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><button class="sso-button" type="submit"><span class="ready"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.5 8 11 4.6-1.5 8-6 8-11V5l-8-3Zm0 4 4 1.5V11c0 3.2-1.9 6.2-4 7.4C9.9 17.2 8 14.2 8 11V7.5L12 6Z"/></svg>Sign in with Authentik</span><span class="loading"><i></i>Redirecting...</span></button></form>`
|
||||||
: `<form method="post" action="/api/auth/login"><input type="hidden" name="csrf" value="${csrf}"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="sso-button" type="submit">Sign in</button></form>`
|
: `<form method="post" action="/api/auth/login"><input type="hidden" name="csrf" value="${csrf}"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="sso-button" type="submit">Sign in</button></form>`
|
||||||
const error = loginErrorMessage(c.req.query('error'))
|
const error = loginErrorMessage(c.req.query('error'))
|
||||||
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>Floci DevTools</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
|
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>TheCloud</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginFailure(c: Context, status: 401 | 403 | 429): Response {
|
function loginFailure(c: Context, status: 401 | 403 | 429): Response {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
|
|||||||
import { IAMClient } from "@aws-sdk/client-iam";
|
import { IAMClient } from "@aws-sdk/client-iam";
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
|
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
|
||||||
|
import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2";
|
||||||
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
||||||
import { AppConfigClient } from "@aws-sdk/client-appconfig";
|
import { AppConfigClient } from "@aws-sdk/client-appconfig";
|
||||||
import { SNSClient } from "@aws-sdk/client-sns";
|
import { SNSClient } from "@aws-sdk/client-sns";
|
||||||
@@ -33,6 +34,12 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
|
|||||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||||
import { Route53Client } from "@aws-sdk/client-route-53";
|
import { Route53Client } from "@aws-sdk/client-route-53";
|
||||||
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
||||||
|
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
|
||||||
|
import { ACMClient } from "@aws-sdk/client-acm";
|
||||||
|
import { PipesClient } from "@aws-sdk/client-pipes";
|
||||||
|
import { EFSClient } from "@aws-sdk/client-efs";
|
||||||
|
import { GlueClient } from "@aws-sdk/client-glue";
|
||||||
|
import { AthenaClient } from "@aws-sdk/client-athena";
|
||||||
|
|
||||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||||
const region = process.env.AWS_REGION || "us-east-1";
|
const region = process.env.AWS_REGION || "us-east-1";
|
||||||
@@ -79,6 +86,7 @@ export type AwsClients = {
|
|||||||
iam: IAMClient;
|
iam: IAMClient;
|
||||||
dynamodb: DynamoDBClient;
|
dynamodb: DynamoDBClient;
|
||||||
apiGateway: APIGatewayClient;
|
apiGateway: APIGatewayClient;
|
||||||
|
apiGatewayV2: ApiGatewayV2Client;
|
||||||
cloudformation: CloudFormationClient;
|
cloudformation: CloudFormationClient;
|
||||||
appConfig: AppConfigClient;
|
appConfig: AppConfigClient;
|
||||||
sns: SNSClient;
|
sns: SNSClient;
|
||||||
@@ -97,6 +105,12 @@ export type AwsClients = {
|
|||||||
cognito: CognitoIdentityProviderClient;
|
cognito: CognitoIdentityProviderClient;
|
||||||
route53: Route53Client;
|
route53: Route53Client;
|
||||||
cloudfront: CloudFrontClient;
|
cloudfront: CloudFrontClient;
|
||||||
|
autoscaling: AutoScalingClient;
|
||||||
|
acm: ACMClient;
|
||||||
|
pipes: PipesClient;
|
||||||
|
efs: EFSClient;
|
||||||
|
glue: GlueClient;
|
||||||
|
athena: AthenaClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AwsClientName = keyof AwsClients;
|
export type AwsClientName = keyof AwsClients;
|
||||||
@@ -127,6 +141,7 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
iam: new IAMClient(base),
|
iam: new IAMClient(base),
|
||||||
dynamodb: new DynamoDBClient(base),
|
dynamodb: new DynamoDBClient(base),
|
||||||
apiGateway: new APIGatewayClient(base),
|
apiGateway: new APIGatewayClient(base),
|
||||||
|
apiGatewayV2: new ApiGatewayV2Client(base),
|
||||||
cloudformation: new CloudFormationClient(base),
|
cloudformation: new CloudFormationClient(base),
|
||||||
appConfig: new AppConfigClient(base),
|
appConfig: new AppConfigClient(base),
|
||||||
sns: new SNSClient(base),
|
sns: new SNSClient(base),
|
||||||
@@ -145,6 +160,12 @@ function buildClients(accountId: string): AwsClients {
|
|||||||
cognito: new CognitoIdentityProviderClient(base),
|
cognito: new CognitoIdentityProviderClient(base),
|
||||||
route53: new Route53Client(base),
|
route53: new Route53Client(base),
|
||||||
cloudfront: new CloudFrontClient(base),
|
cloudfront: new CloudFrontClient(base),
|
||||||
|
autoscaling: new AutoScalingClient(base),
|
||||||
|
acm: new ACMClient(base),
|
||||||
|
pipes: new PipesClient(base),
|
||||||
|
efs: new EFSClient(base),
|
||||||
|
glue: new GlueClient(base),
|
||||||
|
athena: new AthenaClient(base),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +202,7 @@ export const sqs = awsClients.sqs;
|
|||||||
export const iam = awsClients.iam;
|
export const iam = awsClients.iam;
|
||||||
export const dynamodb = awsClients.dynamodb;
|
export const dynamodb = awsClients.dynamodb;
|
||||||
export const apiGateway = awsClients.apiGateway;
|
export const apiGateway = awsClients.apiGateway;
|
||||||
|
export const apiGatewayV2 = awsClients.apiGatewayV2;
|
||||||
export const cloudformation = awsClients.cloudformation;
|
export const cloudformation = awsClients.cloudformation;
|
||||||
export const ecr = awsClients.ecr;
|
export const ecr = awsClients.ecr;
|
||||||
export const kinesis = awsClients.kinesis;
|
export const kinesis = awsClients.kinesis;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('AzureRestRuntimeClient', () => {
|
|||||||
const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1')
|
const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1')
|
||||||
|
|
||||||
await expect(client.fetch('/container?restype=container', {method: 'PUT'})).rejects.toThrow(
|
await expect(client.fetch('/container?restype=container', {method: 'PUT'})).rejects.toThrow(
|
||||||
'Cannot reach Floci-AZ at http://localhost:4577: connection refused',
|
"Cannot reach TheCloud's Azure runtime at http://localhost:4577: connection refused",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeUnavailableError(
|
throw new RuntimeUnavailableError(
|
||||||
`Cannot reach Floci-AZ at ${this.endpoint}: ${errorMessage(error)}`,
|
`Cannot reach TheCloud's Azure runtime at ${this.endpoint}: ${errorMessage(error)}`,
|
||||||
{cause: error},
|
{cause: error},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
|
import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types'
|
||||||
|
|
||||||
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
||||||
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
|
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
@@ -7,12 +7,42 @@ const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
|||||||
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
|
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const httpApiActions: CapabilitySchema<HttpApiActionName>[] = [
|
||||||
|
{name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
]
|
||||||
|
|
||||||
|
const httpOnly = {field: 'protocol', values: ['HTTP']}
|
||||||
|
const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']}
|
||||||
|
|
||||||
export function awsApiGatewaySchema(): ServiceSchema {
|
export function awsApiGatewaySchema(): ServiceSchema {
|
||||||
return {
|
return {
|
||||||
cloud: 'aws',
|
cloud: 'aws',
|
||||||
service: 'apigateway',
|
service: 'apigateway',
|
||||||
displayName: 'AWS API Gateway',
|
displayName: 'AWS API Gateway',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'protocol',
|
||||||
|
label: 'API type',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'REST',
|
||||||
|
options: [
|
||||||
|
{label: 'REST API (resources, methods and stages)', value: 'REST'},
|
||||||
|
{label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'},
|
||||||
|
{label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
label: 'API Name',
|
label: 'API Name',
|
||||||
@@ -30,20 +60,47 @@ export function awsApiGatewaySchema(): ServiceSchema {
|
|||||||
label: 'Description',
|
label: 'Description',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: false,
|
required: false,
|
||||||
description: 'Optional description for the REST API.',
|
description: 'Optional description for the API.',
|
||||||
validation: {
|
validation: {
|
||||||
maxLength: 1024,
|
maxLength: 1024,
|
||||||
message: 'Description must be 1024 characters or fewer.',
|
message: 'Description must be 1024 characters or fewer.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'functionName',
|
||||||
|
label: 'Lambda function',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
visibleWhen: httpOnly,
|
||||||
|
description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'routeSelectionExpression',
|
||||||
|
label: 'Route selection expression',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
defaultValue: '$request.body.action',
|
||||||
|
visibleWhen: webSocketOnly,
|
||||||
|
description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stageName',
|
||||||
|
label: 'Stage',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'dev',
|
||||||
|
visibleWhen: webSocketOnly,
|
||||||
|
description: 'Created with automatic deployment, so route changes go live right away.',
|
||||||
|
validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
actions: ['list', 'create', 'delete', 'inspect'],
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
capabilities: {resourceActions},
|
capabilities: {resourceActions, httpApiActions},
|
||||||
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
|
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'name', label: 'API Name'},
|
{name: 'name', label: 'API Name'},
|
||||||
{name: 'type', label: 'Type'},
|
{name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'},
|
||||||
{name: 'createdAt', label: 'Created At'},
|
{name: 'createdAt', label: 'Created At', format: 'relative'},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** CreateAutoScalingGroup's name rule: 1 to 255 characters, no colon. */
|
||||||
|
export const AUTO_SCALING_GROUP_NAME_PATTERN = '^[^:]{1,255}$'
|
||||||
|
/** A capacity the console accepts; AWS's own ceiling is far above what a laptop can run. */
|
||||||
|
export const AUTO_SCALING_MAX_INSTANCES = 20
|
||||||
|
|
||||||
|
const groupColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'capacity', label: 'Instances', path: 'metadata.capacity'},
|
||||||
|
{name: 'desired', label: 'Desired', path: 'metadata.desiredCapacity'},
|
||||||
|
{name: 'min', label: 'Min', path: 'metadata.minSize'},
|
||||||
|
{name: 'max', label: 'Max', path: 'metadata.maxSize'},
|
||||||
|
{name: 'launchTemplate', label: 'Launch template', path: 'metadata.launchTemplateName', emptyText: '—'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const groupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
const capacity = {pattern: '^\\d{1,2}$', message: `Use a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`}
|
||||||
|
|
||||||
|
export function awsAutoScalingSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'autoscaling',
|
||||||
|
displayName: 'EC2 Auto Scaling groups',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: AUTO_SCALING_GROUP_NAME_PATTERN, message: 'Use up to 255 characters, without a colon'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'launchTemplate',
|
||||||
|
label: 'Launch template',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
description: 'The name or ID (lt-…) of an EC2 launch template. New instances use its image and instance type.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'launchTemplateVersion',
|
||||||
|
label: 'Template version',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
defaultValue: '$Latest',
|
||||||
|
description: 'A version number, $Latest or $Default.',
|
||||||
|
validation: {pattern: '^(\\d+|\\$Latest|\\$Default)$', message: 'Use a version number, $Latest or $Default'},
|
||||||
|
},
|
||||||
|
{name: 'minSize', label: 'Minimum', type: 'text', required: true, defaultValue: '1', validation: capacity},
|
||||||
|
{name: 'desiredCapacity', label: 'Desired', type: 'text', required: false, defaultValue: '1', validation: capacity, description: 'How many instances to run now. Between the minimum and the maximum.'},
|
||||||
|
{name: 'maxSize', label: 'Maximum', type: 'text', required: true, defaultValue: '2', validation: capacity},
|
||||||
|
{
|
||||||
|
name: 'subnets',
|
||||||
|
label: 'Subnets',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Subnet IDs to launch into, separated by commas. Leave empty for the default subnets.',
|
||||||
|
validation: {pattern: '^\\s*subnet-[\\w-]+(\\s*,\\s*subnet-[\\w-]+)*\\s*$', message: 'Use subnet IDs separated by commas'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'targetGroupArns',
|
||||||
|
label: 'Target groups',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Optional load balancer target group ARNs, one per line. Instances are registered with them as they launch.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'healthCheckType',
|
||||||
|
label: 'Health check',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'EC2',
|
||||||
|
options: [
|
||||||
|
{label: 'EC2 instance status', value: 'EC2'},
|
||||||
|
{label: 'Load balancer health checks', value: 'ELB'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List Auto Scaling groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete group and its instances', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
autoScalingActions: [
|
||||||
|
{name: 'setCapacity', label: 'Change minimum, desired and maximum', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'terminateInstance', label: 'Terminate an instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: groupFilters,
|
||||||
|
columns: groupColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoScalingSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsAutoScalingSchema() : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** A domain name, optionally a wildcard (`*.example.com`), as RequestCertificate accepts it. */
|
||||||
|
export const CERTIFICATE_DOMAIN_PATTERN = '^(\\*\\.)?(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$'
|
||||||
|
|
||||||
|
/** The key algorithms RequestCertificate accepts for a public certificate. */
|
||||||
|
export const CERTIFICATE_KEY_ALGORITHMS = ['RSA_2048', 'EC_prime256v1', 'EC_secp384r1'] as const
|
||||||
|
export const CERTIFICATE_VALIDATION_METHODS = ['DNS', 'EMAIL'] as const
|
||||||
|
/** RevokeCertificate's reasons, as the API names them. */
|
||||||
|
export const CERTIFICATE_REVOCATION_REASONS = [
|
||||||
|
'UNSPECIFIED',
|
||||||
|
'KEY_COMPROMISE',
|
||||||
|
'CA_COMPROMISE',
|
||||||
|
'AFFILIATION_CHANGED',
|
||||||
|
'SUPERSEDED',
|
||||||
|
'CESSATION_OF_OPERATION',
|
||||||
|
'PRIVILEGE_WITHDRAWN',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const certificateColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Domain name'},
|
||||||
|
{name: 'status', label: 'Status', format: 'badge'},
|
||||||
|
{name: 'certificateType', label: 'Type', path: 'metadata.certificateType'},
|
||||||
|
{name: 'inUse', label: 'In use', path: 'metadata.inUse'},
|
||||||
|
{name: 'keyAlgorithm', label: 'Key algorithm', path: 'metadata.keyAlgorithm'},
|
||||||
|
{name: 'notAfter', label: 'Expires', path: 'metadata.notAfter', format: 'datetime', emptyText: '—'},
|
||||||
|
{name: 'id', label: 'Certificate ID', format: 'code'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const certificateFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
const requested = {field: 'source', values: ['request']}
|
||||||
|
const imported = {field: 'source', values: ['import']}
|
||||||
|
|
||||||
|
export function awsCertificatesSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'certificates',
|
||||||
|
displayName: 'AWS Certificate Manager',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'source',
|
||||||
|
label: 'Certificate type',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'request',
|
||||||
|
options: [
|
||||||
|
{label: 'Request a public certificate', value: 'request'},
|
||||||
|
{label: 'Import a certificate', value: 'import'},
|
||||||
|
],
|
||||||
|
span: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'domainName',
|
||||||
|
label: 'Domain name',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
requiredWhen: {field: 'source', equals: 'request'},
|
||||||
|
visibleWhen: requested,
|
||||||
|
description: 'For example notes.thecloud.localhost, or *.thecloud.localhost for every subdomain. TheCloud signs it with its own local certificate authority.',
|
||||||
|
validation: {pattern: CERTIFICATE_DOMAIN_PATTERN, message: 'Use a domain name such as example.com or *.example.com'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'subjectAlternativeNames',
|
||||||
|
label: 'Additional names',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
visibleWhen: requested,
|
||||||
|
description: 'Other names the certificate covers, one per line.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'validationMethod',
|
||||||
|
label: 'Validation method',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'DNS',
|
||||||
|
visibleWhen: requested,
|
||||||
|
description: 'How ACM proves you own the domain. TheCloud issues the certificate without checking, and still shows the DNS records AWS would ask for.',
|
||||||
|
options: CERTIFICATE_VALIDATION_METHODS.map((value) => ({label: value, value})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'keyAlgorithm',
|
||||||
|
label: 'Key algorithm',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'RSA_2048',
|
||||||
|
visibleWhen: requested,
|
||||||
|
options: CERTIFICATE_KEY_ALGORITHMS.map((value) => ({label: value.replace('_', ' '), value})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'export',
|
||||||
|
label: 'Exportable',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'DISABLED',
|
||||||
|
visibleWhen: requested,
|
||||||
|
description: 'An exportable certificate can be downloaded with its private key, to use on servers outside AWS. This is chosen once and cannot be changed later.',
|
||||||
|
options: [
|
||||||
|
{label: 'No', value: 'DISABLED'},
|
||||||
|
{label: 'Yes, allow export', value: 'ENABLED'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'certificate',
|
||||||
|
label: 'Certificate body (PEM)',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
requiredWhen: {field: 'source', equals: 'import'},
|
||||||
|
visibleWhen: imported,
|
||||||
|
span: true,
|
||||||
|
description: 'Starts with -----BEGIN CERTIFICATE-----.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'privateKey',
|
||||||
|
label: 'Private key (PEM)',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
requiredWhen: {field: 'source', equals: 'import'},
|
||||||
|
visibleWhen: imported,
|
||||||
|
span: true,
|
||||||
|
description: 'The unencrypted key that matches the certificate.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'certificateChain',
|
||||||
|
label: 'Certificate chain (PEM)',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
visibleWhen: imported,
|
||||||
|
span: true,
|
||||||
|
description: 'The intermediate certificates, if the certificate is not self-signed.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List certificates', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Request or import a certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
certificateActions: [
|
||||||
|
{name: 'download', label: 'Download the certificate and chain', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'export', label: 'Export with the private key', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'revoke', label: 'Revoke certificate', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: certificateFilters,
|
||||||
|
columns: certificateColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function certificatesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsCertificatesSchema() : null
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export function awsCodeBuildSchema(): ServiceSchema {
|
|||||||
{
|
{
|
||||||
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
|
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
|
||||||
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, S3: ''}},
|
defaultValueWhen: {field: 'sourceType', values: {NO_SOURCE: DEFAULT_DOCKER_TO_ECR_BUILDSPEC, S3: ''}},
|
||||||
description: 'For no source, this is a Floci Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
|
description: 'For no source, this is TheCloud\'s Node 20 Docker-to-ECR buildspec. For S3, leave blank to use buildspec.yml from the source, or provide an override. Include taskdef.json and appspec.yaml in the source for CodeDeployToECS.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS',
|
name: 'artifactsType', label: 'Artifact type', type: 'select', required: true, group: 'Artifacts', defaultValue: 'NO_ARTIFACTS',
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glue's rule for database and table names. Glue lowercases them, and Athena
|
||||||
|
* needs them to start with a letter or underscore to query them unquoted.
|
||||||
|
*/
|
||||||
|
export const CATALOG_NAME_PATTERN = '^[a-z_][a-z0-9_]{0,254}$'
|
||||||
|
/** The data formats TheCloud's Athena engine reads from a table's S3 location. */
|
||||||
|
export const CATALOG_TABLE_FORMATS = ['csv', 'json', 'parquet'] as const
|
||||||
|
/** Hive column types Athena understands and the console offers. */
|
||||||
|
export const CATALOG_COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)'] as const
|
||||||
|
|
||||||
|
const databaseColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'tables', label: 'Tables', path: 'metadata.tableCount', emptyText: '—'},
|
||||||
|
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
|
||||||
|
{name: 'location', label: 'Location', path: 'metadata.locationUri', emptyText: '—'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const databaseFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsDataCatalogSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'datacatalog',
|
||||||
|
displayName: 'Glue Data Catalog databases',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
description: 'Lowercase letters, numbers and underscores, starting with a letter or underscore, so Athena can query it.',
|
||||||
|
validation: {pattern: CATALOG_NAME_PATTERN, message: 'Use lowercase letters, numbers and underscores, starting with a letter or underscore'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 2048}},
|
||||||
|
{
|
||||||
|
name: 'locationUri',
|
||||||
|
label: 'Location',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Optional S3 location for the database, such as s3://my-bucket/warehouse/.',
|
||||||
|
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/warehouse/'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List databases', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create database', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete database', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect database', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
dataCatalogActions: [
|
||||||
|
{name: 'createTable', label: 'Create table', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteTable', label: 'Delete table', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: databaseFilters,
|
||||||
|
columns: databaseColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataCatalogSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsDataCatalogSchema() : null
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@ export function awsDatabaseSchema(): ServiceSchema {
|
|||||||
label: 'Create DB snapshot',
|
label: 'Create DB snapshot',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
status: 'partial',
|
status: 'partial',
|
||||||
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
|
reason: 'The Cloud Proxy operation is available, but the current runtime does not implement CreateDBSnapshot.',
|
||||||
runtimeRequired: true,
|
runtimeRequired: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** The Name tag EFS shows for a file system; AWS caps tag values at 256 characters. */
|
||||||
|
export const FILE_SYSTEM_NAME_PATTERN = '^[^\\x00-\\x1f]{1,256}$'
|
||||||
|
export const FILE_SYSTEM_PERFORMANCE_MODES = ['generalPurpose', 'maxIO'] as const
|
||||||
|
export const FILE_SYSTEM_THROUGHPUT_MODES = ['bursting', 'elastic'] as const
|
||||||
|
/** An access point's root: an absolute path of up to 100 characters. */
|
||||||
|
export const ACCESS_POINT_PATH_PATTERN = '^/[^\\x00]{0,99}$'
|
||||||
|
/** Lambda mounts file systems under /mnt only. */
|
||||||
|
export const LAMBDA_MOUNT_PATH_PATTERN = '^/mnt/[a-zA-Z0-9-_.]{1,160}$'
|
||||||
|
|
||||||
|
const fileSystemColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'id', label: 'File system ID', path: 'metadata.fileSystemId'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'mountTargets', label: 'Mount targets', path: 'metadata.numberOfMountTargets'},
|
||||||
|
{name: 'performanceMode', label: 'Performance', path: 'metadata.performanceMode'},
|
||||||
|
{name: 'encrypted', label: 'Encrypted', path: 'metadata.encrypted', format: 'boolean'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const fileSystemFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsFileSystemsSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'filesystems',
|
||||||
|
displayName: 'EFS file systems',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
description: 'Stored as the Name tag, as the AWS console does.',
|
||||||
|
validation: {pattern: FILE_SYSTEM_NAME_PATTERN, message: 'Use up to 256 characters'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mountTargets',
|
||||||
|
label: 'Mount targets',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'default',
|
||||||
|
description: 'Lambda functions and ECS tasks reach a file system through a mount target in their subnet.',
|
||||||
|
options: [
|
||||||
|
{label: 'One in each default subnet', value: 'default'},
|
||||||
|
{label: 'None for now', value: 'none'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'performanceMode',
|
||||||
|
label: 'Performance mode',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'generalPurpose',
|
||||||
|
options: [
|
||||||
|
{label: 'General Purpose', value: 'generalPurpose'},
|
||||||
|
{label: 'Max I/O', value: 'maxIO'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'throughputMode',
|
||||||
|
label: 'Throughput mode',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'bursting',
|
||||||
|
options: [
|
||||||
|
{label: 'Bursting', value: 'bursting'},
|
||||||
|
{label: 'Elastic', value: 'elastic'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'encrypted',
|
||||||
|
label: 'Encryption at rest',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [
|
||||||
|
{label: 'Encrypted', value: 'true'},
|
||||||
|
{label: 'Not encrypted', value: 'false'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List file systems', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create file system', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete file system', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect file system', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
fileSystemActions: [
|
||||||
|
{name: 'createMountTarget', label: 'Add mount target', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteMountTarget', label: 'Remove mount target', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'createAccessPoint', label: 'Create access point', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'deleteAccessPoint', label: 'Delete access point', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'attachFunction', label: 'Attach to a Lambda function', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'detachFunction', label: 'Detach from a Lambda function', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: fileSystemFilters,
|
||||||
|
columns: fileSystemColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileSystemsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsFileSystemsSchema() : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
export const ALARM_TYPES = ['metric', 'composite'] as const
|
||||||
|
export const ALARM_STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'] as const
|
||||||
|
export const ALARM_COMPARISONS = [
|
||||||
|
'GreaterThanThreshold',
|
||||||
|
'GreaterThanOrEqualToThreshold',
|
||||||
|
'LessThanThreshold',
|
||||||
|
'LessThanOrEqualToThreshold',
|
||||||
|
] as const
|
||||||
|
export const ALARM_PERIODS = ['60', '300', '900', '3600'] as const
|
||||||
|
export const ALARM_STATES = ['OK', 'ALARM', 'INSUFFICIENT_DATA'] as const
|
||||||
|
/** CloudWatch's alarm name rule: 1-255 characters, no control characters. */
|
||||||
|
export const ALARM_NAME_PATTERN = '^[^\\x00-\\x1f\\x7f]{1,255}$'
|
||||||
|
|
||||||
|
const alarmColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Alarm'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'alarmType', label: 'Type', path: 'metadata.alarmType'},
|
||||||
|
{name: 'condition', label: 'Condition', path: 'metadata.condition', format: 'code', emptyText: '—'},
|
||||||
|
{name: 'stateUpdatedAt', label: 'State changed', path: 'metadata.stateUpdatedAt', format: 'relative'},
|
||||||
|
{name: 'actionsEnabled', label: 'Actions', path: 'metadata.actionsEnabled', format: 'boolean'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const alarmFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
const metricOnly = {field: 'alarmType', values: ['metric']}
|
||||||
|
|
||||||
|
export function awsMonitoringSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'monitoring',
|
||||||
|
displayName: 'Amazon CloudWatch alarms',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'alarmType',
|
||||||
|
label: 'Alarm type',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'metric',
|
||||||
|
options: [
|
||||||
|
{label: 'Metric alarm (a threshold on one metric)', value: 'metric'},
|
||||||
|
{label: 'Composite alarm (a rule over other alarms)', value: 'composite'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Alarm name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: ALARM_NAME_PATTERN, message: 'Use up to 255 characters, without control characters'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false},
|
||||||
|
{name: 'namespace', label: 'Namespace', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example AWS/Lambda, AWS/SQS or your own, such as MyApp.'},
|
||||||
|
{name: 'metricName', label: 'Metric name', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example Errors or Duration.'},
|
||||||
|
{
|
||||||
|
name: 'dimensions',
|
||||||
|
label: 'Dimensions',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
visibleWhen: metricOnly,
|
||||||
|
description: 'One Name=Value per line, for example FunctionName=checkout. Leave empty for the metric across all dimensions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'statistic',
|
||||||
|
label: 'Statistic',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'Average',
|
||||||
|
visibleWhen: metricOnly,
|
||||||
|
options: ALARM_STATISTICS.map((value) => ({label: value, value})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'period',
|
||||||
|
label: 'Period',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: '300',
|
||||||
|
visibleWhen: metricOnly,
|
||||||
|
options: [{label: '1 minute', value: '60'}, {label: '5 minutes', value: '300'}, {label: '15 minutes', value: '900'}, {label: '1 hour', value: '3600'}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'comparison',
|
||||||
|
label: 'Condition',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'GreaterThanThreshold',
|
||||||
|
visibleWhen: metricOnly,
|
||||||
|
options: [
|
||||||
|
{label: 'Greater than the threshold', value: 'GreaterThanThreshold'},
|
||||||
|
{label: 'Greater than or equal to the threshold', value: 'GreaterThanOrEqualToThreshold'},
|
||||||
|
{label: 'Less than the threshold', value: 'LessThanThreshold'},
|
||||||
|
{label: 'Less than or equal to the threshold', value: 'LessThanOrEqualToThreshold'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{name: 'threshold', label: 'Threshold', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, validation: {pattern: '^-?\\d+(\\.\\d+)?$', message: 'Use a number'}},
|
||||||
|
{name: 'evaluationPeriods', label: 'Periods to breach', type: 'text', required: false, defaultValue: '1', visibleWhen: metricOnly, description: 'How many periods in a row must breach before the alarm fires.', validation: {pattern: '^[1-9]\\d{0,2}$', message: 'Use a whole number from 1'}},
|
||||||
|
{
|
||||||
|
name: 'alarmRule',
|
||||||
|
label: 'Rule',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
requiredWhen: {field: 'alarmType', equals: 'composite'},
|
||||||
|
visibleWhen: {field: 'alarmType', values: ['composite']},
|
||||||
|
description: 'For example ALARM("high-errors") OR ALARM("slow-checkout"). AND, OR, NOT and parentheses work; OK() and INSUFFICIENT_DATA() too.',
|
||||||
|
},
|
||||||
|
{name: 'topicArn', label: 'Notify SNS topic', type: 'text', required: false, description: 'Optional. An SNS topic ARN told when the alarm fires and when it recovers.'},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List alarms', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
monitoringActions: [
|
||||||
|
{name: 'listMetrics', label: 'Browse metrics', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'getMetricData', label: 'Chart a metric', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'alarmHistory', label: 'Show alarm history', enabled: false, status: 'blocked', reason: "TheCloud's runtime does not implement DescribeAlarmHistory yet.", runtimeRequired: true},
|
||||||
|
{name: 'setAlarmState', label: 'Set an alarm state to test its actions', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'setAlarmActions', label: 'Turn alarm actions on or off', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: alarmFilters,
|
||||||
|
columns: alarmColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monitoringSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsMonitoringSchema() : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** CreatePipe's name rule. */
|
||||||
|
export const PIPE_NAME_PATTERN = '^[\\.\\-_A-Za-z0-9]{1,64}$'
|
||||||
|
export const PIPE_STARTING_POSITIONS = ['TRIM_HORIZON', 'LATEST'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sources and targets the runtime's pipe poller actually moves events
|
||||||
|
* between, keyed by the ARN's service field. Anything else is refused on create
|
||||||
|
* rather than accepted into a pipe that would never deliver.
|
||||||
|
*/
|
||||||
|
export const PIPE_SOURCE_SERVICES = {sqs: 'SQS queue', kinesis: 'Kinesis stream', dynamodb: 'DynamoDB stream', kafka: 'MSK topic'} as const
|
||||||
|
export const PIPE_TARGET_SERVICES = {lambda: 'Lambda function', sqs: 'SQS queue', sns: 'SNS topic', events: 'EventBridge event bus', states: 'Step Functions state machine'} as const
|
||||||
|
export const PIPE_ENRICHMENT_SERVICES = {lambda: 'Lambda function', states: 'Step Functions state machine'} as const
|
||||||
|
|
||||||
|
const ARN = '^arn:aws[a-z-]*:[a-z0-9-]+:[a-z0-9-]*:\\d{12}:.+$'
|
||||||
|
|
||||||
|
const pipeColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'source', label: 'Source', path: 'metadata.sourceName'},
|
||||||
|
{name: 'target', label: 'Target', path: 'metadata.targetName'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const pipeFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsPipesSchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'pipes',
|
||||||
|
displayName: 'EventBridge Pipes',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: PIPE_NAME_PATTERN, message: 'Use up to 64 letters, numbers, dots, dashes and underscores'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 512}},
|
||||||
|
{
|
||||||
|
name: 'source',
|
||||||
|
label: 'Source ARN',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
span: true,
|
||||||
|
description: 'Where events come from: an SQS queue, a Kinesis stream, a DynamoDB stream or an MSK cluster.',
|
||||||
|
validation: {pattern: ARN, message: 'Use the ARN of a queue or stream'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'startingPosition',
|
||||||
|
label: 'Starting position',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'LATEST',
|
||||||
|
description: 'For streams and MSK: read new records only, or everything still in the stream.',
|
||||||
|
options: [
|
||||||
|
{label: 'New records only (LATEST)', value: 'LATEST'},
|
||||||
|
{label: 'Oldest record (TRIM_HORIZON)', value: 'TRIM_HORIZON'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'topicName',
|
||||||
|
label: 'MSK topic',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
description: 'Required when the source is an MSK cluster.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'batchSize',
|
||||||
|
label: 'Batch size',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
description: 'Records sent to the target at once. 1 to 10,000; leave empty for the default of 10.',
|
||||||
|
validation: {pattern: '^[1-9][0-9]{0,4}$', message: 'Use a whole number from 1 to 10000'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'filterPattern',
|
||||||
|
label: 'Filter pattern',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Optional EventBridge event pattern; only matching records go on. For SQS, match the parsed message under body, for example {"body": {"kind": ["order"]}}.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enrichment',
|
||||||
|
label: 'Enrichment ARN',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Optional Lambda function or Step Functions state machine that transforms each batch before the target gets it.',
|
||||||
|
validation: {pattern: ARN, message: 'Use the ARN of a Lambda function or state machine'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'target',
|
||||||
|
label: 'Target ARN',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
span: true,
|
||||||
|
description: 'Where events go: a Lambda function, SQS queue, SNS topic, EventBridge event bus or Step Functions state machine.',
|
||||||
|
validation: {pattern: ARN, message: 'Use the ARN of the target'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'inputTemplate',
|
||||||
|
label: 'Target input template',
|
||||||
|
type: 'textarea',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Optional. Reshape what the target receives, with <$.path> placeholders, for example {"id": <$.messageId>}.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'roleArn',
|
||||||
|
label: 'Execution role ARN',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
span: true,
|
||||||
|
description: 'The IAM role the pipe uses to read the source and call the target, as AWS requires. Create one in IAM first.',
|
||||||
|
validation: {pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$', message: 'Use an IAM role ARN such as arn:aws:iam::000000000000:role/pipe-role'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'desiredState',
|
||||||
|
label: 'State after creating',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'RUNNING',
|
||||||
|
options: [
|
||||||
|
{label: 'Running', value: 'RUNNING'},
|
||||||
|
{label: 'Stopped', value: 'STOPPED'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List pipes', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'start', label: 'Start pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'stop', label: 'Stop pipe', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: pipeFilters,
|
||||||
|
columns: pipeColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsPipesSchema() : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||||
|
|
||||||
|
/** CreateWorkGroup's name rule. */
|
||||||
|
export const WORKGROUP_NAME_PATTERN = '^[a-zA-Z0-9._-]{1,128}$'
|
||||||
|
/** The workgroup every account has, which Athena does not let you delete. */
|
||||||
|
export const PRIMARY_WORKGROUP = 'primary'
|
||||||
|
/** Rows the editor shows for one query; the full result stays in the CSV file in S3. */
|
||||||
|
export const QUERY_RESULT_ROW_LIMIT = 1000
|
||||||
|
/** Recent queries the history shows. */
|
||||||
|
export const QUERY_HISTORY_LIMIT = 25
|
||||||
|
|
||||||
|
const workGroupColumns: TableColumnSchema[] = [
|
||||||
|
{name: 'name', label: 'Name'},
|
||||||
|
{name: 'status', label: 'State', format: 'badge'},
|
||||||
|
{name: 'description', label: 'Description', path: 'metadata.description', emptyText: '—'},
|
||||||
|
{name: 'createdAt', label: 'Created', format: 'datetime', emptyText: '—'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const workGroupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||||
|
|
||||||
|
export function awsQuerySchema(): ServiceSchema {
|
||||||
|
return {
|
||||||
|
cloud: 'aws',
|
||||||
|
service: 'query',
|
||||||
|
displayName: 'Athena workgroups',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
validation: {pattern: WORKGROUP_NAME_PATTERN, message: 'Use up to 128 letters, numbers, dots, dashes and underscores'},
|
||||||
|
},
|
||||||
|
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 1024}},
|
||||||
|
{
|
||||||
|
name: 'outputLocation',
|
||||||
|
label: 'Query result location',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
span: true,
|
||||||
|
description: 'Where each query writes its results as a CSV file, such as s3://my-bucket/athena-results/. Leave empty to use TheCloud\'s default results bucket.',
|
||||||
|
validation: {pattern: '^s3://[a-z0-9.-]{3,63}(/.*)?$', message: 'Use an S3 URI such as s3://my-bucket/athena-results/'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enforce',
|
||||||
|
label: 'Override client-side settings',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'false',
|
||||||
|
description: 'When on, queries always use this workgroup\'s result location, even if a client asks for another.',
|
||||||
|
options: [
|
||||||
|
{label: 'Off', value: 'false'},
|
||||||
|
{label: 'On', value: 'true'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: ['list', 'create', 'delete', 'inspect'],
|
||||||
|
capabilities: {
|
||||||
|
resourceActions: [
|
||||||
|
{name: 'list', label: 'List workgroups', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'create', label: 'Create workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'delete', label: 'Delete workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'inspect', label: 'Inspect workgroup', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
queryActions: [
|
||||||
|
{name: 'run', label: 'Run query', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'results', label: 'Get query results', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'stop', label: 'Cancel query', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'history', label: 'Recent queries', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
{name: 'catalog', label: 'Browse databases and tables', enabled: true, status: 'available', runtimeRequired: true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filters: workGroupFilters,
|
||||||
|
columns: workGroupColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function querySchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||||
|
return cloud === 'aws' ? awsQuerySchema() : null
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ export const SERVICE_CATALOG = {
|
|||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
|
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
|
||||||
|
autoscaling: {
|
||||||
|
displayName: 'Auto Scaling',
|
||||||
|
displayNameByCloud: {aws: 'EC2 Auto Scaling'},
|
||||||
|
iconKey: 'autoscaling',
|
||||||
|
group: 'Compute',
|
||||||
|
order: 15,
|
||||||
|
},
|
||||||
containers: {
|
containers: {
|
||||||
displayName: 'Containers',
|
displayName: 'Containers',
|
||||||
displayNameByCloud: {gcp: 'Cloud Run'},
|
displayNameByCloud: {gcp: 'Cloud Run'},
|
||||||
@@ -125,6 +132,13 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Storage',
|
group: 'Storage',
|
||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
|
filesystems: {
|
||||||
|
displayName: 'File Systems',
|
||||||
|
displayNameByCloud: {aws: 'EFS'},
|
||||||
|
iconKey: 'filesystems',
|
||||||
|
group: 'Storage',
|
||||||
|
order: 30,
|
||||||
|
},
|
||||||
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
|
database: {displayName: 'Database', iconKey: 'database', group: 'Databases', order: 10},
|
||||||
nosql: {
|
nosql: {
|
||||||
displayName: 'NoSQL',
|
displayName: 'NoSQL',
|
||||||
@@ -208,6 +222,20 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Analytics',
|
group: 'Analytics',
|
||||||
order: 18,
|
order: 18,
|
||||||
},
|
},
|
||||||
|
datacatalog: {
|
||||||
|
displayName: 'Data Catalog',
|
||||||
|
displayNameByCloud: {aws: 'Glue Data Catalog'},
|
||||||
|
iconKey: 'datacatalog',
|
||||||
|
group: 'Analytics',
|
||||||
|
order: 20,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
displayName: 'Query',
|
||||||
|
displayNameByCloud: {aws: 'Athena'},
|
||||||
|
iconKey: 'query',
|
||||||
|
group: 'Analytics',
|
||||||
|
order: 21,
|
||||||
|
},
|
||||||
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
|
identity: {displayName: 'Identity', iconKey: 'iam', group: 'Security', order: 5},
|
||||||
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
|
apigateway: {displayName: 'API Gateway', iconKey: 'apigateway', group: 'Integration', order: 10},
|
||||||
email: {
|
email: {
|
||||||
@@ -239,6 +267,13 @@ export const SERVICE_CATALOG = {
|
|||||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||||
routeByCloud: {aws: '/secretsmanager'},
|
routeByCloud: {aws: '/secretsmanager'},
|
||||||
},
|
},
|
||||||
|
certificates: {
|
||||||
|
displayName: 'Certificates',
|
||||||
|
displayNameByCloud: {aws: 'Certificate Manager'},
|
||||||
|
iconKey: 'certificates',
|
||||||
|
group: 'Security',
|
||||||
|
order: 35,
|
||||||
|
},
|
||||||
cdn: {
|
cdn: {
|
||||||
displayName: 'CDN',
|
displayName: 'CDN',
|
||||||
displayNameByCloud: {aws: 'CloudFront'},
|
displayNameByCloud: {aws: 'CloudFront'},
|
||||||
@@ -274,6 +309,13 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Provisioning',
|
group: 'Provisioning',
|
||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
|
pipes: {
|
||||||
|
displayName: 'Pipes',
|
||||||
|
displayNameByCloud: {aws: 'EventBridge Pipes'},
|
||||||
|
iconKey: 'pipes',
|
||||||
|
group: 'Integration',
|
||||||
|
order: 25,
|
||||||
|
},
|
||||||
scheduler: {
|
scheduler: {
|
||||||
displayName: 'Cloud Scheduler',
|
displayName: 'Cloud Scheduler',
|
||||||
displayNameByCloud: {gcp: 'Cloud Scheduler'},
|
displayNameByCloud: {gcp: 'Cloud Scheduler'},
|
||||||
@@ -281,6 +323,13 @@ export const SERVICE_CATALOG = {
|
|||||||
group: 'Integration',
|
group: 'Integration',
|
||||||
order: 20,
|
order: 20,
|
||||||
},
|
},
|
||||||
|
monitoring: {
|
||||||
|
displayName: 'Monitoring',
|
||||||
|
displayNameByCloud: {aws: 'CloudWatch'},
|
||||||
|
iconKey: 'monitoring',
|
||||||
|
group: 'Observability',
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
displayName: 'Logs',
|
displayName: 'Logs',
|
||||||
displayNameByCloud: {aws: 'CloudWatch Logs'},
|
displayNameByCloud: {aws: 'CloudWatch Logs'},
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
|
|||||||
iconKey: string
|
iconKey: string
|
||||||
group: ServiceGroup
|
group: ServiceGroup
|
||||||
order: number
|
order: number
|
||||||
|
/**
|
||||||
|
* Whether the generic resource list serves it. False for a service still on its
|
||||||
|
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
|
||||||
|
*/
|
||||||
|
listable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
|
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
|
||||||
@@ -181,6 +186,25 @@ export type UserPoolActionName =
|
|||||||
| 'describeClient'
|
| 'describeClient'
|
||||||
| 'updateClientOAuth'
|
| 'updateClientOAuth'
|
||||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||||
|
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||||
|
export type CertificateActionName = 'download' | 'export' | 'revoke'
|
||||||
|
export type QueryActionName = 'run' | 'results' | 'stop' | 'history' | 'catalog'
|
||||||
|
export type DataCatalogActionName = 'createTable' | 'deleteTable'
|
||||||
|
export type FileSystemActionName = 'createMountTarget' | 'deleteMountTarget' | 'createAccessPoint' | 'deleteAccessPoint' | 'attachFunction' | 'detachFunction'
|
||||||
|
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||||
|
export type HttpApiActionName =
|
||||||
|
| 'describe'
|
||||||
|
| 'createRoute'
|
||||||
|
| 'deleteRoute'
|
||||||
|
| 'setRouteAuthorizer'
|
||||||
|
| 'createStage'
|
||||||
|
| 'deleteStage'
|
||||||
|
| 'deploy'
|
||||||
|
| 'createAuthorizer'
|
||||||
|
| 'deleteAuthorizer'
|
||||||
|
| 'updateCors'
|
||||||
|
| 'sendRequest'
|
||||||
|
| 'testWebSocket'
|
||||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||||
|
|
||||||
export interface CapabilitySchema<TAction extends string> {
|
export interface CapabilitySchema<TAction extends string> {
|
||||||
@@ -226,6 +250,14 @@ export interface ServiceSchema {
|
|||||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||||
|
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
||||||
|
certificateActions?: CapabilitySchema<CertificateActionName>[]
|
||||||
|
fileSystemActions?: CapabilitySchema<FileSystemActionName>[]
|
||||||
|
dataCatalogActions?: CapabilitySchema<DataCatalogActionName>[]
|
||||||
|
queryActions?: CapabilitySchema<QueryActionName>[]
|
||||||
|
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||||
|
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||||
|
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||||
// Child levels advertise separately from the resource level because a
|
// Child levels advertise separately from the resource level because a
|
||||||
// store can be readable at the leaf and writable at the collection —
|
// store can be readable at the leaf and writable at the collection —
|
||||||
// CloudWatch Logs creates streams but cannot delete an event.
|
// CloudWatch Logs creates streams but cannot delete an event.
|
||||||
@@ -457,6 +489,140 @@ export interface UserPoolSignInTestInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
|
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
|
||||||
|
export interface MetricDimension {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSummary {
|
||||||
|
namespace: string
|
||||||
|
metricName: string
|
||||||
|
dimensions: MetricDimension[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSeriesQuery {
|
||||||
|
namespace: string
|
||||||
|
metricName: string
|
||||||
|
dimensions?: MetricDimension[]
|
||||||
|
statistic?: string
|
||||||
|
periodSeconds?: number
|
||||||
|
/** How far back from now, in minutes. */
|
||||||
|
rangeMinutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSeries {
|
||||||
|
label: string
|
||||||
|
statistic: string
|
||||||
|
periodSeconds: number
|
||||||
|
points: {timestamp: string; value: number}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||||
|
|
||||||
|
export interface AlarmHistoryItem {
|
||||||
|
timestamp: string | null
|
||||||
|
type: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A certificate and its chain as PEM text; never the private key. */
|
||||||
|
export interface CertificatePem {
|
||||||
|
certificate: string
|
||||||
|
certificateChain: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An exported certificate: the private key is PEM, encrypted with the caller's passphrase. */
|
||||||
|
export interface ExportedCertificate extends CertificatePem {
|
||||||
|
privateKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CertificateRevocationReason =
|
||||||
|
| 'UNSPECIFIED'
|
||||||
|
| 'KEY_COMPROMISE'
|
||||||
|
| 'CA_COMPROMISE'
|
||||||
|
| 'AFFILIATION_CHANGED'
|
||||||
|
| 'SUPERSEDED'
|
||||||
|
| 'CESSATION_OF_OPERATION'
|
||||||
|
| 'PRIVILEGE_WITHDRAWN'
|
||||||
|
|
||||||
|
/** Any of the three may be left out to keep its current value. */
|
||||||
|
export interface AutoScalingCapacityInput {
|
||||||
|
minSize?: number
|
||||||
|
desiredCapacity?: number
|
||||||
|
maxSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessPointInput {
|
||||||
|
name?: string
|
||||||
|
/** Where the access point's root sits inside the file system; created on first use. */
|
||||||
|
path: string
|
||||||
|
uid: number
|
||||||
|
gid: number
|
||||||
|
/** Octal permissions for the root directory when EFS creates it, such as 755. */
|
||||||
|
permissions: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryInput {
|
||||||
|
sql: string
|
||||||
|
/** The Glue database unqualified table names resolve in. */
|
||||||
|
database?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecutionSummary {
|
||||||
|
id: string
|
||||||
|
sql: string
|
||||||
|
database: string | null
|
||||||
|
workGroup: string | null
|
||||||
|
/** QUEUED, RUNNING, SUCCEEDED, FAILED or CANCELLED. */
|
||||||
|
state: string
|
||||||
|
stateReason: string | null
|
||||||
|
statementType: string | null
|
||||||
|
submittedAt: string | null
|
||||||
|
completedAt: string | null
|
||||||
|
runtimeMs: number | null
|
||||||
|
dataScannedBytes: number | null
|
||||||
|
/** The S3 object holding the full result as CSV. */
|
||||||
|
outputLocation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
execution: QueryExecutionSummary
|
||||||
|
columns: Array<{name: string; type: string | null}>
|
||||||
|
rows: Array<Array<string | null>>
|
||||||
|
/** More rows exist than the console returns; the full result is in the output file. */
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryCatalog {
|
||||||
|
databases: string[]
|
||||||
|
database: string | null
|
||||||
|
tables: Array<{name: string; columns: Array<{name: string; type: string}>}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogColumnInput {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogTableInput {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
/** The S3 prefix holding the table's files, such as s3://bucket/orders/. */
|
||||||
|
location: string
|
||||||
|
format: 'csv' | 'json' | 'parquet'
|
||||||
|
/** CSV only: the field separator, a comma unless given. */
|
||||||
|
delimiter?: string
|
||||||
|
/** CSV only: whether the first line of each file is a header to skip. */
|
||||||
|
headerRow?: boolean
|
||||||
|
columns: CatalogColumnInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemAttachmentInput {
|
||||||
|
accessPointId: string
|
||||||
|
functionName: string
|
||||||
|
mountPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionInvalidation {
|
export interface DistributionInvalidation {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
@@ -731,6 +897,107 @@ export interface ApiGatewayCorsResult {
|
|||||||
proxyMethods: string[]
|
proxyMethods: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET'
|
||||||
|
|
||||||
|
export interface HttpApiIntegrationOverview {
|
||||||
|
integrationId: string
|
||||||
|
type: string
|
||||||
|
uri: string | null
|
||||||
|
/** The Lambda function a proxy integration invokes, when it is one. */
|
||||||
|
functionName: string | null
|
||||||
|
payloadFormatVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiRouteOverview {
|
||||||
|
routeId: string
|
||||||
|
/** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */
|
||||||
|
routeKey: string
|
||||||
|
integration: HttpApiIntegrationOverview | null
|
||||||
|
authorizationType: string
|
||||||
|
authorizerId: string | null
|
||||||
|
authorizationScopes: string[]
|
||||||
|
/** WebSocket only: whether the integration's response is sent back to the client. */
|
||||||
|
returnsResponse: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiStageOverview {
|
||||||
|
stageName: string
|
||||||
|
autoDeploy: boolean
|
||||||
|
deploymentId: string | null
|
||||||
|
lastUpdatedAt: string | null
|
||||||
|
/** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */
|
||||||
|
invokePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiAuthorizerOverview {
|
||||||
|
authorizerId: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
identitySource: string[]
|
||||||
|
issuer: string | null
|
||||||
|
audience: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiCors {
|
||||||
|
allowOrigins: string[]
|
||||||
|
allowMethods: string[]
|
||||||
|
allowHeaders: string[]
|
||||||
|
exposeHeaders: string[]
|
||||||
|
maxAgeSeconds: number | null
|
||||||
|
allowCredentials: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiOverview {
|
||||||
|
apiId: string
|
||||||
|
protocol: HttpApiProtocol
|
||||||
|
apiEndpoint: string | null
|
||||||
|
routeSelectionExpression: string | null
|
||||||
|
routes: HttpApiRouteOverview[]
|
||||||
|
stages: HttpApiStageOverview[]
|
||||||
|
authorizers: HttpApiAuthorizerOverview[]
|
||||||
|
cors: HttpApiCors | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiRouteInput {
|
||||||
|
routeKey: string
|
||||||
|
/** A Lambda function name; exactly one of functionName and httpUrl. */
|
||||||
|
functionName?: string
|
||||||
|
/** An HTTP backend the route proxies to; HTTP APIs only. */
|
||||||
|
httpUrl?: string
|
||||||
|
authorizerId?: string
|
||||||
|
/** WebSocket only: send the integration's response back to the client. */
|
||||||
|
returnsResponse?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiStageInput {
|
||||||
|
stageName: string
|
||||||
|
autoDeploy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiAuthorizerInput {
|
||||||
|
name: string
|
||||||
|
/** Fill the issuer and audience from a Cognito user pool and one of its app clients. */
|
||||||
|
userPoolId?: string
|
||||||
|
userPoolClientId?: string
|
||||||
|
issuer?: string
|
||||||
|
audience?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiWebSocketTestInput {
|
||||||
|
stage: string
|
||||||
|
message: string
|
||||||
|
/** Sent as query string parameters on the connection request, for a $connect authorizer. */
|
||||||
|
query?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiWebSocketTestResult {
|
||||||
|
connected: boolean
|
||||||
|
messages: string[]
|
||||||
|
closeCode: number | null
|
||||||
|
error: string | null
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface S3LambdaTriggerInput {
|
export interface S3LambdaTriggerInput {
|
||||||
functionName: string
|
functionName: string
|
||||||
events: string[]
|
events: string[]
|
||||||
@@ -925,6 +1192,17 @@ export interface CloudServiceAdapter {
|
|||||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||||
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
||||||
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
||||||
|
describeHttpApi?(apiId: string): Promise<HttpApiOverview>
|
||||||
|
createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview>
|
||||||
|
deleteHttpApiRoute?(apiId: string, routeId: string): Promise<void>
|
||||||
|
setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview>
|
||||||
|
createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview>
|
||||||
|
deleteHttpApiStage?(apiId: string, stageName: string): Promise<void>
|
||||||
|
deployHttpApi?(apiId: string, stageName: string): Promise<HttpApiStageOverview>
|
||||||
|
createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview>
|
||||||
|
deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise<void>
|
||||||
|
updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null>
|
||||||
|
testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult>
|
||||||
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
|
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
|
||||||
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
|
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
|
||||||
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
|
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
|
||||||
@@ -1006,7 +1284,34 @@ export interface CloudServiceAdapter {
|
|||||||
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
||||||
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
||||||
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
|
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
|
||||||
|
listMetrics?(query: {namespace?: string; search?: string}): Promise<MetricSummary[]>
|
||||||
|
getMetricSeries?(query: MetricSeriesQuery): Promise<MetricSeries>
|
||||||
|
describeAlarmHistory?(alarmName: string): Promise<AlarmHistoryItem[]>
|
||||||
|
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
||||||
|
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
||||||
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||||
|
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
|
||||||
|
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
||||||
|
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
||||||
|
getCertificatePem?(certificateId: string): Promise<CertificatePem>
|
||||||
|
startQuery?(workGroup: string, input: QueryInput): Promise<QueryExecutionSummary>
|
||||||
|
/** The query's state, and its first rows once it has succeeded. */
|
||||||
|
getQueryResult?(workGroup: string, queryId: string): Promise<QueryResult>
|
||||||
|
stopQuery?(workGroup: string, queryId: string): Promise<void>
|
||||||
|
listQueries?(workGroup: string): Promise<QueryExecutionSummary[]>
|
||||||
|
/** The databases a query can use and, for the one named, its tables. */
|
||||||
|
describeQueryCatalog?(database?: string): Promise<QueryCatalog>
|
||||||
|
createCatalogTable?(databaseName: string, input: CatalogTableInput): Promise<CloudResource>
|
||||||
|
deleteCatalogTable?(databaseName: string, tableName: string): Promise<CloudResource>
|
||||||
|
createMountTarget?(fileSystemId: string, subnetId: string): Promise<CloudResource>
|
||||||
|
deleteMountTarget?(fileSystemId: string, mountTargetId: string): Promise<CloudResource>
|
||||||
|
createAccessPoint?(fileSystemId: string, input: AccessPointInput): Promise<CloudResource>
|
||||||
|
deleteAccessPoint?(fileSystemId: string, accessPointId: string): Promise<CloudResource>
|
||||||
|
/** Mounts one of the file system's access points into a Lambda function, as UpdateFunctionConfiguration does. */
|
||||||
|
attachFileSystemToFunction?(fileSystemId: string, input: FileSystemAttachmentInput): Promise<CloudResource>
|
||||||
|
detachFileSystemFromFunction?(fileSystemId: string, functionName: string): Promise<CloudResource>
|
||||||
|
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
|
||||||
|
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
|
||||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||||
clearEmailInbox?(): Promise<void>
|
clearEmailInbox?(): Promise<void>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
|||||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DataCatalogActionName, DistributionActionName, FileSystemActionName, HttpApiActionName, QueryActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guards the schema-to-implementation contract for every registered adapter.
|
* Guards the schema-to-implementation contract for every registered adapter.
|
||||||
@@ -47,11 +47,67 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
|||||||
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
|
||||||
|
listMetrics: 'listMetrics',
|
||||||
|
getMetricData: 'getMetricSeries',
|
||||||
|
alarmHistory: 'describeAlarmHistory',
|
||||||
|
setAlarmState: 'setAlarmState',
|
||||||
|
setAlarmActions: 'setAlarmActionsEnabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_HTTP_API_ACTION: Record<HttpApiActionName, keyof CloudServiceAdapter> = {
|
||||||
|
describe: 'describeHttpApi',
|
||||||
|
createRoute: 'createHttpApiRoute',
|
||||||
|
deleteRoute: 'deleteHttpApiRoute',
|
||||||
|
setRouteAuthorizer: 'setHttpApiRouteAuthorizer',
|
||||||
|
createStage: 'createHttpApiStage',
|
||||||
|
deleteStage: 'deleteHttpApiStage',
|
||||||
|
deploy: 'deployHttpApi',
|
||||||
|
createAuthorizer: 'createHttpApiAuthorizer',
|
||||||
|
deleteAuthorizer: 'deleteHttpApiAuthorizer',
|
||||||
|
updateCors: 'updateHttpApiCors',
|
||||||
|
sendRequest: 'testApiGatewayRoute',
|
||||||
|
testWebSocket: 'testWebSocketApi',
|
||||||
|
}
|
||||||
|
|
||||||
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
||||||
setEnabled: 'setDistributionEnabled',
|
setEnabled: 'setDistributionEnabled',
|
||||||
createInvalidation: 'createDistributionInvalidation',
|
createInvalidation: 'createDistributionInvalidation',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudServiceAdapter> = {
|
||||||
|
download: 'getCertificatePem',
|
||||||
|
export: 'exportCertificate',
|
||||||
|
revoke: 'revokeCertificate',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_QUERY_ACTION: Record<QueryActionName, keyof CloudServiceAdapter> = {
|
||||||
|
run: 'startQuery',
|
||||||
|
results: 'getQueryResult',
|
||||||
|
stop: 'stopQuery',
|
||||||
|
history: 'listQueries',
|
||||||
|
catalog: 'describeQueryCatalog',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_DATA_CATALOG_ACTION: Record<DataCatalogActionName, keyof CloudServiceAdapter> = {
|
||||||
|
createTable: 'createCatalogTable',
|
||||||
|
deleteTable: 'deleteCatalogTable',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_FILE_SYSTEM_ACTION: Record<FileSystemActionName, keyof CloudServiceAdapter> = {
|
||||||
|
createMountTarget: 'createMountTarget',
|
||||||
|
deleteMountTarget: 'deleteMountTarget',
|
||||||
|
createAccessPoint: 'createAccessPoint',
|
||||||
|
deleteAccessPoint: 'deleteAccessPoint',
|
||||||
|
attachFunction: 'attachFileSystemToFunction',
|
||||||
|
detachFunction: 'detachFileSystemFromFunction',
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
|
||||||
|
setCapacity: 'setAutoScalingCapacity',
|
||||||
|
terminateInstance: 'terminateAutoScalingInstance',
|
||||||
|
}
|
||||||
|
|
||||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||||
updateUser: 'updateUserPoolUser',
|
updateUser: 'updateUserPoolUser',
|
||||||
setUserGroups: 'setUserPoolUserGroups',
|
setUserGroups: 'setUserPoolUserGroups',
|
||||||
@@ -147,6 +203,69 @@ describe('registered adapters honour their schema', () => {
|
|||||||
).toBe('function')
|
).toBe('function')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of monitoringClaims) {
|
||||||
|
const method = METHOD_FOR_MONITORING_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of httpApiClaims) {
|
||||||
|
const method = METHOD_FOR_HTTP_API_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateClaims = (adapter.schema().capabilities?.certificateActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of certificateClaims) {
|
||||||
|
const method = METHOD_FOR_CERTIFICATE_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of autoScalingClaims) {
|
||||||
|
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClaims = (adapter.schema().capabilities?.queryActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of queryClaims) {
|
||||||
|
const method = METHOD_FOR_QUERY_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataCatalogClaims = (adapter.schema().capabilities?.dataCatalogActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of dataCatalogClaims) {
|
||||||
|
const method = METHOD_FOR_DATA_CATALOG_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSystemClaims = (adapter.schema().capabilities?.fileSystemActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
|
for (const capability of fileSystemClaims) {
|
||||||
|
const method = METHOD_FOR_FILE_SYSTEM_ACTION[capability.name]
|
||||||
|
expect(
|
||||||
|
typeof adapter[method],
|
||||||
|
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||||
|
).toBe('function')
|
||||||
|
}
|
||||||
|
|
||||||
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||||
for (const capability of distributionClaims) {
|
for (const capability of distributionClaims) {
|
||||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||||
@@ -169,6 +288,13 @@ describe('registered adapters honour their schema', () => {
|
|||||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.fileSystemActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.dataCatalogActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.queryActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.certificateActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||||
|
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||||
]
|
]
|
||||||
@@ -233,7 +359,7 @@ describe('adapter capability advertisements match the runtime reality', () => {
|
|||||||
label: 'Create DB snapshot',
|
label: 'Create DB snapshot',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
status: 'partial',
|
status: 'partial',
|
||||||
reason: 'The Cloud Proxy operation is available, but the current Floci runtime does not implement CreateDBSnapshot.',
|
reason: 'The Cloud Proxy operation is available, but the current runtime does not implement CreateDBSnapshot.',
|
||||||
runtimeRequired: true,
|
runtimeRequired: true,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
|||||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||||
|
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
|
||||||
|
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
|
||||||
|
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
|
||||||
|
import {AwsFileSystemsAdapter} from './adapter-aws/AwsFileSystemsAdapter'
|
||||||
|
import {AwsDataCatalogAdapter} from './adapter-aws/AwsDataCatalogAdapter'
|
||||||
|
import {AwsQueryAdapter} from './adapter-aws/AwsQueryAdapter'
|
||||||
|
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||||
import {createEc2Service} from './services/ec2'
|
import {createEc2Service} from './services/ec2'
|
||||||
import {createEksService} from './services/eks'
|
import {createEksService} from './services/eks'
|
||||||
@@ -84,7 +91,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
|
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
|
||||||
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
|
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
|
||||||
new AwsIamAdapter(clients.iam),
|
new AwsIamAdapter(clients.iam),
|
||||||
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
|
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2),
|
||||||
new AwsCloudFormationAdapter(clients.cloudformation),
|
new AwsCloudFormationAdapter(clients.cloudformation),
|
||||||
new AwsSecretsAdapter(clients.secretsManager),
|
new AwsSecretsAdapter(clients.secretsManager),
|
||||||
new AwsSesAdapter(),
|
new AwsSesAdapter(),
|
||||||
@@ -102,6 +109,13 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
|||||||
new AwsUserPoolsAdapter(clients.cognito),
|
new AwsUserPoolsAdapter(clients.cognito),
|
||||||
new AwsDnsAdapter(clients.route53),
|
new AwsDnsAdapter(clients.route53),
|
||||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||||
|
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
|
||||||
|
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
|
||||||
|
new AwsPipesAdapter(clients.pipes),
|
||||||
|
new AwsFileSystemsAdapter(clients.efs, clients.lambda, clients.ec2),
|
||||||
|
new AwsDataCatalogAdapter(clients.glue),
|
||||||
|
new AwsQueryAdapter(clients.athena),
|
||||||
|
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||||
new AzureStorageAdapter(),
|
new AzureStorageAdapter(),
|
||||||
new AzureServiceBusAdapter(),
|
new AzureServiceBusAdapter(),
|
||||||
new AzureDatabaseAdapter(),
|
new AzureDatabaseAdapter(),
|
||||||
|
|||||||
@@ -122,6 +122,6 @@ describe('GcpRestRuntimeClient.health', () => {
|
|||||||
stubFetch(() => {
|
stubFetch(() => {
|
||||||
throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'})
|
throw Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'})
|
||||||
})
|
})
|
||||||
await expect(client().health()).rejects.toThrow(`Cannot reach Floci-GCP at ${ENDPOINT}`)
|
await expect(client().health()).rejects.toThrow(`Cannot reach TheCloud's GCP runtime at ${ENDPOINT}`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
|
|||||||
res = await globalThis.fetch(`${this.endpoint}${path}`, init)
|
res = await globalThis.fetch(`${this.endpoint}${path}`, init)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeUnavailableError(
|
throw new RuntimeUnavailableError(
|
||||||
`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`,
|
`Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`,
|
||||||
{cause: error},
|
{cause: error},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,12 +76,12 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
|
|||||||
res = await globalThis.fetch(url, {method: 'GET'})
|
res = await globalThis.fetch(url, {method: 'GET'})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeUnavailableError(
|
throw new RuntimeUnavailableError(
|
||||||
`Cannot reach Floci-GCP at ${this.endpoint}: ${errorMessage(error)}`,
|
`Cannot reach TheCloud's GCP runtime at ${this.endpoint}: ${errorMessage(error)}`,
|
||||||
{cause: error},
|
{cause: error},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (res.status >= 500) {
|
if (res.status >= 500) {
|
||||||
throw new RuntimeUnavailableError(`Floci-GCP at ${this.endpoint} returned HTTP ${res.status}`)
|
throw new RuntimeUnavailableError(`TheCloud's GCP runtime at ${this.endpoint} returned HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
|
|||||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||||
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||||
|
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||||
|
import {awsFileSystemsSchema} from '../cloud-spi/fileSystemsSchema'
|
||||||
|
import {awsDataCatalogSchema} from '../cloud-spi/dataCatalogSchema'
|
||||||
|
import {awsQuerySchema} from '../cloud-spi/querySchema'
|
||||||
|
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
|
||||||
|
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||||
@@ -770,6 +776,33 @@ describe('cloud schema routes', () => {
|
|||||||
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
|
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'monitoring',
|
||||||
|
schema: awsMonitoringSchema,
|
||||||
|
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
|
||||||
|
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
|
||||||
|
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
|
||||||
|
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
|
||||||
|
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
|
||||||
|
})])
|
||||||
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
const base = '/api/clouds/aws/services/monitoring'
|
||||||
|
|
||||||
|
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
|
||||||
|
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
|
||||||
|
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
|
||||||
|
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['metrics', {namespace: 'App', search: undefined}],
|
||||||
|
['series', 'Errors', 60],
|
||||||
|
['state', 'high-errors', 'ALARM', 'test'],
|
||||||
|
['actions', 'high-errors', false],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
|
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
|
||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
@@ -795,6 +828,123 @@ describe('cloud schema routes', () => {
|
|||||||
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('downloads, exports and revokes a certificate through the ACM adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'certificates',
|
||||||
|
schema: awsCertificatesSchema,
|
||||||
|
getCertificatePem: async (id) => {
|
||||||
|
calls.push(['pem', id])
|
||||||
|
return {certificate: 'CERT', certificateChain: null}
|
||||||
|
},
|
||||||
|
exportCertificate: async (id, passphrase) => {
|
||||||
|
calls.push(['export', id, passphrase])
|
||||||
|
return {certificate: 'CERT', certificateChain: null, privateKey: 'KEY'}
|
||||||
|
},
|
||||||
|
revokeCertificate: async (id, reason) => {
|
||||||
|
calls.push(['revoke', id, reason])
|
||||||
|
return {id, name: 'a.test', cloud: 'aws', service: 'certificates', type: 'certificate', region: null, createdAt: null, status: 'REVOKED', metadata: {}}
|
||||||
|
},
|
||||||
|
})])
|
||||||
|
const json = (body: unknown) => ({method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
const base = '/api/clouds/aws/services/certificates/resources/c1'
|
||||||
|
|
||||||
|
expect(await (await app.request(`${base}/pem`)).json()).toEqual({certificate: 'CERT', certificateChain: null})
|
||||||
|
expect((await (await app.request(`${base}/export`, json({passphrase: 'secret'}))).json()).privateKey).toBe('KEY')
|
||||||
|
const revoked = await app.request(`${base}/revoke`, json({}))
|
||||||
|
expect((await revoked.json()).status).toBe('REVOKED')
|
||||||
|
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'autoscaling',
|
||||||
|
schema: awsAutoScalingSchema,
|
||||||
|
setAutoScalingCapacity: async (id, input) => {
|
||||||
|
calls.push(['capacity', id, input])
|
||||||
|
return {id, name: id, cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: null, createdAt: null, metadata: {desiredCapacity: 3}}
|
||||||
|
},
|
||||||
|
terminateAutoScalingInstance: async (id, instanceId, replace) => {
|
||||||
|
calls.push(['terminate', id, instanceId, replace])
|
||||||
|
},
|
||||||
|
})])
|
||||||
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
const base = '/api/clouds/aws/services/autoscaling/resources/web-asg'
|
||||||
|
|
||||||
|
const updated = await app.request(`${base}/capacity`, json('PUT', {desiredCapacity: 3}))
|
||||||
|
expect((await updated.json()).metadata.desiredCapacity).toBe(3)
|
||||||
|
expect((await app.request(`${base}/instances/i-abc/terminate`, json('POST', {replace: true}))).status).toBe(200)
|
||||||
|
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs, reads, lists and cancels queries through the Athena adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const execution = {id: 'q1', sql: 'SELECT 1', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML', submittedAt: null, completedAt: null, runtimeMs: 5, dataScannedBytes: 0, outputLocation: 's3://r/q1.csv'}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'query',
|
||||||
|
schema: awsQuerySchema,
|
||||||
|
startQuery: async (id, input) => { calls.push(['start', id, input.sql, input.database]); return execution },
|
||||||
|
getQueryResult: async (id, queryId) => { calls.push(['result', id, queryId]); return {execution, columns: [{name: '_col0', type: 'integer'}], rows: [['1']], truncated: false} },
|
||||||
|
stopQuery: async (id, queryId) => { calls.push(['stop', id, queryId]) },
|
||||||
|
listQueries: async (id) => { calls.push(['list', id]); return [execution] },
|
||||||
|
describeQueryCatalog: async (database) => { calls.push(['catalog', database]); return {databases: ['shop'], database: 'shop', tables: []} },
|
||||||
|
})])
|
||||||
|
const base = '/api/clouds/aws/services/query/resources/analysts/queries'
|
||||||
|
|
||||||
|
expect((await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({sql: 'SELECT 1', database: 'shop'})})).status).toBe(201)
|
||||||
|
expect((await (await app.request(`${base}/q1`)).json()).rows).toEqual([['1']])
|
||||||
|
expect((await app.request(base)).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/q1/stop`, {method: 'POST'})).status).toBe(200)
|
||||||
|
expect((await (await app.request('/api/clouds/aws/services/query/catalog?database=shop')).json()).databases).toEqual(['shop'])
|
||||||
|
expect(calls).toEqual([['start', 'analysts', 'SELECT 1', 'shop'], ['result', 'analysts', 'q1'], ['list', 'analysts'], ['stop', 'analysts', 'q1'], ['catalog', 'shop']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates and deletes catalog tables through the Glue adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const database = {id: 'shop', name: 'shop', cloud: 'aws' as const, service: 'datacatalog' as const, type: 'glueDatabase', region: null, createdAt: null, metadata: {}}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'datacatalog',
|
||||||
|
schema: awsDataCatalogSchema,
|
||||||
|
createCatalogTable: async (id, input) => { calls.push(['create', id, input.name, input.format]); return database },
|
||||||
|
deleteCatalogTable: async (id, tableName) => { calls.push(['delete', id, tableName]); return database },
|
||||||
|
})])
|
||||||
|
const base = '/api/clouds/aws/services/datacatalog/resources/shop/tables'
|
||||||
|
|
||||||
|
const created = await app.request(base, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({name: 'orders', location: 's3://data/orders/', format: 'csv', columns: [{name: 'id', type: 'int'}]})})
|
||||||
|
expect(created.status).toBe(201)
|
||||||
|
expect((await app.request(`${base}/orders`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect(calls).toEqual([['create', 'shop', 'orders', 'csv'], ['delete', 'shop', 'orders']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manages mount targets, access points and function mounts through the EFS adapter', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const fileSystem = {id: 'fs-1', name: 'shared', cloud: 'aws' as const, service: 'filesystems' as const, type: 'fileSystem', region: null, createdAt: null, metadata: {}}
|
||||||
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
|
service: 'filesystems',
|
||||||
|
schema: awsFileSystemsSchema,
|
||||||
|
createMountTarget: async (id, subnetId) => { calls.push(['mount', id, subnetId]); return fileSystem },
|
||||||
|
deleteMountTarget: async (id, mountTargetId) => { calls.push(['unmount', id, mountTargetId]); return fileSystem },
|
||||||
|
createAccessPoint: async (id, input) => { calls.push(['ap', id, input.path]); return fileSystem },
|
||||||
|
deleteAccessPoint: async (id, accessPointId) => { calls.push(['delete-ap', id, accessPointId]); return fileSystem },
|
||||||
|
attachFileSystemToFunction: async (id, input) => { calls.push(['attach', id, input.functionName, input.mountPath]); return fileSystem },
|
||||||
|
detachFileSystemFromFunction: async (id, functionName) => { calls.push(['detach', id, functionName]); return fileSystem },
|
||||||
|
})])
|
||||||
|
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||||
|
const base = '/api/clouds/aws/services/filesystems/resources/fs-1'
|
||||||
|
|
||||||
|
expect((await app.request(`${base}/mount-targets`, json('POST', {subnetId: 'subnet-a'}))).status).toBe(201)
|
||||||
|
expect((await app.request(`${base}/mount-targets/fsmt-1`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/access-points`, json('POST', {path: '/app', uid: 1000, gid: 1000, permissions: '755'}))).status).toBe(201)
|
||||||
|
expect((await app.request(`${base}/access-points/fsap-1`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/functions`, json('POST', {accessPointId: 'fsap-1', functionName: 'writer', mountPath: '/mnt/share'}))).status).toBe(200)
|
||||||
|
expect((await app.request(`${base}/functions/writer`, {method: 'DELETE'})).status).toBe(200)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['mount', 'fs-1', 'subnet-a'], ['unmount', 'fs-1', 'fsmt-1'], ['ap', 'fs-1', '/app'], ['delete-ap', 'fs-1', 'fsap-1'],
|
||||||
|
['attach', 'fs-1', 'writer', '/mnt/share'], ['detach', 'fs-1', 'writer'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('clears the SES mailbox through the email adapter', async () => {
|
test('clears the SES mailbox through the email adapter', async () => {
|
||||||
let cleared = false
|
let cleared = false
|
||||||
const app = appWithRoutes([mockAdapter('aws', {
|
const app = appWithRoutes([mockAdapter('aws', {
|
||||||
@@ -1375,6 +1525,18 @@ describe('service descriptors', () => {
|
|||||||
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
|
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
|
||||||
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
|
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('marks a service listable only when an adapter serves the generic list', async () => {
|
||||||
|
// MSK is available through its own pre-SPI page; Console Home must not count it
|
||||||
|
// through /resources, which has no adapter behind it and answers 501.
|
||||||
|
const body = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/aws/services')).json() as Array<{service: string; availability: string; listable: boolean}>
|
||||||
|
const of = (service: string) => body.find((descriptor) => descriptor.service === service)
|
||||||
|
|
||||||
|
expect(of('storage')).toMatchObject({availability: 'available', listable: true})
|
||||||
|
expect(of('msk')).toMatchObject({availability: 'available', listable: false})
|
||||||
|
expect(of('msk-connect')).toMatchObject({availability: 'available', listable: false})
|
||||||
|
expect(of('serverless')).toMatchObject({availability: 'coming_soon', listable: false})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AppConfig nested routes', () => {
|
describe('AppConfig nested routes', () => {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import {Hono} from 'hono'
|
import {Hono} from 'hono'
|
||||||
import type {Context} from 'hono'
|
import type {Context} from 'hono'
|
||||||
import type {
|
import type {
|
||||||
|
AlarmStateValue,
|
||||||
|
AutoScalingCapacityInput,
|
||||||
|
AccessPointInput,
|
||||||
|
CatalogTableInput,
|
||||||
|
QueryInput,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
|
CertificateRevocationReason,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
CloudServiceType,
|
CloudServiceType,
|
||||||
CreateDatabaseSnapshotInput,
|
CreateDatabaseSnapshotInput,
|
||||||
@@ -461,6 +468,49 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
|
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/monitoring/metrics', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined})))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/monitoring/metric-data', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).getMetricSeries(cloud, await c.req.json())))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/monitoring/resources/:id/history', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).describeAlarmHistory(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/:cloud/services/monitoring/resources/:id/state', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const body = await c.req.json<{state?: AlarmStateValue; reason?: string}>()
|
||||||
|
await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '')
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/:cloud/services/monitoring/resources/:id/actions', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const body = await c.req.json<{enabled?: boolean}>()
|
||||||
|
await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean)
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
|
app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
@@ -481,6 +531,148 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/certificates/resources/:id/pem', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).getCertificatePem(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/certificates/resources/:id/export', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const body = await c.req.json<{passphrase?: string}>()
|
||||||
|
return c.json(await svc(c).exportCertificate(cloud, c.req.param('id'), body.passphrase as string))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/certificates/resources/:id/revoke', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const body = await c.req.json<{reason?: CertificateRevocationReason}>()
|
||||||
|
return c.json(await svc(c).revokeCertificate(cloud, c.req.param('id'), body.reason ?? 'UNSPECIFIED'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/catalog', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).describeQueryCatalog(cloud, c.req.query('database') || undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/resources/:id/queries', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).listQueries(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/query/resources/:id/queries', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).startQuery(cloud, c.req.param('id'), await c.req.json<QueryInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/:cloud/services/query/resources/:id/queries/:queryId', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).getQueryResult(cloud, c.req.param('id'), c.req.param('queryId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/query/resources/:id/queries/:queryId/stop', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
await svc(c).stopQuery(cloud, c.req.param('id'), c.req.param('queryId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/datacatalog/resources/:id/tables', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).createCatalogTable(cloud, c.req.param('id'), await c.req.json<CatalogTableInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/datacatalog/resources/:id/tables/:tableName', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).deleteCatalogTable(cloud, c.req.param('id'), c.req.param('tableName'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/filesystems/resources/:id/mount-targets', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const body = await c.req.json<{subnetId?: string}>()
|
||||||
|
return c.json(await svc(c).createMountTarget(cloud, c.req.param('id'), body.subnetId ?? ''), 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/filesystems/resources/:id/mount-targets/:mountTargetId', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).deleteMountTarget(cloud, c.req.param('id'), c.req.param('mountTargetId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/filesystems/resources/:id/access-points', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).createAccessPoint(cloud, c.req.param('id'), await c.req.json<AccessPointInput>()), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/filesystems/resources/:id/access-points/:accessPointId', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).deleteAccessPoint(cloud, c.req.param('id'), c.req.param('accessPointId'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/filesystems/resources/:id/functions', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).attachFileSystemToFunction(cloud, c.req.param('id'), await c.req.json<FileSystemAttachmentInput>())))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/filesystems/resources/:id/functions/:functionName', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).detachFileSystemFromFunction(cloud, c.req.param('id'), c.req.param('functionName'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/:cloud/services/autoscaling/resources/:id/capacity', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).setAutoScalingCapacity(cloud, c.req.param('id'), await c.req.json<AutoScalingCapacityInput>())))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
|
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const body = await c.req.json<{replace?: boolean}>()
|
||||||
|
await svc(c).terminateAutoScalingInstance(cloud, c.req.param('id'), c.req.param('instanceId'), body.replace === true)
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||||
@@ -759,6 +951,131 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs.
|
||||||
|
app.get('/:cloud/services/apigateway/resources/:id/http-api', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).describeHttpApi(cloud, c.req.param('id'))))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/apigateway/resources/:id/http-api/routes', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const input = await c.req.json<Record<string, unknown>>()
|
||||||
|
const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), {
|
||||||
|
routeKey: typeof input.routeKey === 'string' ? input.routeKey : '',
|
||||||
|
functionName: typeof input.functionName === 'string' ? input.functionName : undefined,
|
||||||
|
httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined,
|
||||||
|
authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined,
|
||||||
|
returnsResponse: input.returnsResponse === true,
|
||||||
|
})
|
||||||
|
return c.json(route, 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const input = await c.req.json<{authorizerId?: unknown}>()
|
||||||
|
const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null
|
||||||
|
return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const input = await c.req.json<Record<string, unknown>>()
|
||||||
|
const stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), {
|
||||||
|
stageName: typeof input.stageName === 'string' ? input.stageName : '',
|
||||||
|
autoDeploy: input.autoDeploy === true,
|
||||||
|
})
|
||||||
|
return c.json(stage, 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => c.json(await svc(c).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const input = await c.req.json<Record<string, unknown>>()
|
||||||
|
const authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), {
|
||||||
|
name: typeof input.name === 'string' ? input.name : '',
|
||||||
|
userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined,
|
||||||
|
userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined,
|
||||||
|
issuer: typeof input.issuer === 'string' ? input.issuer : undefined,
|
||||||
|
audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined,
|
||||||
|
})
|
||||||
|
return c.json(authorizer, 201)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId'))
|
||||||
|
return c.json({ok: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const input = await c.req.json<{cors?: Record<string, unknown> | null}>()
|
||||||
|
const cors = input.cors ? {
|
||||||
|
allowOrigins: stringList(input.cors.allowOrigins),
|
||||||
|
allowMethods: stringList(input.cors.allowMethods),
|
||||||
|
allowHeaders: stringList(input.cors.allowHeaders),
|
||||||
|
exposeHeaders: stringList(input.cors.exposeHeaders),
|
||||||
|
maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null,
|
||||||
|
allowCredentials: input.cors.allowCredentials === true,
|
||||||
|
} : null
|
||||||
|
return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-test', async (c) => {
|
||||||
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
|
return withRuntime(c, async () => {
|
||||||
|
const input = await c.req.json<Record<string, unknown>>()
|
||||||
|
return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), {
|
||||||
|
stage: typeof input.stage === 'string' ? input.stage : '',
|
||||||
|
message: typeof input.message === 'string' ? input.message : '',
|
||||||
|
query: typeof input.query === 'string' ? input.query : undefined,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
@@ -1618,6 +1935,10 @@ function isStringRecord(value: unknown): value is Record<string, string> {
|
|||||||
&& Object.values(value).every((item) => typeof item === 'string')
|
&& Object.values(value).every((item) => typeof item === 'string')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||||
|
}
|
||||||
|
|
||||||
function stringHeaders(value: unknown): Record<string, string> {
|
function stringHeaders(value: unknown): Record<string, string> {
|
||||||
if (value === undefined) return {}
|
if (value === undefined) return {}
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ import type {
|
|||||||
ApiGatewayDeleteRouteInput,
|
ApiGatewayDeleteRouteInput,
|
||||||
ApiGatewayCorsInput,
|
ApiGatewayCorsInput,
|
||||||
ApiGatewayCorsResult,
|
ApiGatewayCorsResult,
|
||||||
|
HttpApiAuthorizerInput,
|
||||||
|
HttpApiAuthorizerOverview,
|
||||||
|
HttpApiCors,
|
||||||
|
HttpApiOverview,
|
||||||
|
HttpApiRouteInput,
|
||||||
|
HttpApiRouteOverview,
|
||||||
|
HttpApiStageInput,
|
||||||
|
HttpApiStageOverview,
|
||||||
|
HttpApiWebSocketTestInput,
|
||||||
|
HttpApiWebSocketTestResult,
|
||||||
S3LambdaTriggerInput,
|
S3LambdaTriggerInput,
|
||||||
S3LambdaTriggerOverview,
|
S3LambdaTriggerOverview,
|
||||||
S3BucketWebsiteConfiguration,
|
S3BucketWebsiteConfiguration,
|
||||||
@@ -83,6 +93,22 @@ import type {
|
|||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
DistributionInvalidation,
|
DistributionInvalidation,
|
||||||
|
AutoScalingCapacityInput,
|
||||||
|
AccessPointInput,
|
||||||
|
CatalogTableInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
|
CertificatePem,
|
||||||
|
CertificateRevocationReason,
|
||||||
|
ExportedCertificate,
|
||||||
|
AlarmHistoryItem,
|
||||||
|
AlarmStateValue,
|
||||||
|
MetricSeries,
|
||||||
|
MetricSeriesQuery,
|
||||||
|
MetricSummary,
|
||||||
UpdateUserPoolClientOAuthInput,
|
UpdateUserPoolClientOAuthInput,
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
@@ -162,6 +188,7 @@ export class CloudProxyService {
|
|||||||
iconKey: entry.iconKey,
|
iconKey: entry.iconKey,
|
||||||
group: entry.group,
|
group: entry.group,
|
||||||
order: entry.order,
|
order: entry.order,
|
||||||
|
listable: Boolean(adapter),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -325,6 +352,56 @@ export class CloudProxyService {
|
|||||||
return adapter.enableApiGatewayCors(apiId, input)
|
return adapter.enableApiGatewayCors(apiId, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async describeHttpApi(cloud: CloudProvider, apiId: string): Promise<HttpApiOverview> {
|
||||||
|
return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||||
|
return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
|
||||||
|
await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||||
|
return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||||
|
return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
|
||||||
|
await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||||
|
return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||||
|
return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
|
||||||
|
await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||||
|
return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors)
|
||||||
|
}
|
||||||
|
|
||||||
|
async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||||
|
return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'apigateway')
|
||||||
|
if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`)
|
||||||
|
return adapter
|
||||||
|
}
|
||||||
|
|
||||||
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
|
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
|
||||||
const adapter = this.requireAdapter(cloud, 'storage')
|
const adapter = this.requireAdapter(cloud, 'storage')
|
||||||
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
||||||
@@ -834,6 +911,36 @@ export class CloudProxyService {
|
|||||||
return adapter.testUserPoolSignIn(poolId, input)
|
return adapter.testUserPoolSignIn(poolId, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise<MetricSummary[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||||
|
if (!adapter.listMetrics) throw new NotSupportedError(`Metrics are not supported for ${cloud}/monitoring`)
|
||||||
|
return adapter.listMetrics(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetricSeries(cloud: CloudProvider, query: MetricSeriesQuery): Promise<MetricSeries> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||||
|
if (!adapter.getMetricSeries) throw new NotSupportedError(`Metric data is not supported for ${cloud}/monitoring`)
|
||||||
|
return adapter.getMetricSeries(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeAlarmHistory(cloud: CloudProvider, alarmName: string): Promise<AlarmHistoryItem[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||||
|
if (!adapter.describeAlarmHistory) throw new NotSupportedError(`Alarm history is not supported for ${cloud}/monitoring`)
|
||||||
|
return adapter.describeAlarmHistory(alarmName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAlarmState(cloud: CloudProvider, alarmName: string, state: AlarmStateValue, reason: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||||
|
if (!adapter.setAlarmState) throw new NotSupportedError(`Setting alarm states is not supported for ${cloud}/monitoring`)
|
||||||
|
await adapter.setAlarmState(alarmName, state, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAlarmActionsEnabled(cloud: CloudProvider, alarmName: string, enabled: boolean): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||||
|
if (!adapter.setAlarmActionsEnabled) throw new NotSupportedError(`Alarm actions are not supported for ${cloud}/monitoring`)
|
||||||
|
await adapter.setAlarmActionsEnabled(alarmName, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
|
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
|
||||||
const adapter = this.requireAdapter(cloud, 'cdn')
|
const adapter = this.requireAdapter(cloud, 'cdn')
|
||||||
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
|
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
|
||||||
@@ -846,6 +953,114 @@ export class CloudProxyService {
|
|||||||
return adapter.createDistributionInvalidation(distributionId, paths)
|
return adapter.createDistributionInvalidation(distributionId, paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCertificatePem(cloud: CloudProvider, certificateId: string): Promise<CertificatePem> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'certificates')
|
||||||
|
if (!adapter.getCertificatePem) throw new NotSupportedError(`Downloading certificates is not supported for ${cloud}/certificates`)
|
||||||
|
return adapter.getCertificatePem(certificateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportCertificate(cloud: CloudProvider, certificateId: string, passphrase: string): Promise<ExportedCertificate> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'certificates')
|
||||||
|
if (!adapter.exportCertificate) throw new NotSupportedError(`Exporting certificates is not supported for ${cloud}/certificates`)
|
||||||
|
return adapter.exportCertificate(certificateId, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeCertificate(cloud: CloudProvider, certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'certificates')
|
||||||
|
if (!adapter.revokeCertificate) throw new NotSupportedError(`Revoking certificates is not supported for ${cloud}/certificates`)
|
||||||
|
return adapter.revokeCertificate(certificateId, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
async startQuery(cloud: CloudProvider, workGroup: string, input: QueryInput): Promise<QueryExecutionSummary> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.startQuery) throw new NotSupportedError(`Running queries is not supported for ${cloud}/query`)
|
||||||
|
return adapter.startQuery(workGroup, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueryResult(cloud: CloudProvider, workGroup: string, queryId: string): Promise<QueryResult> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.getQueryResult) throw new NotSupportedError(`Query results are not supported for ${cloud}/query`)
|
||||||
|
return adapter.getQueryResult(workGroup, queryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopQuery(cloud: CloudProvider, workGroup: string, queryId: string): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.stopQuery) throw new NotSupportedError(`Cancelling queries is not supported for ${cloud}/query`)
|
||||||
|
return adapter.stopQuery(workGroup, queryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueries(cloud: CloudProvider, workGroup: string): Promise<QueryExecutionSummary[]> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.listQueries) throw new NotSupportedError(`Query history is not supported for ${cloud}/query`)
|
||||||
|
return adapter.listQueries(workGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
async describeQueryCatalog(cloud: CloudProvider, database?: string): Promise<QueryCatalog> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'query')
|
||||||
|
if (!adapter.describeQueryCatalog) throw new NotSupportedError(`Browsing the catalog is not supported for ${cloud}/query`)
|
||||||
|
return adapter.describeQueryCatalog(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCatalogTable(cloud: CloudProvider, databaseName: string, input: CatalogTableInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'datacatalog')
|
||||||
|
if (!adapter.createCatalogTable) throw new NotSupportedError(`Creating tables is not supported for ${cloud}/datacatalog`)
|
||||||
|
return adapter.createCatalogTable(databaseName, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCatalogTable(cloud: CloudProvider, databaseName: string, tableName: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'datacatalog')
|
||||||
|
if (!adapter.deleteCatalogTable) throw new NotSupportedError(`Deleting tables is not supported for ${cloud}/datacatalog`)
|
||||||
|
return adapter.deleteCatalogTable(databaseName, tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMountTarget(cloud: CloudProvider, fileSystemId: string, subnetId: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.createMountTarget) throw new NotSupportedError(`Mount targets are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.createMountTarget(fileSystemId, subnetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMountTarget(cloud: CloudProvider, fileSystemId: string, mountTargetId: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.deleteMountTarget) throw new NotSupportedError(`Mount targets are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.deleteMountTarget(fileSystemId, mountTargetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccessPoint(cloud: CloudProvider, fileSystemId: string, input: AccessPointInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.createAccessPoint) throw new NotSupportedError(`Access points are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.createAccessPoint(fileSystemId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccessPoint(cloud: CloudProvider, fileSystemId: string, accessPointId: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.deleteAccessPoint) throw new NotSupportedError(`Access points are not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.deleteAccessPoint(fileSystemId, accessPointId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async attachFileSystemToFunction(cloud: CloudProvider, fileSystemId: string, input: FileSystemAttachmentInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.attachFileSystemToFunction) throw new NotSupportedError(`Attaching file systems is not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.attachFileSystemToFunction(fileSystemId, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async detachFileSystemFromFunction(cloud: CloudProvider, fileSystemId: string, functionName: string): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'filesystems')
|
||||||
|
if (!adapter.detachFileSystemFromFunction) throw new NotSupportedError(`Detaching file systems is not supported for ${cloud}/filesystems`)
|
||||||
|
return adapter.detachFileSystemFromFunction(fileSystemId, functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||||
|
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`)
|
||||||
|
return adapter.setAutoScalingCapacity(groupName, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateAutoScalingInstance(cloud: CloudProvider, groupName: string, instanceId: string, replace: boolean): Promise<void> {
|
||||||
|
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||||
|
if (!adapter.terminateAutoScalingInstance) throw new NotSupportedError(`Terminating instances is not supported for ${cloud}/autoscaling`)
|
||||||
|
return adapter.terminateAutoScalingInstance(groupName, instanceId, replace)
|
||||||
|
}
|
||||||
|
|
||||||
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||||
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
|
|||||||
const NOW = new Date('2026-10-23T10:30:15.000Z')
|
const NOW = new Date('2026-10-23T10:30:15.000Z')
|
||||||
|
|
||||||
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
|
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
|
||||||
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
|
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1, listable: availability === 'available'}
|
||||||
}
|
}
|
||||||
|
|
||||||
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type RuntimeProbe = () => Promise<void>
|
|||||||
export const runtimeProbes: Record<CloudProvider, RuntimeProbe> = {
|
export const runtimeProbes: Record<CloudProvider, RuntimeProbe> = {
|
||||||
// Every runtime exposes a health endpoint, but under its own path: core and
|
// Every runtime exposes a health endpoint, but under its own path: core and
|
||||||
// Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health.
|
// Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health.
|
||||||
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'Floci core'),
|
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'TheCloud AWS runtime'),
|
||||||
azure: async () => {
|
azure: async () => {
|
||||||
await azure.fetch('/_floci/health', {method: 'GET'})
|
await azure.fetch('/_floci/health', {method: 'GET'})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type LightsailNodeDeployment = {status: 'deployed'; files: number; port:
|
|||||||
|
|
||||||
export async function deployLightsailStaticFiles(instanceName: string, form: FormData): Promise<LightsailStaticDeployment> {
|
export async function deployLightsailStaticFiles(instanceName: string, form: FormData): Promise<LightsailStaticDeployment> {
|
||||||
const endpoint = process.env.FLOCI_ENDPOINT
|
const endpoint = process.env.FLOCI_ENDPOINT
|
||||||
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
|
if (!endpoint) throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable')
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
@@ -13,19 +13,19 @@ export async function deployLightsailStaticFiles(instanceName: string, form: For
|
|||||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
|
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
|
throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable', {cause: error})
|
||||||
}
|
}
|
||||||
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown}
|
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown}
|
||||||
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Static file deployment failed')
|
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Static file deployment failed')
|
||||||
if (body.status !== 'deployed' || typeof body.files !== 'number') {
|
if (body.status !== 'deployed' || typeof body.files !== 'number') {
|
||||||
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid deployment response')
|
throw new RuntimeUnavailableError('TheCloud diagnostics returned an invalid deployment response')
|
||||||
}
|
}
|
||||||
return {status: 'deployed', files: body.files}
|
return {status: 'deployed', files: body.files}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
|
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
|
||||||
const endpoint = process.env.FLOCI_ENDPOINT
|
const endpoint = process.env.FLOCI_ENDPOINT
|
||||||
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
|
if (!endpoint) throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable')
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
@@ -33,12 +33,12 @@ export async function deployLightsailNodeApplication(instanceName: string, form:
|
|||||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
|
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
|
throw new RuntimeUnavailableError('TheCloud diagnostics are unavailable', {cause: error})
|
||||||
}
|
}
|
||||||
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown; port?: unknown; releaseId?: unknown}
|
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown; port?: unknown; releaseId?: unknown}
|
||||||
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Node.js deployment failed')
|
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Node.js deployment failed')
|
||||||
if (body.status !== 'deployed' || typeof body.files !== 'number' || typeof body.port !== 'number' || typeof body.releaseId !== 'string') {
|
if (body.status !== 'deployed' || typeof body.files !== 'number' || typeof body.port !== 'number' || typeof body.releaseId !== 'string') {
|
||||||
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid Node.js deployment response')
|
throw new RuntimeUnavailableError('TheCloud diagnostics returned an invalid Node.js deployment response')
|
||||||
}
|
}
|
||||||
return {status: 'deployed', files: body.files, port: body.port, releaseId: body.releaseId}
|
return {status: 'deployed', files: body.files, port: body.port, releaseId: body.releaseId}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,23 +59,23 @@ type SshCheckDependencies = {
|
|||||||
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
|
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
|
||||||
const endpoint = process.env.FLOCI_ENDPOINT
|
const endpoint = process.env.FLOCI_ENDPOINT
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
|
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
|
||||||
} catch {
|
} catch {
|
||||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await response.json() as DiagnosticResponse
|
return await response.json() as DiagnosticResponse
|
||||||
} catch {
|
} catch {
|
||||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
|
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +149,12 @@ export function createSshCheckService(dependencies: SshCheckDependencies = {}) {
|
|||||||
diagnostics = await diagnosticFetcher()
|
diagnostics = await diagnosticFetcher()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SshCheckError) throw error
|
if (error instanceof SshCheckError) throw error
|
||||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diagnostics || typeof diagnostics !== 'object'
|
if (!diagnostics || typeof diagnostics !== 'object'
|
||||||
|| !Array.isArray(diagnostics.ec2?.instances)) {
|
|| !Array.isArray(diagnostics.ec2?.instances)) {
|
||||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
|
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics returned an invalid response', 502)
|
||||||
}
|
}
|
||||||
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
|
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
|
|||||||
@@ -130,6 +130,54 @@ export const apiEndpointKeys = {
|
|||||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||||
moveTasks: "clouds.services.messaging.move-tasks",
|
moveTasks: "clouds.services.messaging.move-tasks",
|
||||||
},
|
},
|
||||||
|
httpApi: {
|
||||||
|
describe: "clouds.services.apigateway.http-api.describe",
|
||||||
|
createRoute: "clouds.services.apigateway.http-api.routes.create",
|
||||||
|
deleteRoute: "clouds.services.apigateway.http-api.routes.delete",
|
||||||
|
setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer",
|
||||||
|
createStage: "clouds.services.apigateway.http-api.stages.create",
|
||||||
|
deleteStage: "clouds.services.apigateway.http-api.stages.delete",
|
||||||
|
deploy: "clouds.services.apigateway.http-api.stages.deploy",
|
||||||
|
createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create",
|
||||||
|
deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete",
|
||||||
|
cors: "clouds.services.apigateway.http-api.cors",
|
||||||
|
webSocketTest: "clouds.services.apigateway.http-api.websocket-test",
|
||||||
|
},
|
||||||
|
monitoring: {
|
||||||
|
metrics: "clouds.services.monitoring.metrics",
|
||||||
|
metricData: "clouds.services.monitoring.metric-data",
|
||||||
|
history: "clouds.services.monitoring.history",
|
||||||
|
state: "clouds.services.monitoring.state",
|
||||||
|
actions: "clouds.services.monitoring.actions",
|
||||||
|
},
|
||||||
|
certificates: {
|
||||||
|
pem: "clouds.services.certificates.pem",
|
||||||
|
export: "clouds.services.certificates.export",
|
||||||
|
revoke: "clouds.services.certificates.revoke",
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
catalog: "clouds.services.query.catalog",
|
||||||
|
queries: "clouds.services.query.queries",
|
||||||
|
start: "clouds.services.query.start",
|
||||||
|
result: "clouds.services.query.result",
|
||||||
|
stop: "clouds.services.query.stop",
|
||||||
|
},
|
||||||
|
datacatalog: {
|
||||||
|
tables: "clouds.services.datacatalog.tables",
|
||||||
|
table: "clouds.services.datacatalog.table",
|
||||||
|
},
|
||||||
|
filesystems: {
|
||||||
|
mountTargets: "clouds.services.filesystems.mountTargets",
|
||||||
|
mountTarget: "clouds.services.filesystems.mountTarget",
|
||||||
|
accessPoints: "clouds.services.filesystems.accessPoints",
|
||||||
|
accessPoint: "clouds.services.filesystems.accessPoint",
|
||||||
|
functions: "clouds.services.filesystems.functions",
|
||||||
|
function: "clouds.services.filesystems.function",
|
||||||
|
},
|
||||||
|
autoscaling: {
|
||||||
|
capacity: "clouds.services.autoscaling.capacity",
|
||||||
|
terminate: "clouds.services.autoscaling.instances.terminate",
|
||||||
|
},
|
||||||
cdn: {
|
cdn: {
|
||||||
enabled: "clouds.services.cdn.enabled",
|
enabled: "clouds.services.cdn.enabled",
|
||||||
invalidations: "clouds.services.cdn.invalidations",
|
invalidations: "clouds.services.cdn.invalidations",
|
||||||
@@ -890,6 +938,148 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}],
|
||||||
|
[apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}],
|
||||||
|
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
|
||||||
|
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
|
||||||
|
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||||
|
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
||||||
|
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.certificates.pem,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/certificates/resources/:id/pem",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.certificates.export,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/certificates/resources/:id/export",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.certificates.revoke,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/certificates/resources/:id/revoke",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.catalog,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/catalog",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.queries,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.start,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.result,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.query.stop,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/query/resources/:id/queries/:queryId/stop",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.datacatalog.tables,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.datacatalog.table,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/datacatalog/resources/:id/tables/:tableName",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTargets,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/mount-targets",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTarget,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/mount-targets/:mountTargetId",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoints,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/access-points",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoint,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/access-points/:accessPointId",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.functions,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/functions",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.filesystems.function,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/filesystems/resources/:id/functions/:functionName",
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/autoscaling/resources/:id/capacity",
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
apiEndpointKeys.clouds.autoscaling.terminate,
|
||||||
|
{
|
||||||
|
path: "/clouds/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
apiEndpointKeys.clouds.cdn.enabled,
|
apiEndpointKeys.clouds.cdn.enabled,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ import type {
|
|||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
UserPoolClientSettings,
|
||||||
DistributionInvalidation,
|
DistributionInvalidation,
|
||||||
|
AutoScalingCapacityInput,
|
||||||
|
AccessPointInput,
|
||||||
|
CatalogTableInput,
|
||||||
|
QueryCatalog,
|
||||||
|
QueryExecutionSummary,
|
||||||
|
QueryInput,
|
||||||
|
QueryResult,
|
||||||
|
FileSystemAttachmentInput,
|
||||||
|
CertificatePem,
|
||||||
|
CertificateRevocationReason,
|
||||||
|
ExportedCertificate,
|
||||||
|
AlarmHistoryItem,
|
||||||
|
AlarmStateValue,
|
||||||
|
MetricSeries,
|
||||||
|
MetricSeriesQuery,
|
||||||
|
MetricSummary,
|
||||||
UpdateUserPoolClientOAuthInput,
|
UpdateUserPoolClientOAuthInput,
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
LogsInsightsQueryInput,
|
LogsInsightsQueryInput,
|
||||||
@@ -1477,6 +1493,436 @@ export async function testUserPoolSignIn(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMetrics(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
query: { namespace?: string; search?: string } = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MetricSummary[]> {
|
||||||
|
const res = await apiClient.call<MetricSummary[]>(
|
||||||
|
apiEndpointKeys.clouds.monitoring.metrics,
|
||||||
|
requestOptions(cloud, "monitoring", { signal, params: { namespace: query.namespace || undefined, search: query.search || undefined } }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetricSeries(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
query: MetricSeriesQuery,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MetricSeries> {
|
||||||
|
const res = await apiClient.call<MetricSeries, MetricSeriesQuery>(
|
||||||
|
apiEndpointKeys.clouds.monitoring.metricData,
|
||||||
|
requestOptions(cloud, "monitoring", { signal, body: query }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function describeAlarmHistory(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
alarmName: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AlarmHistoryItem[]> {
|
||||||
|
const res = await apiClient.call<AlarmHistoryItem[]>(
|
||||||
|
apiEndpointKeys.clouds.monitoring.history,
|
||||||
|
requestOptions(cloud, "monitoring", { signal }),
|
||||||
|
{ cloud, id: alarmName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAlarmState(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
alarmName: string,
|
||||||
|
state: AlarmStateValue,
|
||||||
|
reason: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, { state: AlarmStateValue; reason: string }>(
|
||||||
|
apiEndpointKeys.clouds.monitoring.state,
|
||||||
|
requestOptions(cloud, "monitoring", { body: { state, reason } }),
|
||||||
|
{ cloud, id: alarmName },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAlarmActionsEnabled(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
alarmName: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, { enabled: boolean }>(
|
||||||
|
apiEndpointKeys.clouds.monitoring.actions,
|
||||||
|
requestOptions(cloud, "monitoring", { body: { enabled } }),
|
||||||
|
{ cloud, id: alarmName },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpApiProtocol = "HTTP" | "WEBSOCKET";
|
||||||
|
|
||||||
|
export interface HttpApiIntegrationOverview {
|
||||||
|
integrationId: string;
|
||||||
|
type: string;
|
||||||
|
uri: string | null;
|
||||||
|
functionName: string | null;
|
||||||
|
payloadFormatVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiRouteOverview {
|
||||||
|
routeId: string;
|
||||||
|
routeKey: string;
|
||||||
|
integration: HttpApiIntegrationOverview | null;
|
||||||
|
authorizationType: string;
|
||||||
|
authorizerId: string | null;
|
||||||
|
authorizationScopes: string[];
|
||||||
|
returnsResponse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiStageOverview {
|
||||||
|
stageName: string;
|
||||||
|
autoDeploy: boolean;
|
||||||
|
deploymentId: string | null;
|
||||||
|
lastUpdatedAt: string | null;
|
||||||
|
invokePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiAuthorizerOverview {
|
||||||
|
authorizerId: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
identitySource: string[];
|
||||||
|
issuer: string | null;
|
||||||
|
audience: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiCors {
|
||||||
|
allowOrigins: string[];
|
||||||
|
allowMethods: string[];
|
||||||
|
allowHeaders: string[];
|
||||||
|
exposeHeaders: string[];
|
||||||
|
maxAgeSeconds: number | null;
|
||||||
|
allowCredentials: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiOverview {
|
||||||
|
apiId: string;
|
||||||
|
protocol: HttpApiProtocol;
|
||||||
|
apiEndpoint: string | null;
|
||||||
|
routeSelectionExpression: string | null;
|
||||||
|
routes: HttpApiRouteOverview[];
|
||||||
|
stages: HttpApiStageOverview[];
|
||||||
|
authorizers: HttpApiAuthorizerOverview[];
|
||||||
|
cors: HttpApiCors | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiRouteInput {
|
||||||
|
routeKey: string;
|
||||||
|
functionName?: string;
|
||||||
|
httpUrl?: string;
|
||||||
|
authorizerId?: string;
|
||||||
|
returnsResponse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiAuthorizerInput {
|
||||||
|
name: string;
|
||||||
|
userPoolId?: string;
|
||||||
|
userPoolClientId?: string;
|
||||||
|
issuer?: string;
|
||||||
|
audience?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpApiWebSocketTestResult {
|
||||||
|
connected: boolean;
|
||||||
|
messages: string[];
|
||||||
|
closeCode: number | null;
|
||||||
|
error: string | null;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise<HttpApiOverview> {
|
||||||
|
const res = await apiClient.call<HttpApiOverview>(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||||
|
const res = await apiClient.call<HttpApiRouteOverview, HttpApiRouteInput>(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
|
||||||
|
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||||
|
const res = await apiClient.call<HttpApiRouteOverview, { authorizerId: string | null }>(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise<HttpApiStageOverview> {
|
||||||
|
const res = await apiClient.call<HttpApiStageOverview, typeof input>(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
|
||||||
|
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||||
|
const res = await apiClient.call<HttpApiStageOverview>(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||||
|
const res = await apiClient.call<HttpApiAuthorizerOverview, HttpApiAuthorizerInput>(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
|
||||||
|
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||||
|
const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId });
|
||||||
|
return res.data.cors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise<HttpApiWebSocketTestResult> {
|
||||||
|
const res = await apiClient.call<HttpApiWebSocketTestResult, typeof input>(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCertificatePem(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
certificateId: string,
|
||||||
|
): Promise<CertificatePem> {
|
||||||
|
const res = await apiClient.call<CertificatePem>(
|
||||||
|
apiEndpointKeys.clouds.certificates.pem,
|
||||||
|
requestOptions(cloud, "certificates"),
|
||||||
|
{ cloud, id: certificateId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportCertificate(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
certificateId: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<ExportedCertificate> {
|
||||||
|
const res = await apiClient.call<ExportedCertificate, { passphrase: string }>(
|
||||||
|
apiEndpointKeys.clouds.certificates.export,
|
||||||
|
requestOptions(cloud, "certificates", { body: { passphrase } }),
|
||||||
|
{ cloud, id: certificateId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCertificate(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
certificateId: string,
|
||||||
|
reason: CertificateRevocationReason,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, { reason: CertificateRevocationReason }>(
|
||||||
|
apiEndpointKeys.clouds.certificates.revoke,
|
||||||
|
requestOptions(cloud, "certificates", { body: { reason } }),
|
||||||
|
{ cloud, id: certificateId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function describeQueryCatalog(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
database?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryCatalog> {
|
||||||
|
const res = await apiClient.call<QueryCatalog>(
|
||||||
|
apiEndpointKeys.clouds.query.catalog,
|
||||||
|
requestOptions(cloud, "query", { signal, params: { database } }),
|
||||||
|
{ cloud },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listQueries(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryExecutionSummary[]> {
|
||||||
|
const res = await apiClient.call<QueryExecutionSummary[]>(
|
||||||
|
apiEndpointKeys.clouds.query.queries,
|
||||||
|
requestOptions(cloud, "query", { signal }),
|
||||||
|
{ cloud, id: workGroup },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
input: QueryInput,
|
||||||
|
): Promise<QueryExecutionSummary> {
|
||||||
|
const res = await apiClient.call<QueryExecutionSummary, QueryInput>(
|
||||||
|
apiEndpointKeys.clouds.query.start,
|
||||||
|
requestOptions(cloud, "query", { body: input }),
|
||||||
|
{ cloud, id: workGroup },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueryResult(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
queryId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryResult> {
|
||||||
|
const res = await apiClient.call<QueryResult>(
|
||||||
|
apiEndpointKeys.clouds.query.result,
|
||||||
|
requestOptions(cloud, "query", { signal }),
|
||||||
|
{ cloud, id: workGroup, queryId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopQuery(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
workGroup: string,
|
||||||
|
queryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void>(
|
||||||
|
apiEndpointKeys.clouds.query.stop,
|
||||||
|
requestOptions(cloud, "query"),
|
||||||
|
{ cloud, id: workGroup, queryId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCatalogTable(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
databaseName: string,
|
||||||
|
input: CatalogTableInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, CatalogTableInput>(
|
||||||
|
apiEndpointKeys.clouds.datacatalog.tables,
|
||||||
|
requestOptions(cloud, "datacatalog", { body: input }),
|
||||||
|
{ cloud, id: databaseName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCatalogTable(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
databaseName: string,
|
||||||
|
tableName: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.datacatalog.table,
|
||||||
|
requestOptions(cloud, "datacatalog"),
|
||||||
|
{ cloud, id: databaseName, tableName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMountTarget(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
subnetId: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, { subnetId: string }>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTargets,
|
||||||
|
requestOptions(cloud, "filesystems", { body: { subnetId } }),
|
||||||
|
{ cloud, id: fileSystemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMountTarget(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
mountTargetId: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.mountTarget,
|
||||||
|
requestOptions(cloud, "filesystems"),
|
||||||
|
{ cloud, id: fileSystemId, mountTargetId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccessPoint(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
input: AccessPointInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, AccessPointInput>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoints,
|
||||||
|
requestOptions(cloud, "filesystems", { body: input }),
|
||||||
|
{ cloud, id: fileSystemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccessPoint(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
accessPointId: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.accessPoint,
|
||||||
|
requestOptions(cloud, "filesystems"),
|
||||||
|
{ cloud, id: fileSystemId, accessPointId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachFileSystemToFunction(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
input: FileSystemAttachmentInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, FileSystemAttachmentInput>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.functions,
|
||||||
|
requestOptions(cloud, "filesystems", { body: input }),
|
||||||
|
{ cloud, id: fileSystemId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detachFileSystemFromFunction(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
fileSystemId: string,
|
||||||
|
functionName: string,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource>(
|
||||||
|
apiEndpointKeys.clouds.filesystems.function,
|
||||||
|
requestOptions(cloud, "filesystems"),
|
||||||
|
{ cloud, id: fileSystemId, functionName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAutoScalingCapacity(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
groupName: string,
|
||||||
|
input: AutoScalingCapacityInput,
|
||||||
|
): Promise<CloudResource> {
|
||||||
|
const res = await apiClient.call<CloudResource, AutoScalingCapacityInput>(
|
||||||
|
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||||
|
requestOptions(cloud, "autoscaling", { body: input }),
|
||||||
|
{ cloud, id: groupName },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminateAutoScalingInstance(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
groupName: string,
|
||||||
|
instanceId: string,
|
||||||
|
replace: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.call<void, { replace: boolean }>(
|
||||||
|
apiEndpointKeys.clouds.autoscaling.terminate,
|
||||||
|
requestOptions(cloud, "autoscaling", { body: { replace } }),
|
||||||
|
{ cloud, id: groupName, instanceId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setDistributionEnabled(
|
export async function setDistributionEnabled(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
distributionId: string,
|
distributionId: string,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
|
|||||||
import {CopyValueButton} from './CopyValueButton'
|
import {CopyValueButton} from './CopyValueButton'
|
||||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||||
import {ServiceIcon} from '@/components/serviceIcons'
|
import {ServiceIcon} from '@/components/serviceIcons'
|
||||||
|
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
|
||||||
|
|
||||||
interface ApiGatewayRoutePanelProps {
|
interface ApiGatewayRoutePanelProps {
|
||||||
cloud: CloudProvider
|
cloud: CloudProvider
|
||||||
@@ -138,8 +139,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
return (
|
return (
|
||||||
<section className="table-panel">
|
<section className="table-panel">
|
||||||
<div className="empty compact">
|
<div className="empty compact">
|
||||||
<h3>Select a REST API</h3>
|
<h3>Select an API</h3>
|
||||||
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
|
<p>Select an API to see its routes, or create one.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -513,49 +514,6 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function invokeEndpoint(runtimeEndpoint?: string): string | null {
|
|
||||||
if (!runtimeEndpoint) return null
|
|
||||||
try {
|
|
||||||
const endpoint = new URL(runtimeEndpoint)
|
|
||||||
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
|
|
||||||
endpoint.hostname = window.location.hostname
|
|
||||||
}
|
|
||||||
return endpoint.toString().replace(/\/$/, '')
|
|
||||||
} catch {
|
|
||||||
return runtimeEndpoint.replace(/\/$/, '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDockerBridgeAddress(hostname: string): boolean {
|
|
||||||
const octets = hostname.split('.').map(Number)
|
|
||||||
return octets.length === 4
|
|
||||||
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
|
|
||||||
&& octets[0] === 172
|
|
||||||
&& octets[1] >= 16
|
|
||||||
&& octets[1] <= 31
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHeaders(value: string): Record<string, string> {
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(value || '{}')
|
|
||||||
} catch {
|
|
||||||
throw new Error('Request headers must be valid JSON.')
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
|
|
||||||
throw new Error('Request headers must be a JSON object with string values.')
|
|
||||||
}
|
|
||||||
return parsed as Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatResponseBody(body: string): string {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(body), null, 2)
|
|
||||||
} catch {
|
|
||||||
return body || '(empty response body)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
|
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
|
||||||
let headers: Record<string, string> = {}
|
let headers: Record<string, string> = {}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {AutoScalingPanel} from './AutoScalingPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
setAutoScalingCapacity: vi.fn(),
|
||||||
|
terminateAutoScalingInstance: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
function group(metadata: Record<string, unknown> = {}): CloudResource {
|
||||||
|
return {
|
||||||
|
id: 'web-asg', name: 'web-asg', cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: 'us-east-1', createdAt: null, status: null,
|
||||||
|
metadata: {
|
||||||
|
minSize: 1, desiredCapacity: 2, maxSize: 3, capacity: '2 in service',
|
||||||
|
instances: [
|
||||||
|
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
|
||||||
|
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
|
||||||
|
],
|
||||||
|
activities: [{id: 'a1', description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', cause: 'An instance was started in response to a desired capacity change.', status: 'Successful', statusMessage: null, startTime: null}],
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><AutoScalingPanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.setAutoScalingCapacity.mockImplementation(async (_cloud, _id, input) => group({desiredCapacity: input.desiredCapacity}))
|
||||||
|
api.terminateAutoScalingInstance.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the instances and what launched them', async () => {
|
||||||
|
renderPanel(group())
|
||||||
|
|
||||||
|
expect(await screen.findByText('i-a3155c876867976b1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Launching a new EC2 instance: i-b8736c8b4a8c129a3')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/never runs them/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('changes the desired capacity', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(group())
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Group capacity'})
|
||||||
|
const update = within(form).getByRole('button', {name: 'Update capacity'})
|
||||||
|
expect(update).toBeDisabled()
|
||||||
|
const desired = within(form).getByLabelText('Desired')
|
||||||
|
await user.clear(desired)
|
||||||
|
await user.type(desired, '3')
|
||||||
|
await user.click(update)
|
||||||
|
|
||||||
|
expect(api.setAutoScalingCapacity).toHaveBeenCalledWith('aws', 'web-asg', {minSize: 1, desiredCapacity: 3, maxSize: 3})
|
||||||
|
expect(await screen.findByText(/Capacity set to 3/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('replaces an instance, or removes one while above the minimum', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderPanel(group())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'}))
|
||||||
|
expect(api.terminateAutoScalingInstance).toHaveBeenCalledWith('aws', 'web-asg', 'i-b8736c8b4a8c129a3', true)
|
||||||
|
expect(await screen.findByText(/launches a replacement/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Remove i-a3155c876867976b1'}))
|
||||||
|
expect(api.terminateAutoScalingInstance).toHaveBeenLastCalledWith('aws', 'web-asg', 'i-a3155c876867976b1', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cannot remove an instance from a group at its minimum', async () => {
|
||||||
|
renderPanel(group({minSize: 2}))
|
||||||
|
expect(await screen.findByRole('button', {name: 'Remove i-b8736c8b4a8c129a3'})).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'})).toBeEnabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {RefreshCw} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {getCloudResource, setAutoScalingCapacity, terminateAutoScalingInstance} from '@/api/cloudProxyClient'
|
||||||
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Instance = {id: string; availabilityZone: string | null; lifecycleState: string | null; health: string | null; instanceType: string | null}
|
||||||
|
type Activity = {id: string; description: string; cause: string; status: string | null; statusMessage: string | null; startTime: string | null}
|
||||||
|
type Capacity = {minSize: string; desiredCapacity: string; maxSize: string}
|
||||||
|
|
||||||
|
/** The runtime reconciles every 10 seconds, so a group that is still moving is re-read this often. */
|
||||||
|
const SETTLING_POLL_MS = 4000
|
||||||
|
|
||||||
|
export function AutoScalingPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [capacity, setCapacity] = useState<Capacity>({minSize: '', desiredCapacity: '', maxSize: ''})
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const groupKey = ['autoscaling-group', accountId, resource?.id]
|
||||||
|
|
||||||
|
const groupQuery = useQuery({
|
||||||
|
queryKey: groupKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'autoscaling', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
refetchInterval: (query) => query.state.data && !settled(query.state.data) ? SETTLING_POLL_MS : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = groupQuery.data ?? resource
|
||||||
|
useEffect(() => {
|
||||||
|
setNotice('')
|
||||||
|
}, [resource?.id])
|
||||||
|
// The form follows the group's saved capacity whenever it changes, here or elsewhere.
|
||||||
|
const savedMin = String(group?.metadata.minSize ?? '')
|
||||||
|
const savedDesired = String(group?.metadata.desiredCapacity ?? '')
|
||||||
|
const savedMax = String(group?.metadata.maxSize ?? '')
|
||||||
|
useEffect(() => {
|
||||||
|
setCapacity({minSize: savedMin, desiredCapacity: savedDesired, maxSize: savedMax})
|
||||||
|
}, [group?.id, savedMin, savedDesired, savedMax])
|
||||||
|
|
||||||
|
const refresh = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({queryKey: groupKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'autoscaling')}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const capacityMutation = useMutation({
|
||||||
|
mutationFn: () => setAutoScalingCapacity('aws', resource?.id ?? '', {
|
||||||
|
minSize: Number(capacity.minSize),
|
||||||
|
desiredCapacity: Number(capacity.desiredCapacity),
|
||||||
|
maxSize: Number(capacity.maxSize),
|
||||||
|
}),
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
setNotice(`Capacity set to ${String(updated.metadata.desiredCapacity)}. TheCloud launches or terminates instances within about 10 seconds.`)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const terminateMutation = useMutation({
|
||||||
|
mutationFn: ({instanceId, replace}: {instanceId: string; replace: boolean}) => terminateAutoScalingInstance('aws', resource?.id ?? '', instanceId, replace),
|
||||||
|
onSuccess: (_result, {instanceId, replace}) => {
|
||||||
|
setNotice(replace ? `Terminating ${instanceId}. The group launches a replacement to stay at its desired capacity.` : `Terminating ${instanceId} and lowering the desired capacity by one.`)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource || !group) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select an Auto Scaling group</h3><p>Choose a group to change its capacity, see its instances and follow its scaling activity.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = group.metadata
|
||||||
|
const instances = Array.isArray(metadata.instances) ? metadata.instances as Instance[] : []
|
||||||
|
const activities = Array.isArray(metadata.activities) ? metadata.activities as Activity[] : []
|
||||||
|
const desired = Number(metadata.desiredCapacity ?? 0)
|
||||||
|
const min = Number(metadata.minSize ?? 0)
|
||||||
|
const pending = capacityMutation.isPending || terminateMutation.isPending
|
||||||
|
const disabled = !runtimeReachable || pending
|
||||||
|
const error = groupQuery.error ?? capacityMutation.error ?? terminateMutation.error
|
||||||
|
const changed = capacity.minSize !== String(metadata.minSize) || capacity.desiredCapacity !== String(metadata.desiredCapacity) || capacity.maxSize !== String(metadata.maxSize)
|
||||||
|
const field = (name: keyof Capacity, label: string) => <label className="dynamic-field"><span>{label}</span>
|
||||||
|
<input className="input" type="number" min={0} max={20} step={1} value={capacity[name]} onChange={(event) => setCapacity({...capacity, [name]: event.target.value})} disabled={disabled} required/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Auto Scaling group ${group.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Auto Scaling group</p><span className="muted">{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || groupQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{groupQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="dynamic-form autoscaling-capacity" aria-label="Group capacity" onSubmit={(event) => { event.preventDefault(); setNotice(''); capacityMutation.mutate() }}>
|
||||||
|
{field('minSize', 'Minimum')}
|
||||||
|
{field('desiredCapacity', 'Desired')}
|
||||||
|
{field('maxSize', 'Maximum')}
|
||||||
|
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{capacityMutation.isPending ? 'Updating' : 'Update capacity'}</button></div>
|
||||||
|
</form>
|
||||||
|
<p className="muted compact-text autoscaling-note">Scaling policies are not offered: TheCloud stores EC2 scaling policies but never runs them, so set the capacity here. Alarm-driven scaling works for ECS services through Application Auto Scaling.</p>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Instances</p><span className="muted">Each is a real EC2 instance in TheCloud, launched from the group's template.</span></div></div>
|
||||||
|
{instances.length === 0 ? <p className="muted compact-text autoscaling-note">No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Instance</th><th>State</th><th>Health</th><th>Zone</th><th>Type</th><th aria-label="Actions"/></tr></thead><tbody>{instances.map((instance) => (
|
||||||
|
<tr key={instance.id}>
|
||||||
|
<td className="mono">{instance.id}</td>
|
||||||
|
<td><code>{instance.lifecycleState ?? '—'}</code></td>
|
||||||
|
<td>{instance.health ?? '—'}</td>
|
||||||
|
<td>{instance.availabilityZone ?? '—'}</td>
|
||||||
|
<td>{instance.instanceType ?? '—'}</td>
|
||||||
|
<td className="autoscaling-instance-actions">
|
||||||
|
<button className="button compact" type="button" disabled={disabled || instance.lifecycleState !== 'InService'} aria-label={`Replace ${instance.id}`} onClick={() => { setNotice(''); terminateMutation.mutate({instanceId: instance.id, replace: true}) }}>Replace</button>
|
||||||
|
<button className="button compact danger" type="button" disabled={disabled || instance.lifecycleState !== 'InService' || desired - 1 < min} title={desired - 1 < min ? 'The group is at its minimum size' : undefined} aria-label={`Remove ${instance.id}`} onClick={() => {
|
||||||
|
setNotice('')
|
||||||
|
if (window.confirm(`Terminate ${instance.id} and lower the desired capacity to ${desired - 1}?`)) terminateMutation.mutate({instanceId: instance.id, replace: false})
|
||||||
|
}}>Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
{activities.length > 0 && <>
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Activity</p><span className="muted">The most recent launches and terminations.</span></div></div>
|
||||||
|
<div className="cognito-table"><table className="table"><thead><tr><th>Started</th><th>Status</th><th>Activity</th></tr></thead><tbody>{activities.map((activity) => (
|
||||||
|
<tr key={activity.id}>
|
||||||
|
<td>{formatDateTime(activity.startTime) ?? '—'}</td>
|
||||||
|
<td><code>{activity.status ?? '—'}</code></td>
|
||||||
|
<td>{activity.description}<br/><small className="muted">{activity.statusMessage || activity.cause}</small></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</>}
|
||||||
|
{metadata.activitiesUnavailable === true && <p className="muted compact-text autoscaling-note">The scaling activity could not be read just now.</p>}
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settled once every desired instance is in service and none is still starting or stopping. */
|
||||||
|
function settled(group: CloudResource): boolean {
|
||||||
|
const instances = Array.isArray(group.metadata.instances) ? group.metadata.instances as Instance[] : []
|
||||||
|
const inService = instances.filter((instance) => instance.lifecycleState === 'InService').length
|
||||||
|
return inService === Number(group.metadata.desiredCapacity ?? 0) && instances.every((instance) => instance.lifecycleState === 'InService')
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ export function AzureSqlPanel({
|
|||||||
)}
|
)}
|
||||||
<small className="sql-credential-note">
|
<small className="sql-credential-note">
|
||||||
{isAwsRds
|
{isAwsRds
|
||||||
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
|
? "Queries use TheCloud's local RDS Data API credentials. Database passwords are not sent by the console."
|
||||||
: "Credentials remain in this page only and are sent to the local API per request."}
|
: "Credentials remain in this page only and are sent to the local API per request."}
|
||||||
</small>
|
</small>
|
||||||
{connectionError && <div className="form-error">{connectionError}</div>}
|
{connectionError && <div className="form-error">{connectionError}</div>}
|
||||||
@@ -243,7 +243,7 @@ export function AzureSqlPanel({
|
|||||||
{!connected ? (
|
{!connected ? (
|
||||||
<div className="empty compact sql-connect-empty">
|
<div className="empty compact sql-connect-empty">
|
||||||
<h3>Connect to browse SQL data</h3>
|
<h3>Connect to browse SQL data</h3>
|
||||||
<p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
|
<p>{isAwsRds ? "Connect through TheCloud's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="sql-explorer">
|
<div className="sql-explorer">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {CertificatePanel} from './CertificatePanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
getCertificatePem: vi.fn(),
|
||||||
|
exportCertificate: vi.fn(),
|
||||||
|
revokeCertificate: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
|
||||||
|
function certificate(metadata: Record<string, unknown> = {}, status = 'ISSUED'): CloudResource {
|
||||||
|
return {
|
||||||
|
id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate',
|
||||||
|
region: 'us-east-1', createdAt: null, status,
|
||||||
|
metadata: {
|
||||||
|
certificateType: 'Amazon issued', exportable: false, inUseBy: [],
|
||||||
|
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><CertificatePanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'})
|
||||||
|
api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'})
|
||||||
|
api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the validation records and that nothing uses the certificate', async () => {
|
||||||
|
renderPanel(certificate())
|
||||||
|
|
||||||
|
expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument()
|
||||||
|
// Export and revoke exist only for certificates requested as exportable, as on AWS.
|
||||||
|
expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists what uses the certificate, since ACM will not delete it then', async () => {
|
||||||
|
renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']}))
|
||||||
|
|
||||||
|
expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gets the certificate and chain as downloadable PEM files', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(certificate())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Get certificate'}))
|
||||||
|
expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF')
|
||||||
|
expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA')
|
||||||
|
expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exports an exportable certificate with the passphrase, then revokes it', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderPanel(certificate({exportable: true}))
|
||||||
|
|
||||||
|
const exportForm = await screen.findByRole('form', {name: 'Export certificate'})
|
||||||
|
await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame')
|
||||||
|
await user.click(within(exportForm).getByRole('button', {name: 'Export'}))
|
||||||
|
expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame')
|
||||||
|
expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----')
|
||||||
|
|
||||||
|
const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'})
|
||||||
|
await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED')
|
||||||
|
await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'}))
|
||||||
|
expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED')
|
||||||
|
expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {Download, RefreshCw} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient'
|
||||||
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null}
|
||||||
|
|
||||||
|
const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [
|
||||||
|
{value: 'UNSPECIFIED', label: 'Unspecified'},
|
||||||
|
{value: 'KEY_COMPROMISE', label: 'Key compromise'},
|
||||||
|
{value: 'SUPERSEDED', label: 'Superseded'},
|
||||||
|
{value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'},
|
||||||
|
{value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'},
|
||||||
|
{value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'},
|
||||||
|
{value: 'CA_COMPROMISE', label: 'CA compromise'},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function CertificatePanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [passphrase, setPassphrase] = useState('')
|
||||||
|
const [reason, setReason] = useState<CertificateRevocationReason>('UNSPECIFIED')
|
||||||
|
const [pem, setPem] = useState<CertificatePem | ExportedCertificate | null>(null)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const certificateKey = ['acm-certificate', accountId, resource?.id]
|
||||||
|
|
||||||
|
// The table row is a ListCertificates summary; validation records and users come from DescribeCertificate.
|
||||||
|
const certificateQuery = useQuery({
|
||||||
|
queryKey: certificateKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPassphrase('')
|
||||||
|
setReason('UNSPECIFIED')
|
||||||
|
setPem(null)
|
||||||
|
setNotice('')
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const refresh = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({queryKey: certificateKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const pemMutation = useMutation({
|
||||||
|
mutationFn: () => getCertificatePem('aws', resource?.id ?? ''),
|
||||||
|
onSuccess: setPem,
|
||||||
|
})
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase),
|
||||||
|
onSuccess: (exported) => {
|
||||||
|
setPem(exported)
|
||||||
|
setPassphrase('')
|
||||||
|
setNotice('Exported. The private key is encrypted with your passphrase.')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNotice('Certificate revoked. It can no longer be renewed or exported.')
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a certificate</h3><p>Choose a certificate to see its validation records, where it is used, and to download or export it.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificate = certificateQuery.data ?? resource
|
||||||
|
const metadata = certificate.metadata
|
||||||
|
const validations = list<DomainValidation>(metadata.domainValidations)
|
||||||
|
const inUseBy = list<string>(metadata.inUseBy)
|
||||||
|
const exportable = metadata.exportable === true
|
||||||
|
const issued = certificate.status === 'ISSUED'
|
||||||
|
const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending
|
||||||
|
const disabled = !runtimeReachable || pending
|
||||||
|
const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error
|
||||||
|
const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_')
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Certificate ${certificate.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Certificate</p><span className="muted">{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); pemMutation.mutate() }}><Download size={14}/>{pemMutation.isPending ? 'Loading' : 'Get certificate'}</button>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || certificateQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{certificateQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validations.length > 0 && <>
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Domains</p><span className="muted">On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.</span></div></div>
|
||||||
|
<div className="cognito-table"><table className="table"><thead><tr><th>Domain</th><th>Validation</th><th>CNAME name</th><th>CNAME value</th></tr></thead><tbody>{validations.map((validation) => (
|
||||||
|
<tr key={validation.domainName}>
|
||||||
|
<td className="mono">{validation.domainName}</td>
|
||||||
|
<td><code>{validation.status ?? '—'}</code>{validation.method ? ` ${validation.method}` : ''}</td>
|
||||||
|
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.name}<CopyValueButton value={validation.record.name} label="CNAME name"/></span> : '—'}</td>
|
||||||
|
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.value}<CopyValueButton value={validation.record.value} label="CNAME value"/></span> : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">In use by</p><span className="muted">{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}</span></div></div>
|
||||||
|
{inUseBy.length > 0 && <ul className="certificate-users">{inUseBy.map((arn) => <li key={arn} className="mono">{arn}</li>)}</ul>}
|
||||||
|
|
||||||
|
{exportable && issued && <>
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Export</p><span className="muted">Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.</span></div></div>
|
||||||
|
<form className="dynamic-form" aria-label="Export certificate" onSubmit={(event) => { event.preventDefault(); setNotice(''); exportMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field"><span>Passphrase</span>
|
||||||
|
<input className="input" type="password" autoComplete="new-password" value={passphrase} onChange={(event) => setPassphrase(event.target.value)} disabled={disabled} minLength={4} maxLength={128} required/>
|
||||||
|
<small className="muted">4 to 128 characters, without #, $ or %. You need it to decrypt the key.</small>
|
||||||
|
</label>
|
||||||
|
<div><button className="button primary" type="submit" disabled={disabled || passphrase.length < 4}>{exportMutation.isPending ? 'Exporting' : 'Export'}</button></div>
|
||||||
|
</form>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{exportable && certificate.status !== 'REVOKED' && <>
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Revoke</p><span className="muted">Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.</span></div></div>
|
||||||
|
<form className="dynamic-form" aria-label="Revoke certificate" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setNotice('')
|
||||||
|
if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field"><span>Reason</span>
|
||||||
|
<select className="input" value={reason} onChange={(event) => setReason(event.target.value as CertificateRevocationReason)} disabled={disabled}>
|
||||||
|
{REVOCATION_REASONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div><button className="button danger" type="submit" disabled={disabled}>{revokeMutation.isPending ? 'Revoking' : 'Revoke'}</button></div>
|
||||||
|
</form>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{pem && <div className="certificate-pem">
|
||||||
|
<PemBlock label="Certificate" value={pem.certificate} file={`${fileStem}.pem`}/>
|
||||||
|
{pem.certificateChain && <PemBlock label="Certificate chain" value={pem.certificateChain} file={`${fileStem}-chain.pem`}/>}
|
||||||
|
{'privateKey' in pem && <PemBlock label="Private key (encrypted)" value={pem.privateKey} file={`${fileStem}-key.pem`}/>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PemBlock({label, value, file}: {label: string; value: string; file: string}) {
|
||||||
|
const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}`
|
||||||
|
return <div className="certificate-pem-block">
|
||||||
|
<div className="certificate-pem-head">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<CopyValueButton value={value} label={label}/>
|
||||||
|
<a className="button" href={href} download={file}><Download size={13}/> {file}</a>
|
||||||
|
</div>
|
||||||
|
<textarea className="textarea code-textarea small" rows={6} value={value} readOnly aria-label={label}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function list<T>(value: unknown): T[] {
|
||||||
|
return Array.isArray(value) ? value as T[] : []
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ const api = vi.hoisted(() => ({
|
|||||||
vi.mock('@/api/cloudProxyClient', () => api)
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/'
|
const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost:4566/'
|
||||||
const distribution: CloudResource = {
|
const distribution: CloudResource = {
|
||||||
id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution',
|
id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution',
|
||||||
region: null, createdAt: null, status: 'Deployed',
|
region: null, createdAt: null, status: 'Deployed',
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {CloudWatchAlarmPanel} from './CloudWatchAlarmPanel'
|
||||||
|
import {HttpError} from '@/api/HttpClient'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
describeAlarmHistory: vi.fn(),
|
||||||
|
getMetricSeries: vi.fn(),
|
||||||
|
setAlarmState: vi.fn(),
|
||||||
|
setAlarmActionsEnabled: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const alarm: CloudResource = {
|
||||||
|
id: 'high-errors', name: 'high-errors', cloud: 'aws', service: 'monitoring', type: 'alarm', region: 'us-east-1', createdAt: null, status: 'ALARM',
|
||||||
|
metadata: {
|
||||||
|
alarmType: 'metric', stateReason: 'Threshold crossed', actionsEnabled: true, alarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], okActions: [],
|
||||||
|
namespace: 'App', metricName: 'Errors', dimensions: [], statistic: 'Sum', threshold: 5, condition: 'Errors Sum > 5 for 1 × 5 min',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><CloudWatchAlarmPanel resource={alarm} runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getCloudResource.mockResolvedValue(alarm)
|
||||||
|
api.describeAlarmHistory.mockResolvedValue([{timestamp: null, type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||||
|
api.getMetricSeries.mockResolvedValue({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 7}]})
|
||||||
|
api.setAlarmState.mockResolvedValue(undefined)
|
||||||
|
api.setAlarmActionsEnabled.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the state, its metric with the threshold, the targets and the history', async () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(screen.getByText('Threshold crossed')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('Threshold 5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('arn:aws:sns:us-east-1:000000000000:alerts')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('Alarm updated from OK to ALARM')).toBeInTheDocument()
|
||||||
|
expect(api.getMetricSeries).toHaveBeenCalledWith('aws', expect.objectContaining({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 180}), expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sets a state to test the actions, and turns the actions off', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Set alarm state'})
|
||||||
|
await user.selectOptions(within(form).getByLabelText('State'), 'OK')
|
||||||
|
await user.type(within(form).getByLabelText('Reason'), 'Testing')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Set state'}))
|
||||||
|
expect(api.setAlarmState).toHaveBeenCalledWith('aws', 'high-errors', 'OK', 'Testing')
|
||||||
|
expect(await screen.findByText(/Set to OK/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Turn actions off'}))
|
||||||
|
expect(api.setAlarmActionsEnabled).toHaveBeenCalledWith('aws', 'high-errors', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says when the runtime does not record alarm history, without an error', async () => {
|
||||||
|
api.describeAlarmHistory.mockRejectedValue(new HttpError(501, 'Not Implemented', {code: 'operation_not_implemented', error: 'Operation is not implemented by the selected runtime'}, '/history'))
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByText(/does not record alarm history yet/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {RefreshCw} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {describeAlarmHistory, getCloudResource, getMetricSeries, setAlarmActionsEnabled, setAlarmState} from '@/api/cloudProxyClient'
|
||||||
|
import {HttpError} from '@/api/HttpClient'
|
||||||
|
import {MetricChart} from '@/components/MetricChart'
|
||||||
|
import {RANGES} from '@/lib/metricChart'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {AlarmStateValue, CloudResource, MetricDimension} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATES: AlarmStateValue[] = ['ALARM', 'OK', 'INSUFFICIENT_DATA']
|
||||||
|
|
||||||
|
export function CloudWatchAlarmPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||||
|
const [nextState, setNextState] = useState<AlarmStateValue>('ALARM')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const alarmKey = ['cloudwatch-alarm', accountId, resource?.id]
|
||||||
|
const historyKey = ['cloudwatch-alarm-history', accountId, resource?.id]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotice('')
|
||||||
|
setReason('')
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const alarmQuery = useQuery({
|
||||||
|
queryKey: alarmKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'monitoring', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: historyKey,
|
||||||
|
queryFn: ({signal}) => describeAlarmHistory('aws', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const alarm = alarmQuery.data ?? resource
|
||||||
|
const metadata = alarm?.metadata ?? {}
|
||||||
|
const isMetric = metadata.alarmType === 'metric' && typeof metadata.metricName === 'string'
|
||||||
|
const dimensions = Array.isArray(metadata.dimensions) ? metadata.dimensions as MetricDimension[] : []
|
||||||
|
const seriesQuery = useQuery({
|
||||||
|
queryKey: ['cloudwatch-alarm-series', accountId, resource?.id, rangeMinutes, metadata.metricName, metadata.statistic],
|
||||||
|
queryFn: ({signal}) => getMetricSeries('aws', {
|
||||||
|
namespace: String(metadata.namespace),
|
||||||
|
metricName: String(metadata.metricName),
|
||||||
|
dimensions,
|
||||||
|
statistic: String(metadata.statistic ?? 'Average'),
|
||||||
|
rangeMinutes,
|
||||||
|
}, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable && isMetric,
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({queryKey: alarmKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: historyKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'monitoring']}),
|
||||||
|
])
|
||||||
|
const stateMutation = useMutation({
|
||||||
|
mutationFn: () => setAlarmState('aws', resource?.id ?? '', nextState, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNotice(`Set to ${nextState}. Its actions ran if they are on; CloudWatch puts it back at the next evaluation.`)
|
||||||
|
setReason('')
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const actionsMutation = useMutation({
|
||||||
|
mutationFn: (enabled: boolean) => setAlarmActionsEnabled('aws', resource?.id ?? '', enabled),
|
||||||
|
onSuccess: (_result, enabled) => {
|
||||||
|
setNotice(enabled ? 'Actions on: the alarm notifies its targets when its state changes.' : 'Actions off: the alarm still changes state but notifies nobody.')
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource || !alarm) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select an alarm</h3><p>Choose an alarm to see its metric, test its actions and read its history.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = alarm.status ?? 'INSUFFICIENT_DATA'
|
||||||
|
const actionsEnabled = metadata.actionsEnabled !== false
|
||||||
|
const targets = [...new Set([...list(metadata.alarmActions), ...list(metadata.okActions)])]
|
||||||
|
const disabled = !runtimeReachable || stateMutation.isPending || actionsMutation.isPending
|
||||||
|
// TheCloud's runtime does not implement DescribeAlarmHistory yet; say so rather than fail.
|
||||||
|
const historyUnsupported = isNotImplemented(historyQuery.error)
|
||||||
|
const error = alarmQuery.error ?? (historyUnsupported ? null : historyQuery.error) ?? seriesQuery.error ?? stateMutation.error ?? actionsMutation.error
|
||||||
|
const history = historyQuery.data ?? []
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Alarm ${alarm.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Alarm</p>
|
||||||
|
<span className="cloudwatch-state-line"><span className={`badge ${state.toLowerCase().replace(/_/g, '-')}`}>{state}</span> <span className="muted">{String(metadata.stateReason ?? '')}</span></span>
|
||||||
|
</div>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || alarmQuery.isFetching} onClick={() => { void refresh(); void seriesQuery.refetch() }}><RefreshCw size={14}/>{alarmQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
<dl className="cognito-facts cloudwatch-facts">
|
||||||
|
<dt>{isMetric ? 'Condition' : 'Rule'}</dt><dd className="mono">{String(metadata.condition ?? '—')}</dd>
|
||||||
|
{isMetric && <><dt>Metric</dt><dd className="mono">{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</dd></>}
|
||||||
|
<dt>State changed</dt><dd>{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}</dd>
|
||||||
|
<dt>Notifies</dt><dd className="mono">{targets.length ? targets.join(', ') : 'No actions'}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{isMetric && <>
|
||||||
|
<div className="cloudwatch-range" role="group" aria-label="Time range">
|
||||||
|
{RANGES.map((range) => (
|
||||||
|
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cloudwatch-chart">
|
||||||
|
<p className="cloudwatch-chart-title">{String(metadata.metricName)} <span className="muted">· {String(metadata.statistic ?? '')}</span></p>
|
||||||
|
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||||
|
{seriesQuery.data && <MetricChart series={seriesQuery.data} threshold={typeof metadata.threshold === 'number' ? metadata.threshold : null}/>}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Test the alarm</p><span className="muted">Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.</span></div></div>
|
||||||
|
<form className="dynamic-form" aria-label="Set alarm state" onSubmit={(event) => { event.preventDefault(); setNotice(''); stateMutation.mutate() }}>
|
||||||
|
<label className="dynamic-field"><span>State</span>
|
||||||
|
<select className="input" value={nextState} onChange={(event) => setNextState(event.target.value as AlarmStateValue)} disabled={disabled}>
|
||||||
|
{STATES.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>Reason</span><input className="input" value={reason} placeholder="Testing the on-call notification" onChange={(event) => setReason(event.target.value)} disabled={disabled}/></label>
|
||||||
|
<div className="cloudwatch-form-actions">
|
||||||
|
<button className="button primary" type="submit" disabled={disabled}>{stateMutation.isPending ? 'Setting' : 'Set state'}</button>
|
||||||
|
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); actionsMutation.mutate(!actionsEnabled) }}>{actionsEnabled ? 'Turn actions off' : 'Turn actions on'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">History</p>{history.length > 0 && <span className="muted">Newest first.</span>}</div></div>
|
||||||
|
{historyUnsupported && <p className="muted padded">TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.</p>}
|
||||||
|
{historyQuery.isSuccess && history.length === 0 && <p className="muted padded">No history yet.</p>}
|
||||||
|
{history.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>When</th><th>Type</th><th>What happened</th></tr></thead><tbody>{history.map((item, index) => (
|
||||||
|
<tr key={`${item.timestamp}-${index}`}><td>{formatDateTime(item.timestamp) ?? '—'}</td><td><code>{item.type}</code></td><td>{item.summary}</td></tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotImplemented(error: unknown): boolean {
|
||||||
|
return error instanceof HttpError && typeof error.body === 'object' && error.body !== null
|
||||||
|
&& (error.body as {code?: unknown}).code === 'operation_not_implemented'
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {CloudWatchMetricsExplorer} from './CloudWatchMetricsExplorer'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({listMetrics: vi.fn(), getMetricSeries: vi.fn()}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.listMetrics.mockResolvedValue([
|
||||||
|
{namespace: 'App', metricName: 'Errors', dimensions: []},
|
||||||
|
{namespace: 'AWS/Lambda', metricName: 'Invocations', dimensions: [{name: 'FunctionName', value: 'checkout'}]},
|
||||||
|
])
|
||||||
|
api.getMetricSeries.mockResolvedValue({label: 'Invocations', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 12}]})
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderExplorer() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><CloudWatchMetricsExplorer runtimeReachable/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('filters metrics by namespace and charts the chosen one', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderExplorer()
|
||||||
|
|
||||||
|
expect(await screen.findByText('2 metrics in 2 namespaces. Pick one to chart it.')).toBeInTheDocument()
|
||||||
|
await user.selectOptions(screen.getByLabelText('Namespace'), 'AWS/Lambda')
|
||||||
|
expect(screen.queryByText('Errors')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Chart Invocations for checkout'}))
|
||||||
|
await user.selectOptions(screen.getByLabelText('Statistic'), 'Sum')
|
||||||
|
expect(await screen.findByRole('img', {name: /Invocations \(Sum\)/})).toBeInTheDocument()
|
||||||
|
expect(api.getMetricSeries).toHaveBeenLastCalledWith('aws', expect.objectContaining({namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'Sum', dimensions: [{name: 'FunctionName', value: 'checkout'}]}), expect.anything())
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {RefreshCw} from 'lucide-react'
|
||||||
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
import {useMemo, useState} from 'react'
|
||||||
|
import {getMetricSeries, listMetrics} from '@/api/cloudProxyClient'
|
||||||
|
import {MetricChart} from '@/components/MetricChart'
|
||||||
|
import {RANGES} from '@/lib/metricChart'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import type {MetricSummary} from '@/types/resource'
|
||||||
|
|
||||||
|
const STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']
|
||||||
|
|
||||||
|
export function CloudWatchMetricsExplorer({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selected, setSelected] = useState<MetricSummary | null>(null)
|
||||||
|
const [statistic, setStatistic] = useState('Average')
|
||||||
|
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||||
|
|
||||||
|
const metricsQuery = useQuery({
|
||||||
|
queryKey: ['cloudwatch-metrics', accountId],
|
||||||
|
queryFn: ({signal}) => listMetrics('aws', {}, signal),
|
||||||
|
enabled: runtimeReachable,
|
||||||
|
})
|
||||||
|
const seriesQuery = useQuery({
|
||||||
|
queryKey: ['cloudwatch-series', accountId, selected && metricKey(selected), statistic, rangeMinutes],
|
||||||
|
queryFn: ({signal}) => getMetricSeries('aws', {...selected!, statistic, rangeMinutes}, signal),
|
||||||
|
enabled: runtimeReachable && selected !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data])
|
||||||
|
const namespaces = useMemo(() => [...new Set(metrics.map((metric) => metric.namespace))].sort(), [metrics])
|
||||||
|
const term = search.trim().toLowerCase()
|
||||||
|
const shown = metrics.filter((metric) => (!namespace || metric.namespace === namespace)
|
||||||
|
&& (!term || [metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].some((value) => value.toLowerCase().includes(term))))
|
||||||
|
const error = metricsQuery.error ?? seriesQuery.error
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label="Metrics">
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Metrics</p><span className="muted">{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.</span></div>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || metricsQuery.isFetching} onClick={() => { void metricsQuery.refetch(); if (selected) void seriesQuery.refetch() }}><RefreshCw size={14}/>{metricsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
<div className="cloudwatch-filters">
|
||||||
|
<label className="dynamic-field"><span>Namespace</span>
|
||||||
|
<select className="input" value={namespace} onChange={(event) => setNamespace(event.target.value)}>
|
||||||
|
<option value="">All namespaces</option>
|
||||||
|
{namespaces.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>Search</span><input className="input" value={search} placeholder="Metric or dimension" onChange={(event) => setSearch(event.target.value)}/></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && <div className="cloudwatch-chart">
|
||||||
|
<div className="cloudwatch-chart-head">
|
||||||
|
<p className="cloudwatch-chart-title">{selected.metricName} <span className="muted">· {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</span></p>
|
||||||
|
<div className="cloudwatch-range" role="group" aria-label="Chart settings">
|
||||||
|
<select className="input" aria-label="Statistic" value={statistic} onChange={(event) => setStatistic(event.target.value)}>
|
||||||
|
{STATISTICS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</select>
|
||||||
|
{RANGES.map((range) => (
|
||||||
|
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||||
|
{seriesQuery.data && <MetricChart series={seriesQuery.data}/>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
{metricsQuery.isSuccess && metrics.length === 0 && <div className="empty compact"><h3>No metrics yet</h3><p>Metrics appear when services or your apps publish them, for example with PutMetricData.</p></div>}
|
||||||
|
{shown.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Metric</th><th>Namespace</th><th>Dimensions</th><th/></tr></thead><tbody>{shown.map((metric) => {
|
||||||
|
const isSelected = selected !== null && metricKey(selected) === metricKey(metric)
|
||||||
|
return <tr key={metricKey(metric)} aria-current={isSelected ? 'true' : undefined}>
|
||||||
|
<td className="mono">{metric.metricName}</td>
|
||||||
|
<td className="mono">{metric.namespace}</td>
|
||||||
|
<td className="mono">{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}</td>
|
||||||
|
<td className="cognito-row-actions-cell"><button className="button compact" type="button" aria-label={`Chart ${metric.metricName}${metric.dimensions.length ? ` for ${metric.dimensions.map((dimension) => dimension.value).join(', ')}` : ''}`} onClick={() => setSelected(isSelected ? null : metric)}>{isSelected ? 'Hide' : 'Chart'}</button></td>
|
||||||
|
</tr>
|
||||||
|
})}</tbody></table></div>}
|
||||||
|
{metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 && <p className="muted padded">No metrics match.</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricKey(metric: MetricSummary): string {
|
||||||
|
return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|')
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter} from 'react-router-dom'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {DataCatalogPanel} from './DataCatalogPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
createCatalogTable: vi.fn(),
|
||||||
|
deleteCatalogTable: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
function database(tables: unknown[] = [{
|
||||||
|
name: 'orders', description: null, format: 'csv', location: 's3://data/orders/', delimiter: ';', headerRow: true,
|
||||||
|
columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}], partitionKeys: [], createdAt: null, updatedAt: null,
|
||||||
|
}]): CloudResource {
|
||||||
|
return {id: 'shop', name: 'shop', cloud: 'aws', service: 'datacatalog', type: 'glueDatabase', region: null, createdAt: null, metadata: {description: 'Orders', tableCount: tables.length, tables}}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><DataCatalogPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.createCatalogTable.mockResolvedValue(database())
|
||||||
|
api.deleteCatalogTable.mockResolvedValue(database([]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lists tables with their format, location and columns', async () => {
|
||||||
|
renderPanel(database())
|
||||||
|
|
||||||
|
const row = (await screen.findByText('orders')).closest('tr') as HTMLElement
|
||||||
|
expect(within(row).getByText('s3://data/orders/')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('semicolon-separated, header row')).toBeInTheDocument()
|
||||||
|
expect(within(row).getByText('amount')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'Query in Athena'})).toHaveAttribute('href', '/cloud-explorer/aws/query')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a CSV table from the columns entered', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(database([]))
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Create table'})
|
||||||
|
await user.type(within(form).getByLabelText('Name'), 'events')
|
||||||
|
const location = within(form).getByLabelText('S3 location')
|
||||||
|
await user.clear(location)
|
||||||
|
await user.type(location, 's3://data/events/')
|
||||||
|
await user.type(within(form).getByLabelText('Column 1 name'), 'id')
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Column 1 type'), 'int')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Add column'}))
|
||||||
|
await user.type(within(form).getByLabelText('Column 2 name'), 'kind')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create table'}))
|
||||||
|
|
||||||
|
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {
|
||||||
|
name: 'events', location: 's3://data/events/', format: 'csv', delimiter: ',', headerRow: true,
|
||||||
|
columns: [{name: 'id', type: 'int'}, {name: 'kind', type: 'string'}],
|
||||||
|
})
|
||||||
|
expect(await screen.findByText('Created table events. Athena can query it now.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('JSON tables need no delimiter or header', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(database([]))
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Create table'})
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Format'), 'json')
|
||||||
|
expect(within(form).queryByLabelText('Delimiter')).not.toBeInTheDocument()
|
||||||
|
await user.type(within(form).getByLabelText('Name'), 'events')
|
||||||
|
await user.type(within(form).getByLabelText('S3 location'), 'data/events/')
|
||||||
|
await user.type(within(form).getByLabelText('Column 1 name'), 'kind')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Create table'}))
|
||||||
|
|
||||||
|
expect(api.createCatalogTable).toHaveBeenCalledWith('aws', 'shop', {name: 'events', location: 's3://data/events/', format: 'json', columns: [{name: 'kind', type: 'string'}]})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a table after confirming', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderPanel(database())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Delete table orders'}))
|
||||||
|
expect(api.deleteCatalogTable).toHaveBeenCalledWith('aws', 'shop', 'orders')
|
||||||
|
expect(await screen.findByText('Deleted table orders. Its files stay in S3.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {Plus, RefreshCw, X} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {createCatalogTable, deleteCatalogTable, getCloudResource} from '@/api/cloudProxyClient'
|
||||||
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CatalogTableInput, CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Column = {name: string; type: string}
|
||||||
|
type CatalogTable = {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
format: string
|
||||||
|
location: string | null
|
||||||
|
delimiter: string | null
|
||||||
|
headerRow: boolean
|
||||||
|
columns: Column[]
|
||||||
|
partitionKeys: Column[]
|
||||||
|
updatedAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
type TableForm = {name: string; location: string; format: CatalogTableInput['format']; delimiter: string; headerRow: boolean; columns: Column[]}
|
||||||
|
|
||||||
|
/** Matches the types the API accepts; decimal(p,s) can also be typed in by hand through the API. */
|
||||||
|
const COLUMN_TYPES = ['string', 'int', 'bigint', 'double', 'float', 'boolean', 'date', 'timestamp', 'decimal(10,2)']
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {csv: 'CSV', json: 'JSON lines', parquet: 'Parquet', iceberg: 'Iceberg'}
|
||||||
|
const DELIMITER_NAMES: Record<string, string> = {',': 'comma-separated', ';': 'semicolon-separated', '\t': 'tab-separated', '|': 'pipe-separated'}
|
||||||
|
const delimiterLabel = (delimiter: string | null) => DELIMITER_NAMES[delimiter ?? ','] ?? `separated by "${delimiter}"`
|
||||||
|
const emptyForm = (): TableForm => ({name: '', location: 's3://', format: 'csv', delimiter: ',', headerRow: true, columns: [{name: '', type: 'string'}]})
|
||||||
|
|
||||||
|
export function DataCatalogPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form, setForm] = useState<TableForm>(emptyForm)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const detailKey = ['glue-database', accountId, resource?.id]
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: detailKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'datacatalog', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotice('')
|
||||||
|
setForm(emptyForm())
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const settle = (updated: CloudResource, message: string) => {
|
||||||
|
queryClient.setQueryData(detailKey, updated)
|
||||||
|
setNotice(message)
|
||||||
|
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'datacatalog')})
|
||||||
|
}
|
||||||
|
const createTable = useMutation({
|
||||||
|
mutationFn: () => createCatalogTable('aws', resource?.id ?? '', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
location: form.location.trim(),
|
||||||
|
format: form.format,
|
||||||
|
...(form.format === 'csv' ? {delimiter: form.delimiter, headerRow: form.headerRow} : {}),
|
||||||
|
columns: form.columns.map((column) => ({name: column.name.trim(), type: column.type})),
|
||||||
|
}),
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
const name = form.name.trim()
|
||||||
|
setForm(emptyForm())
|
||||||
|
return settle(updated, `Created table ${name}. Athena can query it now.`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteTable = useMutation({
|
||||||
|
mutationFn: (tableName: string) => deleteCatalogTable('aws', resource?.id ?? '', tableName),
|
||||||
|
onSuccess: (updated, tableName) => settle(updated, `Deleted table ${tableName}. Its files stay in S3.`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a database</h3><p>Choose a database to see its tables, or describe files in S3 as a new table.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = detailQuery.data ?? resource
|
||||||
|
const tables = Array.isArray(database.metadata.tables) ? database.metadata.tables as CatalogTable[] : []
|
||||||
|
const disabled = !runtimeReachable || createTable.isPending || deleteTable.isPending
|
||||||
|
const error = detailQuery.error ?? createTable.error ?? deleteTable.error
|
||||||
|
const setColumn = (index: number, patch: Partial<Column>) => setForm({...form, columns: form.columns.map((column, i) => i === index ? {...column, ...patch} : column)})
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Database ${database.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Glue database</p><span className="muted">{database.metadata.description ? String(database.metadata.description) : 'Tables here describe files in S3 so Athena can query them.'}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
{tables.length > 0 && <Link className="button" to="/cloud-explorer/aws/query">Query in Athena</Link>}
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Tables</p><span className="muted">{tables.length === 1 ? '1 table' : `${tables.length} tables`}</span></div></div>
|
||||||
|
{tables.length === 0 ? <p className="muted compact-text catalog-note">No tables yet. Describe a folder of CSV, JSON or Parquet files below.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Table</th><th>Format</th><th>Location</th><th>Columns</th><th>Updated</th><th aria-label="Actions"/></tr></thead><tbody>{tables.map((table) => (
|
||||||
|
<tr key={table.name}>
|
||||||
|
<td className="mono">{table.name}{table.description && <><br/><small className="muted">{table.description}</small></>}</td>
|
||||||
|
<td>{FORMAT_LABELS[table.format] ?? table.format}{table.format === 'csv' && <><br/><small className="muted">{delimiterLabel(table.delimiter)}{table.headerRow ? ', header row' : ''}</small></>}</td>
|
||||||
|
<td className="mono catalog-location">{table.location ?? '—'}</td>
|
||||||
|
<td className="catalog-columns">{table.columns.map((column) => <span key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></span>)}
|
||||||
|
{table.partitionKeys.length > 0 && <small className="muted">partitioned by {table.partitionKeys.map((key) => key.name).join(', ')}</small>}</td>
|
||||||
|
<td>{formatDateTime(table.updatedAt ?? table.createdAt) ?? '—'}</td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete table ${table.name}`} onClick={() => {
|
||||||
|
setNotice('')
|
||||||
|
if (window.confirm(`Delete table ${table.name}? Its files stay in S3.`)) deleteTable.mutate(table.name)
|
||||||
|
}}>Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
<form className="catalog-form" aria-label="Create table" onSubmit={(event) => { event.preventDefault(); setNotice(''); createTable.mutate() }}>
|
||||||
|
<p className="eyebrow">New table</p>
|
||||||
|
<div className="dynamic-form catalog-form-fields">
|
||||||
|
<label className="dynamic-field"><span>Name</span><input className="input mono" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} disabled={disabled} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/></label>
|
||||||
|
<label className="dynamic-field catalog-span"><span>S3 location</span><input className="input mono" value={form.location} onChange={(event) => setForm({...form, location: event.target.value})} disabled={disabled} required pattern="s3://[a-z0-9.\-]{3,63}(/.*)?" title="A folder such as s3://my-bucket/orders/"/></label>
|
||||||
|
<label className="dynamic-field"><span>Format</span><select className="input" value={form.format} onChange={(event) => setForm({...form, format: event.target.value as TableForm['format']})} disabled={disabled}>
|
||||||
|
<option value="csv">CSV</option><option value="json">JSON lines</option><option value="parquet">Parquet</option>
|
||||||
|
</select></label>
|
||||||
|
{form.format === 'csv' && <>
|
||||||
|
<label className="dynamic-field"><span>Delimiter</span><input className="input mono" value={form.delimiter} maxLength={1} onChange={(event) => setForm({...form, delimiter: event.target.value})} disabled={disabled} required/></label>
|
||||||
|
<label className="dynamic-field catalog-check"><input type="checkbox" checked={form.headerRow} onChange={(event) => setForm({...form, headerRow: event.target.checked})} disabled={disabled}/><span>First line is a header</span></label>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<fieldset className="catalog-columns-editor" disabled={disabled}>
|
||||||
|
<legend>Columns</legend>
|
||||||
|
{form.columns.map((column, index) => <div className="catalog-column-row" key={index}>
|
||||||
|
<input className="input mono" aria-label={`Column ${index + 1} name`} value={column.name} onChange={(event) => setColumn(index, {name: event.target.value})} required pattern="[a-z_][a-z0-9_]*" title="Lowercase letters, numbers and underscores"/>
|
||||||
|
<select className="input" aria-label={`Column ${index + 1} type`} value={column.type} onChange={(event) => setColumn(index, {type: event.target.value})}>
|
||||||
|
{COLUMN_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="button compact" type="button" aria-label={`Remove column ${index + 1}`} disabled={form.columns.length === 1} onClick={() => setForm({...form, columns: form.columns.filter((_, i) => i !== index)})}><X size={14}/></button>
|
||||||
|
</div>)}
|
||||||
|
<button className="button compact" type="button" onClick={() => setForm({...form, columns: [...form.columns, {name: '', type: 'string'}]})}><Plus size={14}/>Add column</button>
|
||||||
|
</fieldset>
|
||||||
|
<p className="muted compact-text">List the columns in the order they appear in the files. For JSON, use the field names.</p>
|
||||||
|
<div><button className="button primary" type="submit" disabled={disabled}>{createTable.isPending ? 'Creating' : 'Create table'}</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ export function DatabaseSnapshotsPanel({
|
|||||||
"code" in error.body &&
|
"code" in error.body &&
|
||||||
(error.body as { code?: unknown }).code === "operation_not_implemented"
|
(error.body as { code?: unknown }).code === "operation_not_implemented"
|
||||||
) {
|
) {
|
||||||
return "Snapshot creation is available in Floci UI, but the selected Floci runtime does not implement CreateDBSnapshot.";
|
return "Snapshot creation is available in TheCloud, but the selected runtime does not implement CreateDBSnapshot.";
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
|
|||||||
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
||||||
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
||||||
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||||
|
import { HttpApiPanel } from "@/components/HttpApiPanel";
|
||||||
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
||||||
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
||||||
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
||||||
@@ -67,6 +68,14 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
|||||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||||
|
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
||||||
|
import { FileSystemPanel } from "@/components/FileSystemPanel";
|
||||||
|
import { DataCatalogPanel } from "@/components/DataCatalogPanel";
|
||||||
|
import { QueryPanel } from "@/components/QueryPanel";
|
||||||
|
import { CertificatePanel } from "@/components/CertificatePanel";
|
||||||
|
import { PipePanel } from "@/components/PipePanel";
|
||||||
|
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||||
|
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||||
@@ -984,9 +993,33 @@ export function DynamicResourceView({
|
|||||||
{service === "dns" && cloud === "aws" && (
|
{service === "dns" && cloud === "aws" && (
|
||||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "certificates" && cloud === "aws" && (
|
||||||
|
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "pipes" && cloud === "aws" && (
|
||||||
|
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "autoscaling" && cloud === "aws" && (
|
||||||
|
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "filesystems" && cloud === "aws" && (
|
||||||
|
<FileSystemPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "datacatalog" && cloud === "aws" && (
|
||||||
|
<DataCatalogPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
|
{service === "query" && cloud === "aws" && (
|
||||||
|
<QueryPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
)}
|
||||||
{service === "cdn" && cloud === "aws" && (
|
{service === "cdn" && cloud === "aws" && (
|
||||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
)}
|
)}
|
||||||
|
{service === "monitoring" && cloud === "aws" && (
|
||||||
|
<>
|
||||||
|
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||||
|
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{service === "compute" && (
|
{service === "compute" && (
|
||||||
<ComputePanel
|
<ComputePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
@@ -1029,14 +1062,21 @@ export function DynamicResourceView({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{service === "apigateway" && (
|
{service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
|
||||||
|
<HttpApiPanel
|
||||||
|
cloud={cloud}
|
||||||
|
resource={activeSelected}
|
||||||
|
runtimeReachable={canUseRuntime}
|
||||||
|
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ApiGatewayRoutePanel
|
<ApiGatewayRoutePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
resource={activeSelected}
|
resource={activeSelected}
|
||||||
runtimeReachable={canUseRuntime}
|
runtimeReachable={canUseRuntime}
|
||||||
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{service === "events" && (
|
{service === "events" && (
|
||||||
<EventBridgeRulePanel
|
<EventBridgeRulePanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter} from 'react-router-dom'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {FileSystemPanel} from './FileSystemPanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listCloudResources: vi.fn(),
|
||||||
|
createMountTarget: vi.fn(),
|
||||||
|
deleteMountTarget: vi.fn(),
|
||||||
|
createAccessPoint: vi.fn(),
|
||||||
|
deleteAccessPoint: vi.fn(),
|
||||||
|
attachFileSystemToFunction: vi.fn(),
|
||||||
|
detachFileSystemFromFunction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
function fileSystem(metadata: Record<string, unknown> = {}): CloudResource {
|
||||||
|
return {
|
||||||
|
id: 'fs-1', name: 'shared', cloud: 'aws', service: 'filesystems', type: 'fileSystem', region: 'us-east-1', createdAt: null, status: 'available',
|
||||||
|
metadata: {
|
||||||
|
fileSystemId: 'fs-1', throughputMode: 'bursting', encrypted: true,
|
||||||
|
mountTargets: [{id: 'fsmt-1', subnetId: 'subnet-a', availabilityZone: 'us-east-1a', ipAddress: '10.0.0.10', state: 'available'}],
|
||||||
|
accessPoints: [{id: 'fsap-1', arn: 'arn:aws:elasticfilesystem:us-east-1:000000000000:access-point/fsap-1', name: 'app', path: '/app', uid: 1000, gid: 1000, permissions: '755', state: 'available'}],
|
||||||
|
functions: [{name: 'writer', accessPointId: 'fsap-1', mountPath: '/mnt/share'}],
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><FileSystemPanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.listCloudResources.mockResolvedValue([{id: 'reader', name: 'reader'}, {id: 'writer', name: 'writer'}])
|
||||||
|
for (const fn of [api.createMountTarget, api.deleteMountTarget, api.createAccessPoint, api.deleteAccessPoint, api.attachFileSystemToFunction, api.detachFileSystemFromFunction]) {
|
||||||
|
fn.mockResolvedValue(fileSystem())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows mount targets, access points and the functions that mount the file system', async () => {
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
expect(await screen.findByText('fsmt-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('fsap-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'writer'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
|
||||||
|
expect(screen.getByText('/mnt/share')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds a mount target and creates an access point', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
const mountForm = await screen.findByRole('form', {name: 'Add mount target'})
|
||||||
|
await user.type(within(mountForm).getByLabelText('Subnet ID'), 'subnet-b')
|
||||||
|
await user.click(within(mountForm).getByRole('button', {name: 'Add mount target'}))
|
||||||
|
expect(api.createMountTarget).toHaveBeenCalledWith('aws', 'fs-1', 'subnet-b')
|
||||||
|
expect(await screen.findByText('Added a mount target in subnet-b.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const accessPointForm = screen.getByRole('form', {name: 'Create access point'})
|
||||||
|
const path = within(accessPointForm).getByLabelText('Root directory')
|
||||||
|
await user.clear(path)
|
||||||
|
await user.type(path, '/uploads')
|
||||||
|
await user.click(within(accessPointForm).getByRole('button', {name: 'Create access point'}))
|
||||||
|
expect(api.createAccessPoint).toHaveBeenCalledWith('aws', 'fs-1', {path: '/uploads', uid: 1000, gid: 1000, permissions: '755'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attaches the file system to a function through an access point', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Attach to a function'})
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Function'), await within(form).findByRole('option', {name: 'reader'}))
|
||||||
|
await user.selectOptions(within(form).getByLabelText('Access point'), 'fsap-1')
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Attach'}))
|
||||||
|
|
||||||
|
expect(api.attachFileSystemToFunction).toHaveBeenCalledWith('aws', 'fs-1', {accessPointId: 'fsap-1', functionName: 'reader', mountPath: '/mnt/data'})
|
||||||
|
expect(await screen.findByText('reader now sees this file system at /mnt/data.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detaches a function after confirming', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
renderPanel(fileSystem())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Detach writer'}))
|
||||||
|
expect(api.detachFileSystemFromFunction).toHaveBeenCalledWith('aws', 'fs-1', 'writer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cannot attach until the file system has a mount target', async () => {
|
||||||
|
renderPanel(fileSystem({mountTargets: [], functions: []}))
|
||||||
|
|
||||||
|
expect(await screen.findByText(/No mount targets yet/)).toBeInTheDocument()
|
||||||
|
expect(within(screen.getByRole('form', {name: 'Attach to a function'})).getByRole('button', {name: 'Attach'})).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {RefreshCw} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
attachFileSystemToFunction,
|
||||||
|
createAccessPoint,
|
||||||
|
createMountTarget,
|
||||||
|
deleteAccessPoint,
|
||||||
|
deleteMountTarget,
|
||||||
|
detachFileSystemFromFunction,
|
||||||
|
getCloudResource,
|
||||||
|
listCloudResources,
|
||||||
|
} from '@/api/cloudProxyClient'
|
||||||
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MountTarget = {id: string; subnetId: string | null; availabilityZone: string | null; ipAddress: string | null; state: string | null}
|
||||||
|
type AccessPoint = {id: string; arn: string | null; name: string | null; path: string; uid: number | null; gid: number | null; permissions: string | null; state: string | null}
|
||||||
|
type MountedFunction = {name: string; accessPointId: string | null; mountPath: string | null}
|
||||||
|
|
||||||
|
const EMPTY_ACCESS_POINT = {name: '', path: '/', uid: '1000', gid: '1000', permissions: '755'}
|
||||||
|
|
||||||
|
export function FileSystemPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [subnetId, setSubnetId] = useState('')
|
||||||
|
const [accessPointForm, setAccessPointForm] = useState(EMPTY_ACCESS_POINT)
|
||||||
|
const [attachForm, setAttachForm] = useState({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const detailKey = ['efs-file-system', accountId, resource?.id]
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: detailKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'filesystems', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const functionsQuery = useQuery({
|
||||||
|
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
|
||||||
|
queryFn: ({signal}) => listCloudResources('aws', 'serverless', undefined, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotice('')
|
||||||
|
setSubnetId('')
|
||||||
|
setAccessPointForm(EMPTY_ACCESS_POINT)
|
||||||
|
setAttachForm({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const settle = (updated: CloudResource, message: string) => {
|
||||||
|
queryClient.setQueryData(detailKey, updated)
|
||||||
|
setNotice(message)
|
||||||
|
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'filesystems')})
|
||||||
|
}
|
||||||
|
const id = resource?.id ?? ''
|
||||||
|
const addMountTarget = useMutation({
|
||||||
|
mutationFn: () => createMountTarget('aws', id, subnetId.trim()),
|
||||||
|
onSuccess: (updated) => { setSubnetId(''); return settle(updated, `Added a mount target in ${subnetId.trim()}.`) },
|
||||||
|
})
|
||||||
|
const removeMountTarget = useMutation({
|
||||||
|
mutationFn: (mountTargetId: string) => deleteMountTarget('aws', id, mountTargetId),
|
||||||
|
onSuccess: (updated, mountTargetId) => settle(updated, `Removed mount target ${mountTargetId}.`),
|
||||||
|
})
|
||||||
|
const addAccessPoint = useMutation({
|
||||||
|
mutationFn: () => createAccessPoint('aws', id, {
|
||||||
|
...(accessPointForm.name.trim() ? {name: accessPointForm.name.trim()} : {}),
|
||||||
|
path: accessPointForm.path.trim(),
|
||||||
|
uid: Number(accessPointForm.uid),
|
||||||
|
gid: Number(accessPointForm.gid),
|
||||||
|
permissions: accessPointForm.permissions.trim(),
|
||||||
|
}),
|
||||||
|
onSuccess: (updated) => { setAccessPointForm(EMPTY_ACCESS_POINT); return settle(updated, `Created an access point at ${accessPointForm.path.trim()}.`) },
|
||||||
|
})
|
||||||
|
const removeAccessPoint = useMutation({
|
||||||
|
mutationFn: (accessPointId: string) => deleteAccessPoint('aws', id, accessPointId),
|
||||||
|
onSuccess: (updated, accessPointId) => settle(updated, `Deleted access point ${accessPointId}.`),
|
||||||
|
})
|
||||||
|
const attach = useMutation({
|
||||||
|
mutationFn: () => attachFileSystemToFunction('aws', id, {...attachForm, mountPath: attachForm.mountPath.trim()}),
|
||||||
|
onSuccess: (updated) => settle(updated, `${attachForm.functionName} now sees this file system at ${attachForm.mountPath.trim()}.`),
|
||||||
|
})
|
||||||
|
const detach = useMutation({
|
||||||
|
mutationFn: (functionName: string) => detachFileSystemFromFunction('aws', id, functionName),
|
||||||
|
onSuccess: (updated, functionName) => settle(updated, `Detached ${functionName}.`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a file system</h3><p>Choose a file system to manage its mount targets and access points, and attach it to Lambda functions.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSystem = detailQuery.data ?? resource
|
||||||
|
const metadata = fileSystem.metadata
|
||||||
|
const mountTargets = Array.isArray(metadata.mountTargets) ? metadata.mountTargets as MountTarget[] : []
|
||||||
|
const accessPoints = Array.isArray(metadata.accessPoints) ? metadata.accessPoints as AccessPoint[] : []
|
||||||
|
const mounted = Array.isArray(metadata.functions) ? metadata.functions as MountedFunction[] : []
|
||||||
|
const functionNames = (functionsQuery.data ?? []).map((fn) => fn.name).sort()
|
||||||
|
const mutations = [addMountTarget, removeMountTarget, addAccessPoint, removeAccessPoint, attach, detach]
|
||||||
|
const disabled = !runtimeReachable || mutations.some((mutation) => mutation.isPending)
|
||||||
|
const error = detailQuery.error ?? mutations.find((mutation) => mutation.error)?.error
|
||||||
|
const accessPointLabel = (accessPointId: string | null) => {
|
||||||
|
const accessPoint = accessPoints.find((candidate) => candidate.id === accessPointId)
|
||||||
|
return accessPoint ? `${accessPoint.name ?? accessPoint.id} (${accessPoint.path})` : accessPointId ?? '—'
|
||||||
|
}
|
||||||
|
const run = (action: () => void) => { setNotice(''); action() }
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`File system ${fileSystem.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">EFS file system</p><span className="muted mono">{String(metadata.fileSystemId ?? fileSystem.id)}</span><span className="muted"> · {String(metadata.throughputMode ?? '')} · {metadata.encrypted ? 'encrypted' : 'not encrypted'}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Mount targets</p><span className="muted">Functions and tasks reach the file system through a mount target in their subnet.</span></div></div>
|
||||||
|
{mountTargets.length === 0 ? <p className="muted compact-text efs-note">No mount targets yet. Add one before attaching the file system to a function.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Mount target</th><th>Subnet</th><th>Zone</th><th>IP address</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{mountTargets.map((mountTarget) => (
|
||||||
|
<tr key={mountTarget.id}>
|
||||||
|
<td className="mono">{mountTarget.id}</td>
|
||||||
|
<td className="mono">{mountTarget.subnetId ?? '—'}</td>
|
||||||
|
<td>{mountTarget.availabilityZone ?? '—'}</td>
|
||||||
|
<td className="mono">{mountTarget.ipAddress ?? '—'}</td>
|
||||||
|
<td><code>{mountTarget.state ?? '—'}</code></td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Remove ${mountTarget.id}`} onClick={() => run(() => {
|
||||||
|
if (window.confirm(`Remove mount target ${mountTarget.id}?`)) removeMountTarget.mutate(mountTarget.id)
|
||||||
|
})}>Remove</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
<form className="dynamic-form efs-inline-form" aria-label="Add mount target" onSubmit={(event) => { event.preventDefault(); run(() => addMountTarget.mutate()) }}>
|
||||||
|
<label className="dynamic-field"><span>Subnet ID</span><input className="input mono" value={subnetId} placeholder="subnet-…" onChange={(event) => setSubnetId(event.target.value)} disabled={disabled} required/></label>
|
||||||
|
<div><button className="button" type="submit" disabled={disabled || !subnetId.trim()}>{addMountTarget.isPending ? 'Adding' : 'Add mount target'}</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Access points</p><span className="muted">An entry point into one directory, as a fixed POSIX user. Lambda mounts a file system through an access point.</span></div></div>
|
||||||
|
{accessPoints.length === 0 ? <p className="muted compact-text efs-note">No access points yet.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Access point</th><th>Root directory</th><th>POSIX user</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{accessPoints.map((accessPoint) => (
|
||||||
|
<tr key={accessPoint.id}>
|
||||||
|
<td>{accessPoint.name && <>{accessPoint.name}<br/></>}<small className="mono">{accessPoint.id}</small></td>
|
||||||
|
<td className="mono">{accessPoint.path}{accessPoint.permissions ? <small className="muted"> ({accessPoint.permissions})</small> : null}</td>
|
||||||
|
<td className="mono">{accessPoint.uid ?? '—'}:{accessPoint.gid ?? '—'}</td>
|
||||||
|
<td><code>{accessPoint.state ?? '—'}</code></td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete ${accessPoint.id}`} onClick={() => run(() => {
|
||||||
|
if (window.confirm(`Delete access point ${accessPoint.id}? Files stay in the file system.`)) removeAccessPoint.mutate(accessPoint.id)
|
||||||
|
})}>Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
<form className="dynamic-form efs-access-point-form" aria-label="Create access point" onSubmit={(event) => { event.preventDefault(); run(() => addAccessPoint.mutate()) }}>
|
||||||
|
<label className="dynamic-field"><span>Name</span><input className="input" value={accessPointForm.name} onChange={(event) => setAccessPointForm({...accessPointForm, name: event.target.value})} disabled={disabled}/></label>
|
||||||
|
<label className="dynamic-field"><span>Root directory</span><input className="input mono" value={accessPointForm.path} onChange={(event) => setAccessPointForm({...accessPointForm, path: event.target.value})} disabled={disabled} required pattern="/.*" title="Start with /"/></label>
|
||||||
|
<label className="dynamic-field"><span>User ID</span><input className="input" type="number" min={0} value={accessPointForm.uid} onChange={(event) => setAccessPointForm({...accessPointForm, uid: event.target.value})} disabled={disabled} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Group ID</span><input className="input" type="number" min={0} value={accessPointForm.gid} onChange={(event) => setAccessPointForm({...accessPointForm, gid: event.target.value})} disabled={disabled} required/></label>
|
||||||
|
<label className="dynamic-field"><span>Permissions</span><input className="input mono" value={accessPointForm.permissions} onChange={(event) => setAccessPointForm({...accessPointForm, permissions: event.target.value})} disabled={disabled} required pattern="[0-7]{3,4}" title="Octal, such as 755"/></label>
|
||||||
|
<div><button className="button" type="submit" disabled={disabled}>{addAccessPoint.isPending ? 'Creating' : 'Create access point'}</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Lambda functions</p><span className="muted">Functions that mount this file system share its files. A function can mount one file system.</span></div></div>
|
||||||
|
{mounted.length === 0 ? <p className="muted compact-text efs-note">No function mounts this file system.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Function</th><th>Mount path</th><th>Access point</th><th aria-label="Actions"/></tr></thead><tbody>{mounted.map((fn) => (
|
||||||
|
<tr key={fn.name}>
|
||||||
|
<td><Link to="/cloud-explorer/aws/serverless" className="mono">{fn.name}</Link></td>
|
||||||
|
<td className="mono">{fn.mountPath ?? '—'}</td>
|
||||||
|
<td>{accessPointLabel(fn.accessPointId)}</td>
|
||||||
|
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Detach ${fn.name}`} onClick={() => run(() => {
|
||||||
|
if (window.confirm(`Detach the file system from ${fn.name}?`)) detach.mutate(fn.name)
|
||||||
|
})}>Detach</button></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
{accessPoints.length > 0 && <form className="dynamic-form efs-access-point-form" aria-label="Attach to a function" onSubmit={(event) => { event.preventDefault(); run(() => attach.mutate()) }}>
|
||||||
|
<label className="dynamic-field"><span>Function</span><select className="input" value={attachForm.functionName} onChange={(event) => setAttachForm({...attachForm, functionName: event.target.value})} disabled={disabled} required>
|
||||||
|
<option value="">{functionNames.length === 0 ? 'No functions yet' : 'Choose a function'}</option>
|
||||||
|
{functionNames.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="dynamic-field"><span>Access point</span><select className="input" value={attachForm.accessPointId} onChange={(event) => setAttachForm({...attachForm, accessPointId: event.target.value})} disabled={disabled} required>
|
||||||
|
<option value="">Choose an access point</option>
|
||||||
|
{accessPoints.map((accessPoint) => <option key={accessPoint.id} value={accessPoint.id}>{accessPointLabel(accessPoint.id)}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="dynamic-field"><span>Mount path</span><input className="input mono" value={attachForm.mountPath} onChange={(event) => setAttachForm({...attachForm, mountPath: event.target.value})} disabled={disabled} required pattern="/mnt/[a-zA-Z0-9\-_.]+" title="Under /mnt, such as /mnt/data"/></label>
|
||||||
|
<div><button className="button" type="submit" disabled={disabled || mountTargets.length === 0} title={mountTargets.length === 0 ? 'Add a mount target first' : undefined}>{attach.isPending ? 'Attaching' : 'Attach'}</button></div>
|
||||||
|
</form>}
|
||||||
|
{accessPoints.length > 0 && <p className="muted compact-text efs-note">Lambda needs a VPC to mount a file system. A function not yet in one is put in this file system's mount target subnets with the default security group.</p>}
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {HttpApiPanel} from './HttpApiPanel'
|
||||||
|
import type {HttpApiOverview} from '@/api/cloudProxyClient'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
describeHttpApi: vi.fn(),
|
||||||
|
listCloudResources: vi.fn(),
|
||||||
|
createHttpApiRoute: vi.fn(),
|
||||||
|
deleteHttpApiRoute: vi.fn(),
|
||||||
|
setHttpApiRouteAuthorizer: vi.fn(),
|
||||||
|
createHttpApiStage: vi.fn(),
|
||||||
|
deleteHttpApiStage: vi.fn(),
|
||||||
|
deployHttpApi: vi.fn(),
|
||||||
|
createHttpApiAuthorizer: vi.fn(),
|
||||||
|
deleteHttpApiAuthorizer: vi.fn(),
|
||||||
|
updateHttpApiCors: vi.fn(),
|
||||||
|
testApiGatewayRoute: vi.fn(),
|
||||||
|
testWebSocketApi: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
|
||||||
|
const httpResource: CloudResource = {
|
||||||
|
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
|
||||||
|
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpOverview: HttpApiOverview = {
|
||||||
|
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
|
||||||
|
routes: [
|
||||||
|
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
|
||||||
|
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
|
||||||
|
],
|
||||||
|
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
|
||||||
|
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
|
||||||
|
cors: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource = httpResource) {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.describeHttpApi.mockResolvedValue(httpOverview)
|
||||||
|
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
|
||||||
|
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
|
||||||
|
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
|
||||||
|
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
|
||||||
|
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
|
||||||
|
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
|
||||||
|
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
|
||||||
|
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the stage URL, routes with their targets and who may call them', async () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
|
||||||
|
const routes = within(screen.getByRole('region', {name: 'Routes'}))
|
||||||
|
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
|
||||||
|
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
|
||||||
|
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
|
||||||
|
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adds a Lambda route and protects an existing one', async () => {
|
||||||
|
renderPanel()
|
||||||
|
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
|
||||||
|
|
||||||
|
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
|
||||||
|
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
|
||||||
|
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
|
||||||
|
await routes.findByRole('option', {name: 'notes'})
|
||||||
|
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
|
||||||
|
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
|
||||||
|
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
|
||||||
|
|
||||||
|
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
|
||||||
|
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
|
||||||
|
renderPanel()
|
||||||
|
const security = within(await screen.findByRole('region', {name: 'Security'}))
|
||||||
|
|
||||||
|
await security.findByRole('option', {name: 'notes-users'})
|
||||||
|
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
|
||||||
|
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
|
||||||
|
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
|
||||||
|
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends a request to the stage and shows the response', async () => {
|
||||||
|
renderPanel()
|
||||||
|
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
|
||||||
|
|
||||||
|
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
|
||||||
|
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
|
||||||
|
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
|
||||||
|
expect(await tester.findByText('200')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
|
||||||
|
api.describeHttpApi.mockResolvedValue({
|
||||||
|
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
|
||||||
|
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
|
||||||
|
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
|
||||||
|
authorizers: [], cors: null,
|
||||||
|
} satisfies HttpApiOverview)
|
||||||
|
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
|
||||||
|
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
|
||||||
|
|
||||||
|
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
|
||||||
|
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
|
||||||
|
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
|
||||||
|
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
|
||||||
|
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
|
||||||
|
expect(await tester.findByText('Connected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
createHttpApiAuthorizer,
|
||||||
|
createHttpApiRoute,
|
||||||
|
createHttpApiStage,
|
||||||
|
deleteHttpApiAuthorizer,
|
||||||
|
deleteHttpApiRoute,
|
||||||
|
deleteHttpApiStage,
|
||||||
|
deployHttpApi,
|
||||||
|
describeHttpApi,
|
||||||
|
listCloudResources,
|
||||||
|
setHttpApiRouteAuthorizer,
|
||||||
|
testApiGatewayRoute,
|
||||||
|
testWebSocketApi,
|
||||||
|
updateHttpApiCors,
|
||||||
|
} from '@/api/cloudProxyClient'
|
||||||
|
import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient'
|
||||||
|
import type {CloudProvider} from '@/types/cloud'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import {CopyValueButton} from './CopyValueButton'
|
||||||
|
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
|
||||||
|
|
||||||
|
interface HttpApiPanelProps {
|
||||||
|
cloud: CloudProvider
|
||||||
|
resource: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
runtimeEndpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
|
||||||
|
const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
|
||||||
|
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*']
|
||||||
|
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||||
|
|
||||||
|
/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */
|
||||||
|
export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) {
|
||||||
|
const queryKey = ['http-api', cloud, resource.id]
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal),
|
||||||
|
enabled: runtimeReachable,
|
||||||
|
})
|
||||||
|
const api = query.data
|
||||||
|
const endpoint = invokeEndpoint(runtimeEndpoint)
|
||||||
|
const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET'
|
||||||
|
|
||||||
|
return <div className="http-api-workspace">
|
||||||
|
<section className="table-panel" aria-label={`${isWebSocket ? 'WebSocket' : 'HTTP'} API ${resource.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{isWebSocket ? 'WebSocket API' : 'HTTP API'}</p>
|
||||||
|
<span className="muted">{isWebSocket
|
||||||
|
? <>Messages are routed by <code>{api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}</code>.</>
|
||||||
|
: 'Routes send requests to Lambda functions or HTTP backends.'}</span>
|
||||||
|
</div>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={14}/>{query.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
{query.isLoading && <p className="muted padded">Loading the API</p>}
|
||||||
|
{query.error instanceof Error && <p className="error-text compact-text padded" role="alert">{query.error.message}</p>}
|
||||||
|
{api && <StageUrls api={api} endpoint={endpoint}/>}
|
||||||
|
</section>
|
||||||
|
{api && <>
|
||||||
|
<RoutesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||||
|
<StagesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||||
|
{api.protocol === 'HTTP' && <SecuritySection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>}
|
||||||
|
{api.protocol === 'HTTP'
|
||||||
|
? <HttpTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>
|
||||||
|
: <WebSocketTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) {
|
||||||
|
if (api.stages.length === 0) return <p className="muted padded">No stages yet. Add one below to serve the API.</p>
|
||||||
|
return <div className="http-api-urls">
|
||||||
|
{api.stages.map((stage) => {
|
||||||
|
const url = stageUrl(api, stage, endpoint)
|
||||||
|
return <div className="dynamic-field" key={stage.stageName}>
|
||||||
|
<span>{stage.stageName} {stage.autoDeploy && <small className="muted">· deploys automatically</small>}</span>
|
||||||
|
{url ? <div className="copy-value"><code>{url}</code><CopyValueButton value={url} label={`${stage.stageName} URL`}/></div> : <code>{stage.invokePath}</code>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]}
|
||||||
|
|
||||||
|
function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const isWebSocket = api.protocol === 'WEBSOCKET'
|
||||||
|
const [method, setMethod] = useState('GET')
|
||||||
|
const [path, setPath] = useState('/hello')
|
||||||
|
const [catchAll, setCatchAll] = useState(false)
|
||||||
|
const [webSocketKey, setWebSocketKey] = useState('$default')
|
||||||
|
const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda')
|
||||||
|
const [functionName, setFunctionName] = useState('')
|
||||||
|
const [httpUrl, setHttpUrl] = useState('')
|
||||||
|
const [authorizerId, setAuthorizerId] = useState('')
|
||||||
|
const [returnsResponse, setReturnsResponse] = useState(true)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmDelete(null)
|
||||||
|
setAuthorizerId('')
|
||||||
|
}, [api.apiId])
|
||||||
|
|
||||||
|
const functionsQuery = useQuery({
|
||||||
|
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||||
|
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
|
||||||
|
enabled: runtimeReachable,
|
||||||
|
})
|
||||||
|
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||||
|
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||||
|
|
||||||
|
const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}`
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createHttpApiRoute(cloud, api.apiId, {
|
||||||
|
routeKey,
|
||||||
|
functionName: targetKind === 'lambda' ? functionName : undefined,
|
||||||
|
httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined,
|
||||||
|
authorizerId: authorizerId || undefined,
|
||||||
|
returnsResponse: isWebSocket ? returnsResponse : undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: refresh,
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setConfirmDelete(null)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const authorizerMutation = useMutation({
|
||||||
|
mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer),
|
||||||
|
onSuccess: refresh,
|
||||||
|
})
|
||||||
|
|
||||||
|
const target = targetKind === 'lambda' ? functionName : httpUrl.trim()
|
||||||
|
const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending
|
||||||
|
const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id
|
||||||
|
const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error
|
||||||
|
|
||||||
|
return <section className="table-panel" aria-label="Routes">
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Routes</p>
|
||||||
|
<span className="muted">{isWebSocket
|
||||||
|
? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.'
|
||||||
|
: 'The most specific route wins; $default catches every request no other route matches.'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{api.routes.length === 0 && <p className="muted padded">No routes yet.</p>}
|
||||||
|
{api.routes.length > 0 && <div className="cognito-table"><table className="table">
|
||||||
|
<thead><tr><th>Route</th><th>Target</th>{!isWebSocket && <th>Access</th>}<th aria-label="Actions"/></tr></thead>
|
||||||
|
<tbody>{api.routes.map((route) => <tr key={route.routeId}>
|
||||||
|
<td><code>{route.routeKey}</code>{isWebSocket && route.returnsResponse && <small className="muted"> · replies</small>}</td>
|
||||||
|
<td>{describeTarget(route)}</td>
|
||||||
|
{!isWebSocket && <td>
|
||||||
|
<select className="input http-api-access" aria-label={`Access for ${route.routeKey}`} value={route.authorizerId ?? ''} disabled={!runtimeReachable || authorizerMutation.isPending}
|
||||||
|
onChange={(event) => authorizerMutation.mutate({routeId: route.routeId, authorizer: event.target.value || null})}>
|
||||||
|
<option value="">Open to anyone</option>
|
||||||
|
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && <small className="muted">Authorizer {authorizerName(route.authorizerId)}</small>}
|
||||||
|
</td>}
|
||||||
|
<td className="cognito-row-actions-cell">
|
||||||
|
{confirmDelete === route.routeId
|
||||||
|
? <span className="apigateway-route-confirm">
|
||||||
|
<span>Delete {route.routeKey}?</span>
|
||||||
|
<button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(route.routeId)}>Delete</button>
|
||||||
|
<button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||||
|
</span>
|
||||||
|
: <button className="icon-btn" type="button" aria-label={`Delete ${route.routeKey}`} disabled={!runtimeReachable} onClick={() => setConfirmDelete(route.routeId)}><Trash2 size={13}/></button>}
|
||||||
|
</td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table></div>}
|
||||||
|
|
||||||
|
<form className="dynamic-form" aria-label="Add a route" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (canCreate) createMutation.mutate()
|
||||||
|
}}>
|
||||||
|
{isWebSocket ? <label className="dynamic-field">
|
||||||
|
<span>Route key</span>
|
||||||
|
<input className="input" list="websocket-route-keys" value={webSocketKey} onChange={(event) => setWebSocketKey(event.target.value)} placeholder="sendMessage"/>
|
||||||
|
<datalist id="websocket-route-keys">{WEBSOCKET_SYSTEM_ROUTES.map((key) => <option key={key} value={key}/>)}</datalist>
|
||||||
|
<small>$connect, $disconnect, $default, or an action such as sendMessage.</small>
|
||||||
|
</label> : <>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Method</span>
|
||||||
|
<select className="input" value={method} disabled={catchAll} onChange={(event) => setMethod(event.target.value)}>
|
||||||
|
{ROUTE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Path</span>
|
||||||
|
<input className="input" aria-label="Path" value={path} disabled={catchAll} onChange={(event) => setPath(event.target.value)} placeholder="/notes/{id}"/>
|
||||||
|
<small>Use {'{id}'} for a path parameter and a final {'{proxy+}'} for everything below.</small>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field http-api-check-field">
|
||||||
|
<input type="checkbox" checked={catchAll} onChange={(event) => setCatchAll(event.target.checked)}/>
|
||||||
|
<span>Catch-all ($default)</span>
|
||||||
|
</label>
|
||||||
|
</>}
|
||||||
|
{!isWebSocket && <label className="dynamic-field">
|
||||||
|
<span>Target</span>
|
||||||
|
<select className="input" value={targetKind} onChange={(event) => setTargetKind(event.target.value as 'lambda' | 'http')}>
|
||||||
|
<option value="lambda">Lambda function</option>
|
||||||
|
<option value="http">HTTP backend</option>
|
||||||
|
</select>
|
||||||
|
</label>}
|
||||||
|
{targetKind === 'lambda' || isWebSocket
|
||||||
|
? <label className="dynamic-field">
|
||||||
|
<span>Lambda function</span>
|
||||||
|
<select className="input" aria-label="Lambda function" value={functionName} onChange={(event) => setFunctionName(event.target.value)}>
|
||||||
|
<option value="">Select a Lambda function</option>
|
||||||
|
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<small>{functionsQuery.isLoading ? 'Loading functions' : functions.length === 0 ? 'Create a Lambda function first.' : isWebSocket ? 'Receives the message as a WebSocket event.' : 'Receives a version 2.0 proxy event.'}</small>
|
||||||
|
</label>
|
||||||
|
: <label className="dynamic-field">
|
||||||
|
<span>Backend URL</span>
|
||||||
|
<input className="input" value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} placeholder="https://backend.example.com/{proxy}"/>
|
||||||
|
<small>Requests are proxied here as they are.</small>
|
||||||
|
</label>}
|
||||||
|
{!isWebSocket && api.authorizers.length > 0 && <label className="dynamic-field">
|
||||||
|
<span>Access</span>
|
||||||
|
<select className="input" aria-label="Access for the new route" value={authorizerId} onChange={(event) => setAuthorizerId(event.target.value)}>
|
||||||
|
<option value="">Open to anyone</option>
|
||||||
|
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>}
|
||||||
|
{isWebSocket && <label className="dynamic-field http-api-check-field">
|
||||||
|
<input type="checkbox" checked={returnsResponse} onChange={(event) => setReturnsResponse(event.target.checked)}/>
|
||||||
|
<span>Send the function's response back to the client</span>
|
||||||
|
</label>}
|
||||||
|
<button className="button primary" type="submit" disabled={!canCreate}>
|
||||||
|
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Adding' : 'Add route'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [stageName, setStageName] = useState('prod')
|
||||||
|
const [autoDeploy, setAutoDeploy] = useState(true)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmDelete(null)
|
||||||
|
setNotice('')
|
||||||
|
}, [api.apiId])
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}),
|
||||||
|
onSuccess: (stage) => {
|
||||||
|
setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deployMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name),
|
||||||
|
onSuccess: (stage) => {
|
||||||
|
setNotice(`Deployed the current routes to ${stage.stageName}.`)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name),
|
||||||
|
onSuccess: () => {
|
||||||
|
setConfirmDelete(null)
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error
|
||||||
|
const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending
|
||||||
|
|
||||||
|
return <section className="table-panel" aria-label="Stages">
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Stages</p>
|
||||||
|
<span className="muted">A stage serves a deployment of the routes. With automatic deployment, every change is live right away.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{api.stages.length > 0 && <div className="cognito-table"><table className="table">
|
||||||
|
<thead><tr><th>Stage</th><th>Deployment</th><th>Updated</th><th aria-label="Actions"/></tr></thead>
|
||||||
|
<tbody>{api.stages.map((stage) => <tr key={stage.stageName}>
|
||||||
|
<td><code>{stage.stageName}</code></td>
|
||||||
|
<td>{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? <code>{stage.deploymentId}</code> : 'Not deployed'}</td>
|
||||||
|
<td>{formatDateTime(stage.lastUpdatedAt) ?? '—'}</td>
|
||||||
|
<td className="cognito-row-actions-cell">
|
||||||
|
{!stage.autoDeploy && <button className="button compact" type="button" disabled={busy} onClick={() => deployMutation.mutate(stage.stageName)}><Rocket size={13}/>Deploy</button>}
|
||||||
|
{confirmDelete === stage.stageName
|
||||||
|
? <span className="apigateway-route-confirm">
|
||||||
|
<span>Delete {stage.stageName}?</span>
|
||||||
|
<button className="button danger compact" type="button" disabled={busy} onClick={() => deleteMutation.mutate(stage.stageName)}>Delete</button>
|
||||||
|
<button className="button compact" type="button" onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||||
|
</span>
|
||||||
|
: <button className="icon-btn" type="button" aria-label={`Delete stage ${stage.stageName}`} disabled={busy} onClick={() => setConfirmDelete(stage.stageName)}><Trash2 size={13}/></button>}
|
||||||
|
</td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table></div>}
|
||||||
|
<form className="dynamic-form" aria-label="Add a stage" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setNotice('')
|
||||||
|
if (stageName.trim()) createMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field"><span>Stage name</span><input className="input" value={stageName} onChange={(event) => setStageName(event.target.value)} placeholder="prod"/></label>
|
||||||
|
<label className="dynamic-field http-api-check-field">
|
||||||
|
<input type="checkbox" checked={autoDeploy} onChange={(event) => setAutoDeploy(event.target.checked)}/>
|
||||||
|
<span>Deploy changes automatically</span>
|
||||||
|
</label>
|
||||||
|
<button className="button primary" type="submit" disabled={busy || !stageName.trim()}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add stage'}</button>
|
||||||
|
</form>
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||||
|
return <section className="table-panel" aria-label="Security">
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow"><ShieldCheck size={13}/> Security</p>
|
||||||
|
<span className="muted">A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AuthorizersSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||||
|
<CorsSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [source, setSource] = useState<'cognito' | 'issuer'>('cognito')
|
||||||
|
const [name, setName] = useState('cognito')
|
||||||
|
const [userPoolId, setUserPoolId] = useState('')
|
||||||
|
const [clientId, setClientId] = useState('')
|
||||||
|
const [issuer, setIssuer] = useState('')
|
||||||
|
const [audience, setAudience] = useState('')
|
||||||
|
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||||
|
|
||||||
|
const poolsQuery = useQuery({
|
||||||
|
queryKey: ['cloud-resources', cloud, 'userpools'],
|
||||||
|
queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal),
|
||||||
|
enabled: runtimeReachable,
|
||||||
|
})
|
||||||
|
const pools = poolsQuery.data ?? []
|
||||||
|
const pool = pools.find((candidate) => candidate.id === userPoolId)
|
||||||
|
const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : []
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito'
|
||||||
|
? {name: name.trim(), userPoolId, userPoolClientId: clientId}
|
||||||
|
: {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}),
|
||||||
|
onSuccess: refresh,
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId),
|
||||||
|
onSuccess: refresh,
|
||||||
|
})
|
||||||
|
const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim())
|
||||||
|
const error = createMutation.error ?? deleteMutation.error
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{api.authorizers.length > 0 && <div className="cognito-table"><table className="table">
|
||||||
|
<thead><tr><th>Authorizer</th><th>Issuer</th><th>Audience</th><th aria-label="Actions"/></tr></thead>
|
||||||
|
<tbody>{api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => <tr key={authorizer.authorizerId}>
|
||||||
|
<td>{authorizer.name} <small className="muted">· {authorizer.type}</small></td>
|
||||||
|
<td className="mono http-api-wrap">{authorizer.issuer ?? '—'}</td>
|
||||||
|
<td className="mono http-api-wrap">{authorizer.audience.join(', ') || '—'}</td>
|
||||||
|
<td className="cognito-row-actions-cell"><button className="icon-btn" type="button" aria-label={`Delete authorizer ${authorizer.name}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(authorizer.authorizerId)}><Trash2 size={13}/></button></td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table></div>}
|
||||||
|
<form className="dynamic-form" aria-label="Add a JWT authorizer" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (ready && name.trim()) createMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)}/></label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Tokens from</span>
|
||||||
|
<select className="input" value={source} onChange={(event) => setSource(event.target.value as 'cognito' | 'issuer')}>
|
||||||
|
<option value="cognito">A Cognito user pool</option>
|
||||||
|
<option value="issuer">Another OpenID Connect issuer</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{source === 'cognito' ? <>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>User pool</span>
|
||||||
|
<select className="input" aria-label="User pool" value={userPoolId} onChange={(event) => { setUserPoolId(event.target.value); setClientId('') }}>
|
||||||
|
<option value="">Select a user pool</option>
|
||||||
|
{pools.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{poolsQuery.isSuccess && pools.length === 0 && <small>Create a Cognito user pool first.</small>}
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>App client</span>
|
||||||
|
<select className="input" aria-label="App client" value={clientId} disabled={!pool} onChange={(event) => setClientId(event.target.value)}>
|
||||||
|
<option value="">Select an app client</option>
|
||||||
|
{clients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}
|
||||||
|
</select>
|
||||||
|
<small>Only this client's tokens are accepted.</small>
|
||||||
|
</label>
|
||||||
|
</> : <>
|
||||||
|
<label className="dynamic-field"><span>Issuer URL</span><input className="input" value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="https://login.example.com/"/></label>
|
||||||
|
<label className="dynamic-field"><span>Audience</span><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="client-id, another-client-id"/></label>
|
||||||
|
</>}
|
||||||
|
<button className="button primary" type="submit" disabled={!runtimeReachable || !ready || !name.trim() || createMutation.isPending}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add authorizer'}</button>
|
||||||
|
</form>
|
||||||
|
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [origins, setOrigins] = useState('')
|
||||||
|
const [methods, setMethods] = useState<string[]>([])
|
||||||
|
const [headers, setHeaders] = useState('')
|
||||||
|
const [exposeHeaders, setExposeHeaders] = useState('')
|
||||||
|
const [maxAge, setMaxAge] = useState('')
|
||||||
|
const [credentials, setCredentials] = useState(false)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cors = api.cors
|
||||||
|
setOrigins(cors?.allowOrigins.join(', ') ?? '*')
|
||||||
|
setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS'])
|
||||||
|
setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization')
|
||||||
|
setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '')
|
||||||
|
setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds))
|
||||||
|
setCredentials(cors?.allowCredentials ?? false)
|
||||||
|
setNotice('')
|
||||||
|
}, [api.apiId, api.cors])
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors),
|
||||||
|
onSuccess: (cors) => {
|
||||||
|
setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.')
|
||||||
|
return queryClient.invalidateQueries({queryKey})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className="input-row resource-table-bar"><div>
|
||||||
|
<p className="eyebrow">CORS</p>
|
||||||
|
<span className="muted">{api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'}</span>
|
||||||
|
</div></div>
|
||||||
|
<form className="dynamic-form" aria-label="CORS" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setNotice('')
|
||||||
|
mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials})
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field"><span>Allowed origins</span><input className="input" value={origins} onChange={(event) => setOrigins(event.target.value)} placeholder="https://app.example.com"/><small>Comma separated, or * for any site.</small></label>
|
||||||
|
<div className="dynamic-field"><span>Allowed methods</span><div className="http-api-checks">{CORS_METHODS.map((value) => <label key={value}><input type="checkbox" checked={methods.includes(value)} onChange={() => setMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div></div>
|
||||||
|
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={headers} onChange={(event) => setHeaders(event.target.value)}/></label>
|
||||||
|
<label className="dynamic-field"><span>Exposed headers</span><input className="input" value={exposeHeaders} onChange={(event) => setExposeHeaders(event.target.value)} placeholder="x-request-id"/></label>
|
||||||
|
<label className="dynamic-field"><span>Max age (seconds)</span><input className="input" inputMode="numeric" value={maxAge} onChange={(event) => setMaxAge(event.target.value)} placeholder="300"/></label>
|
||||||
|
<label className="dynamic-field http-api-check-field"><input type="checkbox" checked={credentials} onChange={(event) => setCredentials(event.target.checked)}/><span>Allow credentials (cookies)</span></label>
|
||||||
|
<div className="cloudwatch-form-actions">
|
||||||
|
<button className="button primary" type="submit" disabled={!runtimeReachable || mutation.isPending}>{mutation.isPending ? 'Saving' : 'Save CORS'}</button>
|
||||||
|
{api.cors && <button className="button" type="button" disabled={!runtimeReachable || mutation.isPending} onClick={() => { setNotice(''); mutation.mutate(null) }}>Remove CORS</button>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{mutation.error instanceof Error && <p className="error-text compact-text padded" role="alert">{mutation.error.message}</p>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
|
||||||
|
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default')
|
||||||
|
const [method, setMethod] = useState('GET')
|
||||||
|
const [path, setPath] = useState(suggestedPath(api.routes))
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStage(api.stages[0]?.stageName ?? '$default')
|
||||||
|
setPath(suggestedPath(api.routes))
|
||||||
|
// Only when another API is opened; keep what the user typed while this one refreshes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [api.apiId])
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}),
|
||||||
|
})
|
||||||
|
const result = mutation.data
|
||||||
|
|
||||||
|
return <section className="table-panel apigateway-test-panel" aria-label="Send a request">
|
||||||
|
<div className="input-row resource-table-bar"><div>
|
||||||
|
<p className="eyebrow"><Send size={13}/> Send a request</p>
|
||||||
|
<span className="muted">Sends a real request to the stage, through its routes and authorizers. For a protected route, add an <code>authorization</code> header with <code>Bearer</code> and a token.</span>
|
||||||
|
</div></div>
|
||||||
|
<div className="apigateway-test-workspace">
|
||||||
|
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!mutation.isPending) mutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field"><span>Stage</span>
|
||||||
|
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
|
||||||
|
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>Method</span>
|
||||||
|
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>{TEST_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>Path</span><input className="input" aria-label="Request path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/notes/123"/></label>
|
||||||
|
<label className="dynamic-field"><span>Query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="page=1"/></label>
|
||||||
|
<label className="dynamic-field"><span>Headers (JSON)</span><textarea className="textarea code-textarea small" value={headers} onChange={(event) => setHeaders(event.target.value)} spellCheck={false}/></label>
|
||||||
|
{!['GET', 'HEAD'].includes(method) && <label className="dynamic-field"><span>Body</span><textarea className="textarea code-textarea" aria-label="Request body" value={body} onChange={(event) => setBody(event.target.value)} spellCheck={false}/></label>}
|
||||||
|
<button className="button primary" type="submit" disabled={!runtimeReachable || api.stages.length === 0 || mutation.isPending}>
|
||||||
|
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Sending' : 'Send request'}
|
||||||
|
</button>
|
||||||
|
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
|
||||||
|
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
|
||||||
|
</form>
|
||||||
|
<div className="apigateway-test-response">
|
||||||
|
{result ? <div className="apigateway-test-result">
|
||||||
|
<div className="apigateway-test-result-header">
|
||||||
|
<strong>Response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
|
||||||
|
<small>{result.durationMs} ms</small>
|
||||||
|
</div>
|
||||||
|
<details><summary>Response headers</summary><pre>{JSON.stringify(result.headers, null, 2)}</pre></details>
|
||||||
|
<pre>{formatResponseBody(result.body)}</pre>
|
||||||
|
</div> : <p className="muted compact-text">Send a request to see the response.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebSocketTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
|
||||||
|
const actionField = api.routeSelectionExpression?.match(/^\$request\.body\.(.+)$/)?.[1] ?? 'action'
|
||||||
|
const firstAction = api.routes.find((route) => !WEBSOCKET_SYSTEM_ROUTES.includes(route.routeKey))?.routeKey ?? 'sendMessage'
|
||||||
|
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '')
|
||||||
|
const [message, setMessage] = useState(JSON.stringify({[actionField]: firstAction, data: 'hello'}, null, 2))
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStage(api.stages[0]?.stageName ?? '')
|
||||||
|
// Only when another API is opened.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [api.apiId])
|
||||||
|
|
||||||
|
const mutation = useMutation({mutationFn: () => testWebSocketApi(cloud, api.apiId, {stage, message, query})})
|
||||||
|
const result = mutation.data
|
||||||
|
|
||||||
|
return <section className="table-panel apigateway-test-panel" aria-label="Send a message">
|
||||||
|
<div className="input-row resource-table-bar"><div>
|
||||||
|
<p className="eyebrow"><Send size={13}/> Send a message</p>
|
||||||
|
<span className="muted">Connects to the stage like a client would, sends one message, and shows what comes back. Replies need a route that sends the function's response back.</span>
|
||||||
|
</div></div>
|
||||||
|
<div className="apigateway-test-workspace">
|
||||||
|
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!mutation.isPending && stage) mutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field"><span>Stage</span>
|
||||||
|
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
|
||||||
|
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dynamic-field"><span>Connection query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="token=abc"/><small>Passed to $connect.</small></label>
|
||||||
|
<label className="dynamic-field"><span>Message</span><textarea className="textarea code-textarea" aria-label="Message" value={message} onChange={(event) => setMessage(event.target.value)} spellCheck={false}/></label>
|
||||||
|
<button className="button primary" type="submit" disabled={!runtimeReachable || !stage || mutation.isPending}>
|
||||||
|
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Connecting' : 'Connect and send'}
|
||||||
|
</button>
|
||||||
|
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
|
||||||
|
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
|
||||||
|
</form>
|
||||||
|
<div className="apigateway-test-response">
|
||||||
|
{result ? <div className="apigateway-test-result">
|
||||||
|
<div className="apigateway-test-result-header">
|
||||||
|
<strong>{result.connected ? <span className="status-success">Connected</span> : <span className="status-error">Not connected</span>}</strong>
|
||||||
|
<small>{result.durationMs} ms</small>
|
||||||
|
</div>
|
||||||
|
{result.error && <p className="error-text compact-text">{result.error}</p>}
|
||||||
|
{result.connected && result.messages.length === 0 && <p className="muted compact-text">No reply. Check the route sends the function's response back.</p>}
|
||||||
|
{result.messages.map((reply, index) => <pre key={index}>{formatResponseBody(reply)}</pre>)}
|
||||||
|
</div> : <p className="muted compact-text">Send a message to see the replies.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTarget(route: HttpApiRouteOverview): string {
|
||||||
|
const integration = route.integration
|
||||||
|
if (!integration) return 'No target'
|
||||||
|
if (integration.functionName) return `Lambda: ${integration.functionName}`
|
||||||
|
if (integration.type === 'HTTP_PROXY') return `HTTP: ${integration.uri ?? ''}`
|
||||||
|
return integration.type
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageUrl(api: HttpApiOverview, stage: HttpApiStageOverview, endpoint: string | null): string | null {
|
||||||
|
if (!endpoint) return null
|
||||||
|
return `${api.protocol === 'WEBSOCKET' ? endpoint.replace(/^http/, 'ws') : endpoint}${stage.invokePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path to try first: the first route's path with its parameters filled in. */
|
||||||
|
function suggestedPath(routes: HttpApiRouteOverview[]): string {
|
||||||
|
const path = routes.map((route) => route.routeKey.split(' ')[1]).find(Boolean)
|
||||||
|
if (!path || path === '/') return '/hello'
|
||||||
|
return path.replace(/\{[^}]+\+\}/g, 'anything').replace(/\{[^}]+\}/g, '1')
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@ export function Layout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<span className="sidebar-footer-text">Floci DevTools · Local</span>
|
<span className="sidebar-footer-text">TheCloud · Local</span>
|
||||||
<button
|
<button
|
||||||
ref={toggleRef}
|
ref={toggleRef}
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function LightsailLaunchPanel({isSubmitting, submitError, onCancel, onSub
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Amazon Lightsail</p>
|
<p className="eyebrow">Amazon Lightsail</p>
|
||||||
<h2>Create an instance</h2>
|
<h2>Create an instance</h2>
|
||||||
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally through Floci.</p>
|
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally on TheCloud.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-btn" type="button" onClick={onCancel} aria-label="Close create instance">×</button>
|
<button className="icon-btn" type="button" onClick={onCancel} aria-label="Close create instance">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult;
|
|||||||
</div>
|
</div>
|
||||||
{stillRunning && (
|
{stillRunning && (
|
||||||
<div className="muted padded">
|
<div className="muted padded">
|
||||||
Floci hadn't finished this query when the console stopped waiting — these rows are a partial,
|
The runtime hadn't finished this query when the console stopped waiting — these rows are a partial,
|
||||||
in-progress result, not the full answer. Run the query again to see if it has finished
|
in-progress result, not the full answer. Run the query again to see if it has finished
|
||||||
(query id: {result.queryId}).
|
(query id: {result.queryId}).
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {fireEvent, render, screen} from '@testing-library/react'
|
||||||
|
import {expect, test} from 'vitest'
|
||||||
|
import {MetricChart} from './MetricChart'
|
||||||
|
|
||||||
|
const series = {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [
|
||||||
|
{timestamp: '2026-10-25T10:00:00Z', value: 3},
|
||||||
|
{timestamp: '2026-10-25T10:01:00Z', value: 7},
|
||||||
|
]}
|
||||||
|
|
||||||
|
test('draws the line, labels the latest value and the threshold, and tabulates the points', () => {
|
||||||
|
const {container} = render(<MetricChart series={series} threshold={5}/>)
|
||||||
|
|
||||||
|
expect(container.querySelector('.metric-chart-line')?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
|
||||||
|
expect(screen.getByText('Threshold 5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('img', {name: /Errors \(Sum\), 2 points; latest 7 .*threshold 5/})).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Data table (2 points)')).toBeInTheDocument()
|
||||||
|
expect(container.querySelectorAll('.metric-chart-table tbody tr')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the tooltip from the keyboard, value first', () => {
|
||||||
|
render(<MetricChart series={series}/>)
|
||||||
|
const chart = screen.getByRole('img')
|
||||||
|
|
||||||
|
fireEvent.focus(chart)
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(/^7Errors · Sum/)
|
||||||
|
fireEvent.keyDown(chart, {key: 'ArrowLeft'})
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(/^3/)
|
||||||
|
fireEvent.keyDown(chart, {key: 'Escape'})
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('says when there is no data', () => {
|
||||||
|
render(<MetricChart series={{...series, points: []}}/>)
|
||||||
|
expect(screen.getByText('No data points in this time range.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent} from 'react'
|
||||||
|
import {formatValue, niceTicks, timeTicks} from '@/lib/metricChart'
|
||||||
|
import type {MetricSeries} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
series: MetricSeries
|
||||||
|
/** The alarm threshold, drawn as a dashed reference line. */
|
||||||
|
threshold?: number | null
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN = {top: 16, right: 56, bottom: 28, left: 48}
|
||||||
|
/** Few points read as a scatter of values, so each gets a marker. */
|
||||||
|
const MARKER_LIMIT = 24
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One metric over time: a 2px line, a solid hairline grid, the latest value labelled at the end,
|
||||||
|
* an optional dashed threshold, a crosshair tooltip that snaps to the nearest point (keyboard too)
|
||||||
|
* and a table view of the same values.
|
||||||
|
*/
|
||||||
|
export function MetricChart({series, threshold = null, height = 220}: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [width, setWidth] = useState(640)
|
||||||
|
const [active, setActive] = useState<number | null>(null)
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = containerRef.current
|
||||||
|
// Without ResizeObserver (old browsers, tests) the chart keeps its default width.
|
||||||
|
if (!element || typeof ResizeObserver === 'undefined') return
|
||||||
|
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width))))
|
||||||
|
observer.observe(element)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const points = series.points
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
const innerWidth = width - MARGIN.left - MARGIN.right
|
||||||
|
const innerHeight = height - MARGIN.top - MARGIN.bottom
|
||||||
|
const times = points.map((point) => Date.parse(point.timestamp))
|
||||||
|
const values = points.map((point) => point.value)
|
||||||
|
const maxValue = Math.max(...values, threshold ?? -Infinity, 0)
|
||||||
|
const minValue = Math.min(...values, threshold ?? Infinity, 0)
|
||||||
|
const ticks = niceTicks(minValue, maxValue === minValue ? minValue + 1 : maxValue)
|
||||||
|
const yMin = ticks[0]
|
||||||
|
const yMax = ticks[ticks.length - 1]
|
||||||
|
const tMin = times.length ? Math.min(...times) : 0
|
||||||
|
const tMax = times.length ? Math.max(...times) : 1
|
||||||
|
const x = (time: number) => MARGIN.left + (tMax === tMin ? innerWidth / 2 : ((time - tMin) / (tMax - tMin)) * innerWidth)
|
||||||
|
const y = (value: number) => MARGIN.top + innerHeight - ((value - yMin) / (yMax - yMin)) * innerHeight
|
||||||
|
return {innerWidth, innerHeight, times, ticks, x, y, tMin, tMax}
|
||||||
|
}, [points, threshold, width, height])
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
return <div className="metric-chart metric-chart--empty" ref={containerRef}><p className="muted">No data points in this time range.</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const {times, ticks, x, y, tMin, tMax} = geometry
|
||||||
|
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(times[index]).toFixed(1)},${y(point.value).toFixed(1)}`).join(' ')
|
||||||
|
const last = points.length - 1
|
||||||
|
const spansDays = tMax - tMin > 24 * 3600_000
|
||||||
|
const xTicks = timeTicks(tMin, tMax, Math.max(2, Math.floor(geometry.innerWidth / 120)))
|
||||||
|
const shown = active ?? null
|
||||||
|
|
||||||
|
const nearest = (clientX: number, svg: SVGSVGElement) => {
|
||||||
|
const box = svg.getBoundingClientRect()
|
||||||
|
const px = ((clientX - box.left) / box.width) * width
|
||||||
|
let best = 0
|
||||||
|
for (let index = 1; index < times.length; index++) {
|
||||||
|
if (Math.abs(x(times[index]) - px) < Math.abs(x(times[best]) - px)) best = index
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
const onPointerMove = (event: PointerEvent<SVGSVGElement>) => setActive(nearest(event.clientX, event.currentTarget))
|
||||||
|
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
|
||||||
|
if (event.key === 'ArrowLeft') { event.preventDefault(); setActive((index) => Math.max(0, (index ?? last) - 1)) }
|
||||||
|
if (event.key === 'ArrowRight') { event.preventDefault(); setActive((index) => Math.min(last, (index ?? last) + 1)) }
|
||||||
|
if (event.key === 'Home') { event.preventDefault(); setActive(0) }
|
||||||
|
if (event.key === 'End') { event.preventDefault(); setActive(last) }
|
||||||
|
if (event.key === 'Escape') setActive(null)
|
||||||
|
}
|
||||||
|
const summary = `${series.label} (${series.statistic}), ${points.length} points; latest ${formatValue(points[last].value)} at ${formatTime(times[last], spansDays)}${threshold === null ? '' : `; threshold ${formatValue(threshold)}`}`
|
||||||
|
|
||||||
|
return <div className="metric-chart" ref={containerRef}>
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
role="img"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerLeave={() => setActive(null)}
|
||||||
|
onFocus={() => setActive(last)}
|
||||||
|
onBlur={() => setActive(null)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<title id={titleId}>{summary}</title>
|
||||||
|
{ticks.map((tick) => (
|
||||||
|
<g key={tick}>
|
||||||
|
<line className="metric-chart-grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(tick)} y2={y(tick)}/>
|
||||||
|
<text className="metric-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">{formatValue(tick)}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{xTicks.map((tick) => (
|
||||||
|
<text key={tick} className="metric-chart-axis" x={x(tick)} y={height - 8} textAnchor="middle">{formatTime(tick, spansDays)}</text>
|
||||||
|
))}
|
||||||
|
{threshold !== null && <g>
|
||||||
|
<line className="metric-chart-threshold" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(threshold)} y2={y(threshold)}/>
|
||||||
|
<text className="metric-chart-label" x={width - MARGIN.right} y={y(threshold) - 6} textAnchor="end">{`Threshold ${formatValue(threshold)}`}</text>
|
||||||
|
</g>}
|
||||||
|
<path className="metric-chart-line" d={path}/>
|
||||||
|
{points.length <= MARKER_LIMIT && points.map((point, index) => (
|
||||||
|
<circle key={point.timestamp} className="metric-chart-marker" cx={x(times[index])} cy={y(point.value)} r={4}/>
|
||||||
|
))}
|
||||||
|
<text className="metric-chart-label metric-chart-label--strong" x={x(times[last]) + 8} y={y(points[last].value)} dy="0.32em">{formatValue(points[last].value)}</text>
|
||||||
|
{shown !== null && <g pointerEvents="none">
|
||||||
|
<line className="metric-chart-crosshair" x1={x(times[shown])} x2={x(times[shown])} y1={MARGIN.top} y2={height - MARGIN.bottom}/>
|
||||||
|
<circle className="metric-chart-marker metric-chart-marker--active" cx={x(times[shown])} cy={y(points[shown].value)} r={5}/>
|
||||||
|
</g>}
|
||||||
|
</svg>
|
||||||
|
{shown !== null && <div className="metric-chart-tooltip" role="status" style={tooltipPosition(x(times[shown]), width)}>
|
||||||
|
<strong>{formatValue(points[shown].value)}</strong>
|
||||||
|
<span><span className="metric-chart-key" aria-hidden="true"/>{series.label} · {series.statistic}</span>
|
||||||
|
<span className="muted">{formatTime(times[shown], true)}</span>
|
||||||
|
</div>}
|
||||||
|
<details className="metric-chart-table">
|
||||||
|
<summary>Data table ({points.length} points)</summary>
|
||||||
|
<table className="table"><thead><tr><th>Time</th><th>{series.statistic}</th></tr></thead><tbody>{points.slice().reverse().map((point) => (
|
||||||
|
<tr key={point.timestamp}><td>{new Date(point.timestamp).toLocaleString()}</td><td className="mono">{formatValue(point.value)}</td></tr>
|
||||||
|
))}</tbody></table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipPosition(px: number, width: number) {
|
||||||
|
return px > width * 0.6 ? {right: width - px + 12} : {left: px + 12}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: number, withDate: boolean): string {
|
||||||
|
const date = new Date(time)
|
||||||
|
return withDate
|
||||||
|
? date.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})
|
||||||
|
: date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'})
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter} from 'react-router-dom'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {PipePanel} from './PipePanel'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
startCloudResource: vi.fn(),
|
||||||
|
stopCloudResource: vi.fn(),
|
||||||
|
sendSqsMessage: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
function pipe(status = 'RUNNING', metadata: Record<string, unknown> = {}): CloudResource {
|
||||||
|
return {
|
||||||
|
id: 'orders-pipe', name: 'orders-pipe', cloud: 'aws', service: 'pipes', type: 'pipe', region: 'us-east-1', createdAt: null, status,
|
||||||
|
metadata: {
|
||||||
|
desiredState: status, source: 'arn:aws:sqs:us-east-1:000000000000:pipe-src', sourceName: 'pipe-src (SQS queue)',
|
||||||
|
target: 'arn:aws:lambda:us-east-1:000000000000:function:worker', targetName: 'worker (Lambda function)',
|
||||||
|
filterPatterns: ['{"body":{"kind":["order"]}}'], batchSize: 5, inputTemplate: '{"id": <$.messageId>}',
|
||||||
|
roleArn: 'arn:aws:iam::000000000000:role/pipe-role',
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(resource: CloudResource) {
|
||||||
|
api.getCloudResource.mockResolvedValue(resource)
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><PipePanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.startCloudResource.mockResolvedValue(undefined)
|
||||||
|
api.stopCloudResource.mockResolvedValue(undefined)
|
||||||
|
api.sendSqsMessage.mockResolvedValue({messageId: 'm-1'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the flow from source through the filter to the target, linked to each service', async () => {
|
||||||
|
renderPanel(pipe())
|
||||||
|
|
||||||
|
const flow = await screen.findByRole('list', {name: 'Event flow'})
|
||||||
|
expect(within(flow).getByRole('link', {name: 'pipe-src (SQS queue)'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
|
||||||
|
expect(within(flow).getByRole('link', {name: 'worker (Lambda function)'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
|
||||||
|
expect(within(flow).getByText('{"body":{"kind":["order"]}}')).toBeInTheDocument()
|
||||||
|
expect(within(flow).getByText('batches of 5')).toBeInTheDocument()
|
||||||
|
expect(within(flow).getByText('{"id": <$.messageId>}')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stops a running pipe', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(pipe())
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'Stop'}))
|
||||||
|
expect(api.stopCloudResource).toHaveBeenCalledWith('aws', 'pipes', 'orders-pipe')
|
||||||
|
expect(await screen.findByText(/Pipe stopped/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends a test message to an SQS source queue', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel(pipe())
|
||||||
|
|
||||||
|
const form = await screen.findByRole('form', {name: 'Send test message'})
|
||||||
|
await user.click(within(form).getByRole('button', {name: 'Send to pipe-src'}))
|
||||||
|
expect(api.sendSqsMessage).toHaveBeenCalledWith('aws', 'pipe-src', {messageBody: '{"kind": "order", "id": 1}'})
|
||||||
|
expect(await screen.findByText(/Sent message m-1/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('offers no test message for a stream source, and cannot toggle a pipe mid-change', async () => {
|
||||||
|
renderPanel(pipe('STARTING', {source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', sourceName: 'clicks (Kinesis stream)', desiredState: 'RUNNING'}))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('link', {name: 'clicks (Kinesis stream)'})).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('form', {name: 'Send test message'})).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', {name: 'Start'})).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {ArrowRight, RefreshCw} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {type ReactNode, useEffect, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {getCloudResource, sendSqsMessage, startCloudResource, stopCloudResource} from '@/api/cloudProxyClient'
|
||||||
|
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import type {CloudResource} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where each ARN's service lives in TheCloud, so a step links to the resource behind it. */
|
||||||
|
const CONSOLE_SERVICE: Record<string, string> = {
|
||||||
|
sqs: 'messaging',
|
||||||
|
lambda: 'serverless',
|
||||||
|
sns: 'notifications',
|
||||||
|
events: 'events',
|
||||||
|
states: 'workflows',
|
||||||
|
kinesis: 'kinesis',
|
||||||
|
dynamodb: 'nosql',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_MESSAGE = '{"kind": "order", "id": 1}'
|
||||||
|
|
||||||
|
export function PipePanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [message, setMessage] = useState(SAMPLE_MESSAGE)
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const pipeKey = ['pipe', accountId, resource?.id]
|
||||||
|
|
||||||
|
// The table row is a ListPipes entry; the filter, template and role come from DescribePipe.
|
||||||
|
const pipeQuery = useQuery({
|
||||||
|
queryKey: pipeKey,
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'pipes', resource?.id ?? '', signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessage(SAMPLE_MESSAGE)
|
||||||
|
setNotice('')
|
||||||
|
}, [resource?.id])
|
||||||
|
|
||||||
|
const refresh = () => Promise.all([
|
||||||
|
queryClient.invalidateQueries({queryKey: pipeKey}),
|
||||||
|
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'pipes')}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const stateMutation = useMutation({
|
||||||
|
mutationFn: (running: boolean) => running ? startCloudResource('aws', 'pipes', resource?.id ?? '') : stopCloudResource('aws', 'pipes', resource?.id ?? ''),
|
||||||
|
onSuccess: (_result, running) => {
|
||||||
|
setNotice(running ? 'Pipe started. It reads the source again.' : 'Pipe stopped. Records wait in the source until it starts again.')
|
||||||
|
return refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: (queue: string) => sendSqsMessage('aws', queue, {messageBody: message}),
|
||||||
|
onSuccess: (sent) => setNotice(`Sent message ${sent.messageId} to the source queue. The pipe picks it up within a few seconds.`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a pipe</h3><p>Choose a pipe to see where its events flow, start or stop it, or send it a test message.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipe = pipeQuery.data ?? resource
|
||||||
|
const metadata = pipe.metadata
|
||||||
|
const state = pipe.status ?? 'UNKNOWN'
|
||||||
|
const running = state === 'RUNNING'
|
||||||
|
const filters = Array.isArray(metadata.filterPatterns) ? metadata.filterPatterns as string[] : []
|
||||||
|
const source = text(metadata.source)
|
||||||
|
const sourceQueue = source.split(':')[2] === 'sqs' ? source.split(':')[5] : ''
|
||||||
|
const pending = stateMutation.isPending || sendMutation.isPending
|
||||||
|
const disabled = !runtimeReachable || pending
|
||||||
|
const error = pipeQuery.error ?? stateMutation.error ?? sendMutation.error
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Pipe ${pipe.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Pipe</p><span className="muted">{state}{metadata.desiredState && metadata.desiredState !== state ? ` · going to ${String(metadata.desiredState)}` : ''}{metadata.description ? ` · ${String(metadata.description)}` : ''}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={disabled || !['RUNNING', 'STOPPED'].includes(state)} onClick={() => { setNotice(''); stateMutation.mutate(!running) }}>{running ? 'Stop' : 'Start'}</button>
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || pipeQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{pipeQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{typeof metadata.stateReason === 'string' && metadata.stateReason && <p className="error-text compact-text pipe-reason">{metadata.stateReason}</p>}
|
||||||
|
|
||||||
|
<ol className="pipe-flow" aria-label="Event flow">
|
||||||
|
<PipeStep label="Source" arn={source} name={metadata.sourceName} detail={sourceDetail(metadata)}/>
|
||||||
|
{filters.length > 0 && <li className="pipe-step"><span className="eyebrow">Filter</span>{filters.map((pattern) => <code key={pattern} className="pipe-code">{pattern}</code>)}<ArrowRight size={14} className="pipe-arrow" aria-hidden/></li>}
|
||||||
|
{text(metadata.enrichment) && <PipeStep label="Enrichment" arn={text(metadata.enrichment)} name={null}/>}
|
||||||
|
<PipeStep label="Target" arn={text(metadata.target)} name={metadata.targetName} detail={text(metadata.inputTemplate) ? <>input template <code className="pipe-code">{text(metadata.inputTemplate)}</code></> : undefined}/>
|
||||||
|
</ol>
|
||||||
|
{text(metadata.roleArn) && <p className="muted compact-text pipe-role">Runs as <code>{text(metadata.roleArn)}</code></p>}
|
||||||
|
|
||||||
|
{sourceQueue && <>
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Try it</p><span className="muted">Send a message to the source queue{filters.length > 0 ? '. Only messages matching the filter reach the target' : ''}.</span></div></div>
|
||||||
|
<form className="dynamic-form" aria-label="Send test message" onSubmit={(event) => { event.preventDefault(); setNotice(''); sendMutation.mutate(sourceQueue) }}>
|
||||||
|
<label className="dynamic-field dynamic-field--span"><span>Message body</span>
|
||||||
|
<textarea className="textarea code-textarea small" rows={3} value={message} onChange={(event) => setMessage(event.target.value)} disabled={disabled} required/>
|
||||||
|
</label>
|
||||||
|
<div><button className="button primary" type="submit" disabled={disabled || !message.trim()}>{sendMutation.isPending ? 'Sending' : `Send to ${sourceQueue}`}</button></div>
|
||||||
|
</form>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipeStep({label, arn, name, detail}: {label: string; arn: string; name: unknown; detail?: ReactNode}) {
|
||||||
|
const service = CONSOLE_SERVICE[arn.split(':')[2] ?? '']
|
||||||
|
const title = typeof name === 'string' && name ? name : arn
|
||||||
|
return <li className="pipe-step">
|
||||||
|
<span className="eyebrow">{label}</span>
|
||||||
|
{service ? <Link to={`/cloud-explorer/aws/${service}`} className="mono">{title}</Link> : <span className="mono">{title}</span>}
|
||||||
|
<small className="muted mono pipe-arn">{arn}</small>
|
||||||
|
{detail && <small className="muted">{detail}</small>}
|
||||||
|
<ArrowRight size={14} className="pipe-arrow" aria-hidden/>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceDetail(metadata: Record<string, unknown>): ReactNode {
|
||||||
|
const parts = [
|
||||||
|
metadata.batchSize ? `batches of ${String(metadata.batchSize)}` : '',
|
||||||
|
metadata.startingPosition ? `from ${String(metadata.startingPosition)}` : '',
|
||||||
|
metadata.topicName ? `topic ${String(metadata.topicName)}` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {render, screen, within} from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import {MemoryRouter} from 'react-router-dom'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {QueryPanel} from './QueryPanel'
|
||||||
|
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
describeQueryCatalog: vi.fn(),
|
||||||
|
getCloudResource: vi.fn(),
|
||||||
|
listQueries: vi.fn(),
|
||||||
|
startQuery: vi.fn(),
|
||||||
|
getQueryResult: vi.fn(),
|
||||||
|
stopQuery: vi.fn(),
|
||||||
|
storageObjectDownloadUrl: vi.fn((_cloud: string, bucket: string, key: string) => `/download/${bucket}/${key}`),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||||
|
|
||||||
|
const workGroup: CloudResource = {id: 'analysts', name: 'analysts', cloud: 'aws', service: 'query', type: 'athenaWorkGroup', region: null, createdAt: null, status: 'ENABLED', metadata: {outputLocation: 's3://results/'}}
|
||||||
|
|
||||||
|
function execution(overrides: Partial<QueryExecutionSummary> = {}): QueryExecutionSummary {
|
||||||
|
return {
|
||||||
|
id: 'q1', sql: 'SELECT * FROM orders LIMIT 10', database: 'shop', workGroup: 'analysts', state: 'SUCCEEDED', stateReason: null, statementType: 'DML',
|
||||||
|
submittedAt: '2026-10-25T21:00:00.000Z', completedAt: null, runtimeMs: 42, dataScannedBytes: 0, outputLocation: 's3://results/q1.csv', ...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return render(<MemoryRouter><QueryClientProvider client={client}><QueryPanel resource={workGroup} runtimeReachable/></QueryClientProvider></MemoryRouter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getCloudResource.mockResolvedValue({...workGroup, metadata: {outputLocation: 's3://results/athena/'}})
|
||||||
|
api.describeQueryCatalog.mockResolvedValue({databases: ['logs', 'shop'], database: 'logs', tables: [{name: 'orders', columns: [{name: 'id', type: 'int'}, {name: 'amount', type: 'double'}]}]})
|
||||||
|
api.listQueries.mockResolvedValue([])
|
||||||
|
api.startQuery.mockResolvedValue(execution({state: 'RUNNING'}))
|
||||||
|
api.getQueryResult.mockResolvedValue({execution: execution(), columns: [{name: 'id', type: 'integer'}, {name: 'amount', type: 'double'}], rows: [['1', '10.5'], ['2', null]], truncated: false})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starts from a preview of the first table and lists its columns', async () => {
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('amount')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Database')).toHaveValue('logs')
|
||||||
|
expect(await screen.findByText('s3://results/athena/')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runs the query in the workgroup and shows its rows', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
|
||||||
|
await user.click(screen.getByRole('button', {name: 'Run'}))
|
||||||
|
|
||||||
|
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM orders LIMIT 10', database: 'logs'})
|
||||||
|
expect(await screen.findByText('10.5')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('NULL')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 rows')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', {name: 'Download CSV'})).toHaveAttribute('href', '/download/results/q1.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows why a query failed', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getQueryResult.mockResolvedValue({execution: execution({state: 'FAILED', stateReason: 'Table nope does not exist'}), columns: [], rows: [], truncated: false})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const sql = await screen.findByDisplayValue('SELECT * FROM orders LIMIT 10')
|
||||||
|
await user.clear(sql)
|
||||||
|
await user.type(sql, 'SELECT * FROM nope')
|
||||||
|
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||||
|
|
||||||
|
expect(api.startQuery).toHaveBeenCalledWith('aws', 'analysts', {sql: 'SELECT * FROM nope', database: 'logs'})
|
||||||
|
expect(await screen.findByText('Table nope does not exist')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', {name: 'Download CSV'})).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens a query from the history', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.listQueries.mockResolvedValue([execution({id: 'q0', sql: 'SELECT count(*) FROM orders', database: 'shop'})])
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', {name: 'SELECT count(*) FROM orders'}))
|
||||||
|
expect(screen.getByLabelText('SQL')).toHaveValue('SELECT count(*) FROM orders')
|
||||||
|
expect(api.getQueryResult).toHaveBeenCalledWith('aws', 'analysts', 'q0', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('points to the Glue Data Catalog when there is nothing to query', async () => {
|
||||||
|
api.describeQueryCatalog.mockResolvedValue({databases: [], database: null, tables: []})
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: 'Glue Data Catalog'})
|
||||||
|
expect(link).toHaveAttribute('href', '/cloud-explorer/aws/datacatalog')
|
||||||
|
expect(within(screen.getByRole('form', {name: 'Query'})).getByRole('button', {name: 'Run'})).toBeDisabled()
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {Download, Play, RefreshCw, Square} from 'lucide-react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {describeQueryCatalog, getCloudResource, getQueryResult, listQueries, startQuery, stopQuery, storageObjectDownloadUrl} from '@/api/cloudProxyClient'
|
||||||
|
import {useAccountId} from '@/lib/accountStore'
|
||||||
|
import {formatDateTime} from '@/lib/format'
|
||||||
|
import type {CloudResource, QueryExecutionSummary} from '@/types/resource'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
resource?: CloudResource
|
||||||
|
runtimeReachable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Athena reports a running query's state; it is re-read this often until it finishes. */
|
||||||
|
const RUNNING_POLL_MS = 1000
|
||||||
|
const FINISHED = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED'])
|
||||||
|
|
||||||
|
export function QueryPanel({resource, runtimeReachable}: Props) {
|
||||||
|
const accountId = useAccountId()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const workGroup = resource?.id ?? ''
|
||||||
|
const [database, setDatabase] = useState('')
|
||||||
|
const [sql, setSql] = useState('')
|
||||||
|
const [activeQueryId, setActiveQueryId] = useState<string | null>(null)
|
||||||
|
const [seededFor, setSeededFor] = useState<string | null>(null)
|
||||||
|
const historyKey = useMemo(() => ['athena-history', accountId, workGroup], [accountId, workGroup])
|
||||||
|
|
||||||
|
const workGroupQuery = useQuery({
|
||||||
|
queryKey: ['athena-workgroup', accountId, workGroup],
|
||||||
|
queryFn: ({signal}) => getCloudResource('aws', 'query', workGroup, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const catalogQuery = useQuery({
|
||||||
|
queryKey: ['athena-catalog', accountId, database],
|
||||||
|
queryFn: ({signal}) => describeQueryCatalog('aws', database || undefined, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: historyKey,
|
||||||
|
queryFn: ({signal}) => listQueries('aws', workGroup, signal),
|
||||||
|
enabled: Boolean(resource) && runtimeReachable,
|
||||||
|
})
|
||||||
|
const resultQuery = useQuery({
|
||||||
|
queryKey: ['athena-result', accountId, workGroup, activeQueryId],
|
||||||
|
queryFn: ({signal}) => getQueryResult('aws', workGroup, activeQueryId ?? '', signal),
|
||||||
|
enabled: Boolean(activeQueryId) && runtimeReachable,
|
||||||
|
refetchInterval: (query) => query.state.data && !FINISHED.has(query.state.data.execution.state) ? RUNNING_POLL_MS : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const catalog = catalogQuery.data
|
||||||
|
const chosenDatabase = database || catalog?.database || ''
|
||||||
|
const firstTable = catalog?.tables[0]?.name
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveQueryId(null)
|
||||||
|
setSql('')
|
||||||
|
}, [workGroup])
|
||||||
|
// Start each workgroup from a query that runs as-is, the way the Athena console opens on a table.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firstTable || seededFor === workGroup) return
|
||||||
|
setSeededFor(workGroup)
|
||||||
|
setSql((current) => current || `SELECT * FROM ${quote(firstTable)} LIMIT 10`)
|
||||||
|
}, [firstTable, seededFor, workGroup])
|
||||||
|
|
||||||
|
const execution = resultQuery.data?.execution
|
||||||
|
const finished = execution ? FINISHED.has(execution.state) : true
|
||||||
|
const executionState = execution?.state
|
||||||
|
useEffect(() => {
|
||||||
|
if (executionState && FINISHED.has(executionState)) void queryClient.invalidateQueries({queryKey: historyKey})
|
||||||
|
}, [executionState, historyKey, queryClient])
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: () => startQuery('aws', workGroup, {sql, ...(chosenDatabase ? {database: chosenDatabase} : {})}),
|
||||||
|
onSuccess: (started) => {
|
||||||
|
setActiveQueryId(started.id)
|
||||||
|
return queryClient.invalidateQueries({queryKey: historyKey})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const cancel = useMutation({
|
||||||
|
mutationFn: () => stopQuery('aws', workGroup, activeQueryId ?? ''),
|
||||||
|
onSuccess: () => resultQuery.refetch(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return <section className="table-panel"><div className="empty compact"><h3>Select a workgroup</h3><p>Choose a workgroup to run SQL against the tables in the Glue Data Catalog.</p></div></section>
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputLocation = (workGroupQuery.data ?? resource).metadata.outputLocation
|
||||||
|
const disabled = !runtimeReachable || run.isPending
|
||||||
|
const result = resultQuery.data
|
||||||
|
const error = run.error ?? cancel.error ?? resultQuery.error ?? catalogQuery.error
|
||||||
|
const history = historyQuery.data ?? []
|
||||||
|
const download = execution?.outputLocation ? s3Location(execution.outputLocation) : null
|
||||||
|
const submit = () => { if (sql.trim() && !disabled) run.mutate() }
|
||||||
|
const openHistory = (entry: QueryExecutionSummary) => {
|
||||||
|
setActiveQueryId(entry.id)
|
||||||
|
setSql(entry.sql)
|
||||||
|
if (entry.database) setDatabase(entry.database)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Workgroup ${resource.name}`}>
|
||||||
|
<div className="input-row resource-table-bar">
|
||||||
|
<div><p className="eyebrow">Athena query editor</p><span className="muted">Workgroup {resource.name}{outputLocation ? <> · results in <code>{String(outputLocation)}</code></> : null}</span></div>
|
||||||
|
<div className="cdn-actions">
|
||||||
|
<button className="button" type="button" disabled={!runtimeReachable || catalogQuery.isFetching} onClick={() => { void catalogQuery.refetch(); void historyQuery.refetch() }}><RefreshCw size={14}/>{catalogQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="query-layout">
|
||||||
|
<aside className="query-catalog" aria-label="Data catalog">
|
||||||
|
<label className="dynamic-field"><span>Database</span>
|
||||||
|
<select className="input" value={chosenDatabase} onChange={(event) => setDatabase(event.target.value)} disabled={!runtimeReachable || !catalog?.databases.length}>
|
||||||
|
{!catalog?.databases.length && <option value="">No databases</option>}
|
||||||
|
{catalog?.databases.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{catalog && catalog.databases.length === 0 && <p className="muted compact-text">Athena queries tables in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>. Create a database and a table there first.</p>}
|
||||||
|
{catalog && catalog.tables.length === 0 && catalog.databases.length > 0 && <p className="muted compact-text">No tables in {chosenDatabase}. Add one in the <Link to="/cloud-explorer/aws/datacatalog">Glue Data Catalog</Link>.</p>}
|
||||||
|
<ul className="query-tables">{catalog?.tables.map((table) => (
|
||||||
|
<li key={table.name}>
|
||||||
|
<details>
|
||||||
|
<summary><span className="mono">{table.name}</span></summary>
|
||||||
|
<ul>{table.columns.map((column) => <li key={column.name}><code>{column.name}</code> <small className="muted">{column.type}</small></li>)}</ul>
|
||||||
|
</details>
|
||||||
|
<button className="button compact" type="button" aria-label={`Preview ${table.name}`} disabled={disabled} onClick={() => setSql(`SELECT * FROM ${quote(table.name)} LIMIT 10`)}>Preview</button>
|
||||||
|
</li>
|
||||||
|
))}</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="query-editor">
|
||||||
|
<form aria-label="Query" onSubmit={(event) => { event.preventDefault(); submit() }}>
|
||||||
|
<label className="dynamic-field"><span>SQL</span>
|
||||||
|
<textarea className="textarea mono query-sql" value={sql} spellCheck={false} rows={7} placeholder="SELECT * FROM orders LIMIT 10" onChange={(event) => setSql(event.target.value)}
|
||||||
|
onKeyDown={(event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit() } }}/>
|
||||||
|
</label>
|
||||||
|
<div className="query-actions">
|
||||||
|
<button className="button primary" type="submit" disabled={disabled || !sql.trim()}><Play size={14}/>{run.isPending ? 'Starting' : 'Run'}</button>
|
||||||
|
{execution && !finished && <button className="button" type="button" disabled={cancel.isPending} onClick={() => cancel.mutate()}><Square size={14}/>Cancel</button>}
|
||||||
|
<small className="muted">Ctrl+Enter runs the query. Unqualified table names resolve in {chosenDatabase || 'the chosen database'}.</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{execution && <div className="query-status" role="status">
|
||||||
|
<code className={`query-state query-state--${execution.state.toLowerCase()}`}>{execution.state}</code>
|
||||||
|
{execution.runtimeMs !== null && finished && <span className="muted">Run time {formatRuntime(execution.runtimeMs)}</span>}
|
||||||
|
{result && execution.state === 'SUCCEEDED' && <span className="muted">{result.rows.length === 1 ? '1 row' : `${result.rows.length} rows`}{result.truncated ? ' shown; the file has more' : ''}</span>}
|
||||||
|
{download && execution.state === 'SUCCEEDED' && <a className="button compact" href={storageObjectDownloadUrl('aws', download.bucket, download.key)} download><Download size={14}/>Download CSV</a>}
|
||||||
|
</div>}
|
||||||
|
{execution?.state === 'FAILED' && execution.stateReason && <pre className="query-error" role="alert">{execution.stateReason}</pre>}
|
||||||
|
|
||||||
|
{result && execution?.state === 'SUCCEEDED' && (result.columns.length === 0
|
||||||
|
? <p className="muted compact-text">The statement ran and returned no rows.</p>
|
||||||
|
: <div className="cognito-table query-results"><table className="table"><thead><tr>{result.columns.map((column, index) => <th key={`${column.name}-${index}`}>{column.name}</th>)}</tr></thead>
|
||||||
|
<tbody>{result.rows.map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex} className="mono">{cell ?? <span className="muted">NULL</span>}</td>)}</tr>)}</tbody></table></div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-row resource-table-bar"><div><p className="eyebrow">Recent queries</p><span className="muted">The latest queries run in this workgroup.</span></div></div>
|
||||||
|
{history.length === 0 ? <p className="muted compact-text query-note">No queries yet.</p>
|
||||||
|
: <div className="cognito-table"><table className="table"><thead><tr><th>Submitted</th><th>State</th><th>Query</th><th>Run time</th></tr></thead><tbody>{history.map((entry) => (
|
||||||
|
<tr key={entry.id} aria-current={entry.id === activeQueryId ? 'true' : undefined}>
|
||||||
|
<td>{formatDateTime(entry.submittedAt) ?? '—'}</td>
|
||||||
|
<td><code className={`query-state query-state--${entry.state.toLowerCase()}`}>{entry.state}</code></td>
|
||||||
|
<td><button className="link-button mono query-history-sql" type="button" onClick={() => openHistory(entry)} title="Open this query">{entry.sql}</button></td>
|
||||||
|
<td>{entry.runtimeMs !== null ? formatRuntime(entry.runtimeMs) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody></table></div>}
|
||||||
|
|
||||||
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quotes a table name only when Athena would need it, as the Athena console does. */
|
||||||
|
function quote(name: string): string {
|
||||||
|
return /^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRuntime(ms: number): string {
|
||||||
|
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function s3Location(uri: string): {bucket: string; key: string} | null {
|
||||||
|
const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(uri)
|
||||||
|
return match ? {bucket: match[1], key: match[2]} : null
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean})
|
|||||||
const [from, setFrom] = useState('')
|
const [from, setFrom] = useState('')
|
||||||
const [to, setTo] = useState(SIMULATORS[0])
|
const [to, setTo] = useState(SIMULATORS[0])
|
||||||
const [subject, setSubject] = useState('Local SES test')
|
const [subject, setSubject] = useState('Local SES test')
|
||||||
const [text, setText] = useState('Sent from the Floci SES console.')
|
const [text, setText] = useState('Sent from TheCloud\'s SES console.')
|
||||||
const [html, setHtml] = useState('')
|
const [html, setHtml] = useState('')
|
||||||
const [raw, setRaw] = useState('From: sender@example.test\r\nTo: success@simulator.amazonses.com\r\nSubject: Raw SES test\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello from a raw MIME message.')
|
const [raw, setRaw] = useState('From: sender@example.test\r\nTo: success@simulator.amazonses.com\r\nSubject: Raw SES test\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello from a raw MIME message.')
|
||||||
const [configurationSet, setConfigurationSet] = useState('')
|
const [configurationSet, setConfigurationSet] = useState('')
|
||||||
const [templateName, setTemplateName] = useState('')
|
const [templateName, setTemplateName] = useState('')
|
||||||
const [templateData, setTemplateData] = useState('{"name":"Floci"}')
|
const [templateData, setTemplateData] = useState('{"name":"TheCloud"}')
|
||||||
const [editingTemplate, setEditingTemplate] = useState('')
|
const [editingTemplate, setEditingTemplate] = useState('')
|
||||||
const [templateSubject, setTemplateSubject] = useState('Hello {{name}}')
|
const [templateSubject, setTemplateSubject] = useState('Hello {{name}}')
|
||||||
const [templateText, setTemplateText] = useState('Hello {{name}}')
|
const [templateText, setTemplateText] = useState('Hello {{name}}')
|
||||||
@@ -61,7 +61,7 @@ export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean})
|
|||||||
const pending = send.isPending || createIdentity.isPending || saveTemplate.isPending || addSuppression.isPending || createConfigurationSet.isPending || addDestination.isPending
|
const pending = send.isPending || createIdentity.isPending || saveTemplate.isPending || addSuppression.isPending || createConfigurationSet.isPending || addDestination.isPending
|
||||||
|
|
||||||
return <div className="ses-console">
|
return <div className="ses-console">
|
||||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">Floci verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
|
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">TheCloud verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox above to inspect the captured message.</p>}</section>
|
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox above to inspect the captured message.</p>}</section>
|
||||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
|
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
|
||||||
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
|
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
Boxes,
|
Boxes,
|
||||||
Circle,
|
Circle,
|
||||||
Clock,
|
Clock,
|
||||||
Container,
|
Container,
|
||||||
Database,
|
Database,
|
||||||
Earth,
|
Earth,
|
||||||
|
FileBadge,
|
||||||
|
FolderTree,
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Hammer,
|
Hammer,
|
||||||
@@ -14,6 +17,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Network,
|
Network,
|
||||||
Radio,
|
Radio,
|
||||||
|
Scaling,
|
||||||
|
SearchCode,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Scale,
|
Scale,
|
||||||
Server,
|
Server,
|
||||||
@@ -35,6 +40,12 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
|
|||||||
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||||
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
||||||
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||||
|
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
||||||
|
import efsIcon from '../../../../aws-icons-svg/Storage/EFS.svg'
|
||||||
|
import glueIcon from '../../../../aws-icons-svg/Analytics/Glue.svg'
|
||||||
|
import athenaIcon from '../../../../aws-icons-svg/Analytics/Athena.svg'
|
||||||
|
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
|
||||||
|
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||||
@@ -85,6 +96,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
iac: Layers,
|
iac: Layers,
|
||||||
messaging: MessageSquare,
|
messaging: MessageSquare,
|
||||||
events: Radio,
|
events: Radio,
|
||||||
|
pipes: Workflow,
|
||||||
email: MessageSquare,
|
email: MessageSquare,
|
||||||
queue: MessageSquare,
|
queue: MessageSquare,
|
||||||
logs: ScrollText,
|
logs: ScrollText,
|
||||||
@@ -93,6 +105,12 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
|||||||
userpools: UsersRound,
|
userpools: UsersRound,
|
||||||
dns: Globe,
|
dns: Globe,
|
||||||
cdn: Earth,
|
cdn: Earth,
|
||||||
|
autoscaling: Scaling,
|
||||||
|
filesystems: FolderTree,
|
||||||
|
datacatalog: Table2,
|
||||||
|
query: SearchCode,
|
||||||
|
certificates: FileBadge,
|
||||||
|
monitoring: Activity,
|
||||||
loadbalancing: Scale,
|
loadbalancing: Scale,
|
||||||
parameters: SlidersHorizontal,
|
parameters: SlidersHorizontal,
|
||||||
configuration: SlidersHorizontal,
|
configuration: SlidersHorizontal,
|
||||||
@@ -118,6 +136,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
ecr: ecrIcon,
|
ecr: ecrIcon,
|
||||||
email: sesIcon,
|
email: sesIcon,
|
||||||
events: eventBridgeIcon,
|
events: eventBridgeIcon,
|
||||||
|
pipes: eventBridgeIcon,
|
||||||
iac: cloudFormationIcon,
|
iac: cloudFormationIcon,
|
||||||
iam: iamIcon,
|
iam: iamIcon,
|
||||||
kms: kmsIcon,
|
kms: kmsIcon,
|
||||||
@@ -139,6 +158,12 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
|||||||
userpools: cognitoIcon,
|
userpools: cognitoIcon,
|
||||||
dns: route53Icon,
|
dns: route53Icon,
|
||||||
cdn: cloudFrontIcon,
|
cdn: cloudFrontIcon,
|
||||||
|
autoscaling: autoScalingIcon,
|
||||||
|
filesystems: efsIcon,
|
||||||
|
datacatalog: glueIcon,
|
||||||
|
query: athenaIcon,
|
||||||
|
certificates: certificateManagerIcon,
|
||||||
|
monitoring: cloudWatchIcon,
|
||||||
workflows: stepFunctionsIcon,
|
workflows: stepFunctionsIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function runtimeClassFor(status: CloudStatus | undefined, loading: boolea
|
|||||||
export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): string {
|
export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): string {
|
||||||
if (status?.runtime === 'reachable') return 'Connected through Cloud Proxy API'
|
if (status?.runtime === 'reachable') return 'Connected through Cloud Proxy API'
|
||||||
if (status?.runtime === 'unavailable') return 'Start the selected runtime to load resources'
|
if (status?.runtime === 'unavailable') return 'Start the selected runtime to load resources'
|
||||||
if (cloud === 'gcp') return 'Waiting for Floci-GCP runtime status'
|
if (cloud === 'gcp') return 'Waiting for the GCP runtime status'
|
||||||
return 'Waiting for runtime status'
|
return 'Waiting for runtime status'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +52,9 @@ export function cloudName(cloud: CloudProvider): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function providerDescription(cloud: CloudProvider): string {
|
export function providerDescription(cloud: CloudProvider): string {
|
||||||
if (cloud === 'aws') return 'Storage, runtime infrastructure, and AWS-specific pages are backed by Floci AWS Core.'
|
if (cloud === 'aws') return 'Storage, runtime infrastructure, and AWS-specific pages are backed by TheCloud\'s AWS runtime.'
|
||||||
if (cloud === 'azure') return 'Storage is backed by Floci-AZ through the same normalized storage contract.'
|
if (cloud === 'azure') return 'Storage is backed by TheCloud\'s Azure runtime through the same normalized storage contract.'
|
||||||
return 'Storage is backed by Floci-GCP through the same normalized storage contract.'
|
return 'Storage is backed by TheCloud\'s GCP runtime through the same normalized storage contract.'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string {
|
export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string {
|
||||||
@@ -63,7 +63,7 @@ export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runtimeName(cloud: CloudProvider): string {
|
export function runtimeName(cloud: CloudProvider): string {
|
||||||
if (cloud === 'aws') return 'Floci AWS Core'
|
if (cloud === 'aws') return 'TheCloud AWS runtime'
|
||||||
if (cloud === 'azure') return 'Floci-AZ'
|
if (cloud === 'azure') return 'TheCloud Azure runtime'
|
||||||
return 'Floci-GCP'
|
return 'TheCloud GCP runtime'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type {ReactNode} from 'react'
|
||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
import {renderHook, waitFor} from '@testing-library/react'
|
||||||
|
import {beforeEach, expect, test, vi} from 'vitest'
|
||||||
|
import {useCloudConsoleHomeData} from './useCloudConsoleHomeData'
|
||||||
|
import type {CloudServiceDescriptor} from '@/types/cloud'
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
listClouds: vi.fn(),
|
||||||
|
listCloudServices: vi.fn(),
|
||||||
|
getCloudStatus: vi.fn(),
|
||||||
|
listCloudResources: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/cloudProxyClient', () => api)
|
||||||
|
|
||||||
|
function descriptor(service: CloudServiceDescriptor['service'], route: string, listable: boolean): CloudServiceDescriptor {
|
||||||
|
return {cloud: 'aws', service, displayName: service, availability: 'available', route, iconKey: service, group: 'Storage', order: 1, listable}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapper({children}: {children: ReactNode}) {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.listClouds.mockResolvedValue([])
|
||||||
|
api.getCloudStatus.mockResolvedValue({cloud: 'aws', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '2026-10-25T10:00:00Z', error: null})
|
||||||
|
api.listCloudServices.mockResolvedValue([
|
||||||
|
descriptor('storage', 'storage', true),
|
||||||
|
descriptor('secrets', '/secretsmanager', true),
|
||||||
|
descriptor('msk', 'msk', false),
|
||||||
|
])
|
||||||
|
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => {
|
||||||
|
if (service === 'msk') throw new Error('501 operation_not_supported')
|
||||||
|
return service === 'storage' ? [{id: 'a'}, {id: 'b'}] : [{id: 'c'}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('counts only services the generic list serves, so a pre-SPI page cannot fail the total', async () => {
|
||||||
|
const {result} = renderHook(() => useCloudConsoleHomeData('aws'), {wrapper})
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.resourceCount).toBe(3))
|
||||||
|
expect(api.listCloudResources.mock.calls.map((call) => call[1]).sort()).toEqual(['secrets', 'storage'])
|
||||||
|
expect(result.current.resourceDetail).toBe('Normalized resources across available services')
|
||||||
|
expect(result.current.activeServices).toBe(3)
|
||||||
|
|
||||||
|
const msk = result.current.serviceCards.find((card) => card.id === 'msk')
|
||||||
|
expect(msk).toMatchObject({route: '/cloud-explorer/aws/msk', meta: 'open service', count: undefined})
|
||||||
|
expect(result.current.serviceCards.find((card) => card.id === 'secrets')).toMatchObject({route: '/secretsmanager', count: 1})
|
||||||
|
})
|
||||||
@@ -32,12 +32,12 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
|||||||
const status = statusQuery.data
|
const status = statusQuery.data
|
||||||
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
|
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
|
||||||
|
|
||||||
// Availability is derived from adapter registration, so an available service
|
// Only a service with a registered adapter has the generic list endpoint. Route
|
||||||
// always has the generic list endpoint. Route shape only decides where the
|
// shape only decides where the card links: a legacy absolute-route page (AWS
|
||||||
// card links, not whether it can be counted -- a legacy absolute-route page
|
// Secrets Manager) is still counted through its adapter, while MSK, available
|
||||||
// (AWS Secrets Manager) is still counted through its adapter.
|
// through its own pre-SPI page, is not listable and is never counted.
|
||||||
const countable = useMemo(
|
const countable = useMemo(
|
||||||
() => services.filter((service) => service.availability === 'available'),
|
() => services.filter((service) => service.availability === 'available' && service.listable),
|
||||||
[services],
|
[services],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
meta:
|
meta:
|
||||||
service.availability === 'available'
|
service.availability === 'available'
|
||||||
? isLegacyPage
|
? isLegacyPage || !service.listable
|
||||||
? 'open service'
|
? 'open service'
|
||||||
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
|
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
|
||||||
: 'coming soon',
|
: 'coming soon',
|
||||||
|
|||||||