Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 811791603c | |||
| 6acd3656a1 | |||
| 6dd33b8f65 | |||
| f4b3e45f3d | |||
| 91ce11274d | |||
| 7669b5387f | |||
| a86bee443b | |||
| 9cb164f211 | |||
| 6586f48539 | |||
| 5c3c73c582 | |||
| 2b4d4b146d | |||
| ac58bfc98b | |||
| a5c9de25d0 | |||
| 238bb1a3d5 | |||
| d411f632c9 |
@@ -1,26 +1,23 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
|
||||
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
|
||||
<img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">TheCloud</h1>
|
||||
|
||||
<p align="center">
|
||||
<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 align="center">
|
||||
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
|
||||
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
|
||||
<img src="docs/images/thecloud-console.png" alt="TheCloud's Cloud Explorer showing S3 buckets and the selected bucket's details" width="900" />
|
||||
</p>
|
||||
|
||||
Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet.
|
||||
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.
|
||||
|
||||
@@ -42,9 +39,9 @@ Open [http://localhost:4500](http://localhost:4500).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -78,6 +75,8 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Networking | Networking | Yes (list) | No | No |
|
||||
| Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No |
|
||||
| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No |
|
||||
| Networking | Route 53 / DNS | Yes (list, create, delete, inspect) | No | No |
|
||||
| Networking | CloudFront / CDN | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
|
||||
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
|
||||
@@ -95,25 +94,22 @@ 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 | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Parameter Store | 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 |
|
||||
|
||||
Console Home is available for all three clouds.
|
||||
|
||||
Runtime gaps — an adapter exists but the local runtime does not implement it:
|
||||
|
||||
- Azure Serverless: the Floci-AZ runtime returns 501 NotImplemented for the Azure Functions endpoint.
|
||||
- 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
|
||||
server-supplied reason. Adding one is a catalog row in
|
||||
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
|
||||
</p>
|
||||
|
||||
Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled
|
||||
`AKS` for this provider, and every unavailable service carries a reason — Serverless
|
||||
reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions,
|
||||
With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
|
||||
`AKS` for that provider, and every unavailable service carries a reason: Serverless
|
||||
reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
|
||||
even though an adapter is registered.
|
||||
|
||||
## Current Capability Snapshot
|
||||
@@ -156,7 +152,7 @@ All three clouds, through the unified shell.
|
||||
Current gaps:
|
||||
|
||||
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
|
||||
the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s
|
||||
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`.
|
||||
|
||||
</details>
|
||||
@@ -166,7 +162,7 @@ Current gaps:
|
||||
|
||||
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.
|
||||
- Azure Cosmos DB NoSQL: database, container, and document workflows.
|
||||
- AWS DynamoDB: table management, item browsing, and Add record.
|
||||
@@ -193,8 +189,8 @@ Frontend regression tests use mocked `/api/*` responses and need no running emul
|
||||
|
||||
Current gaps:
|
||||
|
||||
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
||||
- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime.
|
||||
- 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 TheCloud's runtime yet.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -273,7 +269,7 @@ Current gaps:
|
||||
|
||||
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.
|
||||
- Inspects sender, recipients, timestamp, and message type.
|
||||
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
|
||||
@@ -290,10 +286,10 @@ aws ses send-email \
|
||||
--endpoint-url http://localhost:4566 \
|
||||
--from sender@example.test \
|
||||
--destination 'ToAddresses=recipient@example.test' \
|
||||
--message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
||||
--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
|
||||
Raw views. You can also inspect the captured messages directly with:
|
||||
|
||||
@@ -321,7 +317,7 @@ AWS and GCP, both through the unified shell.
|
||||
|
||||
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.
|
||||
- GCP Cloud Functions invoke is not wired yet; the capability is advertised as
|
||||
`coming_soon` instead of being silently missing.
|
||||
@@ -374,7 +370,7 @@ Current gaps:
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -386,7 +382,7 @@ The guiding rules are:
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
|
||||
|
||||
@@ -428,9 +424,9 @@ Browser
|
||||
|
||||
Default compose stack:
|
||||
|
||||
- `floci-ui` on `http://localhost:4500`
|
||||
- `floci-api` on `http://localhost:4501`
|
||||
- `floci` on `http://localhost:4566`
|
||||
- `floci-ui` (the console) on `http://localhost:4500`
|
||||
- `floci-api` (TheCloud's API) on `http://localhost:4501`
|
||||
- `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
|
||||
|
||||
Start AWS-only:
|
||||
|
||||
@@ -460,7 +456,7 @@ Prerequisites:
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- 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:
|
||||
|
||||
@@ -476,29 +472,18 @@ cp .env.example packages/api/.env
|
||||
|
||||
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
|
||||
|
||||
Start Floci AWS Core with Docker:
|
||||
Start TheCloud's AWS runtime from its repository:
|
||||
|
||||
```bash
|
||||
docker run -d --name floci \
|
||||
-p 4566:4566 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e FLOCI_DEFAULT_REGION=us-east-1 \
|
||||
-u root \
|
||||
floci/floci:latest
|
||||
```
|
||||
|
||||
Or from a local clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/floci-io/floci.git ../floci
|
||||
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../floci
|
||||
cd ../floci
|
||||
./mvnw clean quarkus:dev
|
||||
docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
|
||||
```
|
||||
|
||||
Optional local runtimes:
|
||||
|
||||
- Floci-AZ on `http://localhost:4577`
|
||||
- Floci-GCP on `http://localhost:4588`
|
||||
- Azure runtime (`floci-az`) on `http://localhost:4577`
|
||||
- GCP runtime (`floci-gcp`) on `http://localhost:4588`
|
||||
|
||||
Start the UI stack:
|
||||
|
||||
@@ -607,18 +592,6 @@ When adding new UI surface:
|
||||
- Keep placeholders explicit instead of inventing fake data.
|
||||
- Update this README when the visible UI surface changes.
|
||||
|
||||
## Community Projects
|
||||
|
||||
Floci UI is the first-party console, but it is not the only one. The community builds
|
||||
consoles for Floci too:
|
||||
|
||||
- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard
|
||||
for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image
|
||||
and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also
|
||||
covers Azure and GCP, so pick whichever fits your stack.
|
||||
|
||||
Building something for Floci? Open a PR to add it here.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem.
|
||||
[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 |
@@ -13,6 +13,7 @@
|
||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||
"@aws-sdk/client-cloudwatch": "^3.1076.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
|
||||
"@aws-sdk/client-codebuild": "^3.1076.0",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@aws-sdk/client-lightsail": "^3.1076.0",
|
||||
"@aws-sdk/client-rds": "^3.1076.0",
|
||||
"@aws-sdk/client-rds-data": "^3.1076.0",
|
||||
"@aws-sdk/client-route-53": "^3.1146.0",
|
||||
"@aws-sdk/client-s3": "^3.1076.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1076.0",
|
||||
"@aws-sdk/client-sesv2": "^3.1076.0",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateDistributionCommand,
|
||||
CreateInvalidationCommand,
|
||||
DeleteDistributionCommand,
|
||||
GetDistributionCommand,
|
||||
GetDistributionConfigCommand,
|
||||
ListDistributionsCommand,
|
||||
ListInvalidationsCommand,
|
||||
UpdateDistributionCommand,
|
||||
type CloudFrontClient,
|
||||
type DistributionConfig,
|
||||
} from '@aws-sdk/client-cloudfront'
|
||||
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
||||
import {AwsCdnAdapter} from './AwsCdnAdapter'
|
||||
import {ConflictError, 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}
|
||||
}
|
||||
|
||||
function awsError(name: string, status = 400): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||
}
|
||||
|
||||
const ID = 'E7CPM0P0G0VC8D'
|
||||
const origin = {Id: 's3-site', DomainName: 'site.s3.amazonaws.com', S3OriginConfig: {OriginAccessIdentity: ''}}
|
||||
const config = {
|
||||
CallerReference: 'r', Comment: 'Docs site', Enabled: true, DefaultRootObject: 'index.html',
|
||||
Origins: {Quantity: 1, Items: [origin]},
|
||||
DefaultCacheBehavior: {TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', AllowedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
||||
CacheBehaviors: {Quantity: 1, Items: [{PathPattern: '/api/*', TargetOriginId: 'custom-api', ViewerProtocolPolicy: 'https-only', AllowedMethods: {Quantity: 7, Items: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']}}]},
|
||||
} as DistributionConfig
|
||||
const distribution = {Id: ID, ARN: `arn:aws:cloudfront::000000000000:distribution/${ID}`, DomainName: 'e7cpm0p0g0vc8d.cloudfront.net', Status: 'Deployed', LastModifiedTime: new Date('2026-10-24T18:00:00Z'), DistributionConfig: config}
|
||||
|
||||
function adapter(cloudfront: (command: object) => SendResult | Promise<SendResult>, s3: (command: object) => SendResult | Promise<SendResult> = () => ({})) {
|
||||
const cf = stub(cloudfront)
|
||||
const bucket = stub(s3)
|
||||
return {adapter: new AwsCdnAdapter(cf.client as unknown as CloudFrontClient, bucket.client as unknown as S3Client, 'http://floci:4566'), sent: cf.sent, s3Sent: bucket.sent}
|
||||
}
|
||||
|
||||
describe('AwsCdnAdapter distributions', () => {
|
||||
test('lists distributions with their local URL, origins and description, and filters them', async () => {
|
||||
const {adapter: cdn} = adapter((command) => command instanceof ListDistributionsCommand
|
||||
? {DistributionList: {Items: [{...distribution, Enabled: true, Comment: 'Docs site', Origins: config.Origins}], IsTruncated: false}}
|
||||
: {})
|
||||
|
||||
const [listed] = await cdn.list()
|
||||
expect(listed).toMatchObject({id: ID, name: 'e7cpm0p0g0vc8d.cloudfront.net', status: 'Deployed'})
|
||||
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: 'nothing'})).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('describes behaviours and recent invalidations, and treats a missing distribution as absent', async () => {
|
||||
const {adapter: cdn} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand && command.input.Id === ID) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionCommand) throw awsError('NoSuchDistribution', 404)
|
||||
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: [{Id: 'I1', Status: 'Completed', CreateTime: new Date('2026-10-24T18:05:00Z')}]}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const described = await cdn.get(ID)
|
||||
expect(described?.metadata).toMatchObject({
|
||||
defaultRootObject: 'index.html',
|
||||
behaviors: [
|
||||
{pathPattern: '/api/*', originId: 'custom-api', viewerProtocol: 'https-only', methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']},
|
||||
{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'allow-all', methods: ['GET', 'HEAD']},
|
||||
],
|
||||
invalidations: [{id: 'I1', status: 'Completed', createdAt: '2026-10-24T18:05:00.000Z', paths: []}],
|
||||
})
|
||||
expect(await cdn.get('EMISSING')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a cached website from a bucket that exists', async () => {
|
||||
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
||||
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
||||
: {})
|
||||
|
||||
await cdn.create({values: {originType: 's3', bucket: 'site', defaultRootObject: '/index.html', comment: 'Docs'}})
|
||||
|
||||
expect(s3Sent[0]).toBeInstanceOf(HeadBucketCommand)
|
||||
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
||||
expect(created).toMatchObject({Enabled: true, Comment: 'Docs', DefaultRootObject: 'index.html', Origins: {Quantity: 1, Items: [{Id: 's3-site', DomainName: 'site.s3.amazonaws.com'}]}})
|
||||
expect(created.DefaultCacheBehavior).toMatchObject({TargetOriginId: 's3-site', ViewerProtocolPolicy: 'allow-all', CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', AllowedMethods: {Quantity: 2}})
|
||||
expect(created.DefaultCacheBehavior?.OriginRequestPolicyId).toBeUndefined()
|
||||
})
|
||||
|
||||
test('creates an uncached API in front of a custom origin, forwarding the viewer request', async () => {
|
||||
const {adapter: cdn, sent, s3Sent} = adapter((command) => command instanceof CreateDistributionCommand
|
||||
? {Distribution: {...distribution, DistributionConfig: command.input.DistributionConfig}}
|
||||
: {})
|
||||
|
||||
await cdn.create({values: {originType: 'custom', originDomain: 'API.example.com', originProtocol: 'http-only', originPort: '8080', allowedMethods: 'all', viewerProtocol: 'redirect-to-https'}})
|
||||
|
||||
expect(s3Sent).toHaveLength(0)
|
||||
const created = (sent[0] as CreateDistributionCommand).input.DistributionConfig!
|
||||
expect(created.Origins?.Items?.[0]).toMatchObject({Id: 'custom-api.example.com', DomainName: 'api.example.com', CustomOriginConfig: {HTTPPort: 8080, HTTPSPort: 8080, OriginProtocolPolicy: 'http-only'}})
|
||||
expect(created.DefaultCacheBehavior).toMatchObject({
|
||||
ViewerProtocolPolicy: 'redirect-to-https', AllowedMethods: {Quantity: 7},
|
||||
CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad', OriginRequestPolicyId: 'b689b0a8-53d0-40ab-baf2-68738e2966ac',
|
||||
})
|
||||
})
|
||||
|
||||
test('refuses a missing bucket and invalid settings before creating anything', async () => {
|
||||
const {adapter: cdn, sent} = adapter(() => ({}), () => { throw awsError('NotFound', 404) })
|
||||
|
||||
await expect(cdn.create({values: {originType: 's3', bucket: 'nope'}})).rejects.toThrow(/no S3 bucket named nope/)
|
||||
await expect(cdn.create({values: {originType: 's3', bucket: 'Bad_Name'}})).rejects.toThrow(ValidationError)
|
||||
await expect(cdn.create({values: {originType: 'custom', originDomain: 'not a host'}})).rejects.toThrow(ValidationError)
|
||||
await expect(cdn.create({values: {originType: 'custom', originDomain: 'api.example.com', originPort: '70000'}})).rejects.toThrow(/originPort/)
|
||||
await expect(cdn.create({values: {originType: 'ftp'}})).rejects.toThrow(/originType/)
|
||||
expect(sent.some((command) => command instanceof CreateDistributionCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('disables an enabled distribution, then deletes it with the new ETag', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'Deployed'}, ETag: 'e2'}
|
||||
return {}
|
||||
})
|
||||
|
||||
await cdn.delete(ID)
|
||||
|
||||
const update = sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand
|
||||
expect(update.input).toMatchObject({Id: ID, IfMatch: 'e1', DistributionConfig: {Enabled: false, Comment: 'Docs site'}})
|
||||
expect((sent.find((command) => command instanceof DeleteDistributionCommand) as DeleteDistributionCommand).input).toEqual({Id: ID, IfMatch: 'e2'})
|
||||
})
|
||||
|
||||
test('says to try again while CloudFront is still deploying the disable', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: distribution, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof UpdateDistributionCommand) return {Distribution: {...distribution, Status: 'InProgress'}, ETag: 'e2'}
|
||||
return {}
|
||||
})
|
||||
|
||||
await expect(cdn.delete(ID)).rejects.toThrow(ConflictError)
|
||||
expect(sent.some((command) => command instanceof DeleteDistributionCommand)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsCdnAdapter actions', () => {
|
||||
test('enables or disables a distribution, keeping the rest of its configuration', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => {
|
||||
if (command instanceof GetDistributionConfigCommand) return {DistributionConfig: config, ETag: 'e1'}
|
||||
if (command instanceof GetDistributionCommand) return {Distribution: {...distribution, DistributionConfig: {...config, Enabled: false}}, ETag: 'e2'}
|
||||
if (command instanceof ListInvalidationsCommand) return {InvalidationList: {Items: []}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const disabled = await cdn.setDistributionEnabled(ID, false)
|
||||
expect(disabled.metadata.enabled).toBe(false)
|
||||
expect((sent.find((command) => command instanceof UpdateDistributionCommand) as UpdateDistributionCommand).input.DistributionConfig).toEqual({...config, Enabled: false})
|
||||
|
||||
// The stored configuration is already enabled: nothing to change.
|
||||
const before = sent.length
|
||||
await cdn.setDistributionEnabled(ID, true)
|
||||
expect(sent.slice(before).some((command) => command instanceof UpdateDistributionCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('invalidates paths that start with a slash', async () => {
|
||||
const {adapter: cdn, sent} = adapter((command) => command instanceof CreateInvalidationCommand
|
||||
? {Invalidation: {Id: 'I2', Status: 'Completed', CreateTime: new Date('2026-10-24T18:10:00Z'), InvalidationBatch: command.input.InvalidationBatch}}
|
||||
: {})
|
||||
|
||||
expect(await cdn.createDistributionInvalidation(ID, [' /* ', '/index.html', '/*'])).toEqual({id: 'I2', status: 'Completed', createdAt: '2026-10-24T18:10:00.000Z', paths: ['/*', '/index.html']})
|
||||
await expect(cdn.createDistributionInvalidation(ID, ['index.html'])).rejects.toThrow(/must start with \//)
|
||||
await expect(cdn.createDistributionInvalidation(ID, [])).rejects.toThrow(/at least one path/)
|
||||
expect(sent.filter((command) => command instanceof CreateInvalidationCommand)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
CreateDistributionCommand,
|
||||
CreateInvalidationCommand,
|
||||
DeleteDistributionCommand,
|
||||
GetDistributionCommand,
|
||||
GetDistributionConfigCommand,
|
||||
ListDistributionsCommand,
|
||||
ListInvalidationsCommand,
|
||||
UpdateDistributionCommand,
|
||||
type CacheBehavior,
|
||||
type CloudFrontClient,
|
||||
type DefaultCacheBehavior,
|
||||
type Distribution,
|
||||
type DistributionConfig,
|
||||
type DistributionSummary,
|
||||
type Origin,
|
||||
} from '@aws-sdk/client-cloudfront'
|
||||
import {HeadBucketCommand, type S3Client} from '@aws-sdk/client-s3'
|
||||
import {
|
||||
CDN_ALLOWED_METHODS,
|
||||
CDN_BUCKET_PATTERN,
|
||||
CDN_ORIGIN_DOMAIN_PATTERN,
|
||||
CDN_ORIGIN_PROTOCOLS,
|
||||
CDN_ORIGIN_TYPES,
|
||||
CDN_VIEWER_PROTOCOLS,
|
||||
awsCdnSchema,
|
||||
} from '../cloud-spi/cdnSchema'
|
||||
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, DistributionInvalidation, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** AWS managed cache and origin request policies, which CloudFront and the runtime both know by ID. */
|
||||
const CACHING_OPTIMIZED = '658327ea-f89d-4fab-a63d-7e88639e58f6'
|
||||
const CACHING_DISABLED = '4135ea2d-6df8-44a3-9df3-4b5a84be39ad'
|
||||
const ALL_VIEWER_EXCEPT_HOST_HEADER = 'b689b0a8-53d0-40ab-baf2-68738e2966ac'
|
||||
const READ_METHODS = ['GET', 'HEAD']
|
||||
const ALL_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE']
|
||||
/** CloudFront's limit on paths in one invalidation that are not wildcards. */
|
||||
const MAX_INVALIDATION_PATHS = 3000
|
||||
const RECENT_INVALIDATIONS = 5
|
||||
|
||||
/**
|
||||
* Talks to CloudFront distributions, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - A distribution is `Deployed` as soon as it is created or updated; AWS takes minutes.
|
||||
* - `DeleteDistribution` refuses an enabled distribution with `DistributionNotDisabled`
|
||||
* (409), as on AWS, so delete disables it first.
|
||||
* - A missing distribution is `NoSuchDistribution` with HTTP 404.
|
||||
* - Viewer requests reach a distribution at `{id}.cloudfront.localhost` on the runtime's
|
||||
* 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 {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'cdn' as const
|
||||
|
||||
constructor(
|
||||
private readonly cloudfront: CloudFrontClient,
|
||||
private readonly s3: S3Client,
|
||||
private readonly endpoint = process.env.FLOCI_ENDPOINT ?? 'http://localhost:4566',
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsCdnSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const summaries: DistributionSummary[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const res = await this.cloudfront.send(new ListDistributionsCommand({...(marker ? {Marker: marker} : {})}))
|
||||
summaries.push(...(res.DistributionList?.Items ?? []))
|
||||
marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined
|
||||
} while (marker)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return summaries.map((summary) => this.toResource(summary, summary.Enabled ?? false, summary.Comment, summary.Origins?.Items))
|
||||
.filter((resource) => !search || [resource.id, resource.name, ...stringList(resource.metadata.originDomains)].some((value) => value.toLowerCase().includes(search)))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
let distribution: Distribution | undefined
|
||||
try {
|
||||
distribution = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).Distribution
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NoSuchDistribution') return null
|
||||
throw error
|
||||
}
|
||||
if (!distribution) return null
|
||||
const config = distribution.DistributionConfig
|
||||
const invalidations = await this.recentInvalidations(id)
|
||||
const resource = this.toResource(distribution, config?.Enabled ?? false, config?.Comment, config?.Origins?.Items)
|
||||
resource.metadata = {
|
||||
...resource.metadata,
|
||||
defaultRootObject: config?.DefaultRootObject || null,
|
||||
aliases: config?.Aliases?.Items ?? [],
|
||||
behaviors: behaviorsOf(config),
|
||||
invalidations,
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const originType = oneOf(values.originType ?? 's3', CDN_ORIGIN_TYPES, 'originType')
|
||||
const viewerProtocol = oneOf(values.viewerProtocol || 'allow-all', CDN_VIEWER_PROTOCOLS, 'viewerProtocol')
|
||||
const methods = oneOf(values.allowedMethods || 'read', CDN_ALLOWED_METHODS, 'allowedMethods')
|
||||
const comment = text(values.comment)
|
||||
if (comment.length > 128) throw new ValidationError('comment must be at most 128 characters')
|
||||
const defaultRootObject = text(values.defaultRootObject).replace(/^\/+/, '')
|
||||
|
||||
const origin = originType === 's3' ? await this.s3Origin(text(values.bucket)) : customOrigin(values)
|
||||
const allowed = methods === 'all' ? ALL_METHODS : READ_METHODS
|
||||
const behavior: DefaultCacheBehavior = {
|
||||
TargetOriginId: origin.Id,
|
||||
ViewerProtocolPolicy: viewerProtocol,
|
||||
AllowedMethods: {Quantity: allowed.length, Items: allowed as never, CachedMethods: {Quantity: 2, Items: ['GET', 'HEAD']}},
|
||||
Compress: true,
|
||||
// A website is cached; an API, which sees every method, is not, and gets the viewer's request.
|
||||
CachePolicyId: methods === 'all' ? CACHING_DISABLED : CACHING_OPTIMIZED,
|
||||
...(methods === 'all' && originType === 'custom' ? {OriginRequestPolicyId: ALL_VIEWER_EXCEPT_HOST_HEADER} : {}),
|
||||
}
|
||||
const res = await this.cloudfront.send(new CreateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
CallerReference: crypto.randomUUID(),
|
||||
Comment: comment,
|
||||
Enabled: true,
|
||||
DefaultRootObject: defaultRootObject || undefined,
|
||||
Origins: {Quantity: 1, Items: [origin]},
|
||||
DefaultCacheBehavior: behavior,
|
||||
},
|
||||
}))
|
||||
if (!res.Distribution) throw new ValidationError('CloudFront did not return the new distribution')
|
||||
const config = res.Distribution.DistributionConfig
|
||||
return this.toResource(res.Distribution, config?.Enabled ?? true, config?.Comment, config?.Origins?.Items)
|
||||
}
|
||||
|
||||
/** CloudFront deletes only a disabled distribution, so an enabled one is disabled first. */
|
||||
async delete(id: string): Promise<void> {
|
||||
let etag = (await this.cloudfront.send(new GetDistributionCommand({Id: id}))).ETag
|
||||
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
|
||||
if (current.DistributionConfig?.Enabled) {
|
||||
const disabled = await this.cloudfront.send(new UpdateDistributionCommand({
|
||||
Id: id,
|
||||
IfMatch: current.ETag,
|
||||
DistributionConfig: {...current.DistributionConfig, Enabled: false},
|
||||
}))
|
||||
if (disabled.Distribution?.Status !== 'Deployed') {
|
||||
throw new ConflictError('The distribution is now disabled. CloudFront deletes it once the change is deployed: try again when its status is Deployed')
|
||||
}
|
||||
etag = disabled.ETag
|
||||
}
|
||||
try {
|
||||
await this.cloudfront.send(new DeleteDistributionCommand({Id: id, IfMatch: etag}))
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'DistributionNotDisabled') {
|
||||
throw new ConflictError('CloudFront is still disabling the distribution: try again when its status is Deployed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setDistributionEnabled(id: string, enabled: boolean): Promise<CloudResource> {
|
||||
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
|
||||
const current = await this.cloudfront.send(new GetDistributionConfigCommand({Id: id}))
|
||||
if (!current.DistributionConfig) throw new NotFoundError(`Distribution ${id} was not found`)
|
||||
if (current.DistributionConfig.Enabled !== enabled) {
|
||||
await this.cloudfront.send(new UpdateDistributionCommand({
|
||||
Id: id,
|
||||
IfMatch: current.ETag,
|
||||
DistributionConfig: {...current.DistributionConfig, Enabled: enabled},
|
||||
}))
|
||||
}
|
||||
const resource = await this.get(id)
|
||||
if (!resource) throw new NotFoundError(`Distribution ${id} was not found`)
|
||||
return resource
|
||||
}
|
||||
|
||||
async createDistributionInvalidation(id: string, paths: string[]): Promise<DistributionInvalidation> {
|
||||
if (!Array.isArray(paths)) throw new ValidationError('paths must be a list')
|
||||
const items = [...new Set(paths.map((path) => String(path).trim()).filter(Boolean))]
|
||||
if (items.length === 0) throw new ValidationError('Add at least one path, such as /* or /index.html')
|
||||
if (items.length > MAX_INVALIDATION_PATHS) throw new ValidationError(`An invalidation holds at most ${MAX_INVALIDATION_PATHS} paths`)
|
||||
const invalid = items.find((path) => !path.startsWith('/'))
|
||||
if (invalid) throw new ValidationError(`${invalid} must start with /`)
|
||||
const res = await this.cloudfront.send(new CreateInvalidationCommand({
|
||||
DistributionId: id,
|
||||
InvalidationBatch: {CallerReference: crypto.randomUUID(), Paths: {Quantity: items.length, Items: items}},
|
||||
}))
|
||||
return {
|
||||
id: res.Invalidation?.Id ?? '',
|
||||
status: res.Invalidation?.Status ?? 'InProgress',
|
||||
createdAt: res.Invalidation?.CreateTime?.toISOString() ?? null,
|
||||
paths: res.Invalidation?.InvalidationBatch?.Paths?.Items ?? items,
|
||||
}
|
||||
}
|
||||
|
||||
private async s3Origin(bucket: string): Promise<Origin> {
|
||||
if (!new RegExp(CDN_BUCKET_PATTERN).test(bucket)) throw new ValidationError('bucket must be the name of an existing S3 bucket')
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({Bucket: bucket}))
|
||||
} catch (error) {
|
||||
if (['NotFound', 'NoSuchBucket'].includes(errorName(error))) throw new ValidationError(`There is no S3 bucket named ${bucket}`)
|
||||
throw error
|
||||
}
|
||||
return {Id: `s3-${bucket}`, DomainName: `${bucket}.s3.amazonaws.com`, S3OriginConfig: {OriginAccessIdentity: ''}}
|
||||
}
|
||||
|
||||
private async recentInvalidations(id: string): Promise<DistributionInvalidation[]> {
|
||||
const res = await this.cloudfront.send(new ListInvalidationsCommand({DistributionId: id, MaxItems: RECENT_INVALIDATIONS}))
|
||||
return (res.InvalidationList?.Items ?? []).map((invalidation) => ({
|
||||
id: invalidation.Id ?? '',
|
||||
status: invalidation.Status ?? '',
|
||||
createdAt: invalidation.CreateTime?.toISOString() ?? null,
|
||||
paths: [],
|
||||
}))
|
||||
}
|
||||
|
||||
private toResource(distribution: Pick<Distribution, 'Id' | 'DomainName' | 'Status' | 'LastModifiedTime' | 'ARN'>, enabled: boolean, comment: string | undefined, origins: Origin[] | undefined): CloudResource {
|
||||
const id = distribution.Id ?? ''
|
||||
return {
|
||||
id,
|
||||
name: distribution.DomainName ?? id,
|
||||
cloud: 'aws',
|
||||
service: 'cdn',
|
||||
type: 'distribution',
|
||||
region: null,
|
||||
createdAt: distribution.LastModifiedTime?.toISOString() ?? null,
|
||||
status: distribution.Status ?? null,
|
||||
metadata: {
|
||||
arn: distribution.ARN ?? null,
|
||||
domainName: distribution.DomainName ?? null,
|
||||
viewerUrl: this.viewerUrl(id),
|
||||
enabled,
|
||||
comment: comment || null,
|
||||
originDomains: (origins ?? []).map((origin) => origin.DomainName ?? ''),
|
||||
origins: (origins ?? []).map((origin) => ({
|
||||
id: origin.Id ?? '',
|
||||
domainName: origin.DomainName ?? '',
|
||||
kind: origin.S3OriginConfig ? 's3' : 'custom',
|
||||
protocol: origin.CustomOriginConfig?.OriginProtocolPolicy ?? null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a browser on this machine reaches the distribution through the runtime. */
|
||||
private viewerUrl(id: string): string {
|
||||
const port = new URL(this.endpoint).port || '4566'
|
||||
return `http://${id.toLowerCase()}.cloudfront.localhost:${port}/`
|
||||
}
|
||||
}
|
||||
|
||||
function customOrigin(values: Record<string, unknown>): Origin {
|
||||
const domain = text(values.originDomain).toLowerCase()
|
||||
if (!new RegExp(CDN_ORIGIN_DOMAIN_PATTERN).test(domain)) throw new ValidationError('originDomain must be a host name such as api.example.com')
|
||||
const protocol = oneOf(values.originProtocol || 'https-only', CDN_ORIGIN_PROTOCOLS, 'originProtocol')
|
||||
const port = text(values.originPort)
|
||||
const portNumber = port ? Number(port) : null
|
||||
if (portNumber !== null && (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535)) throw new ValidationError('originPort must be between 1 and 65535')
|
||||
return {
|
||||
Id: `custom-${domain}`,
|
||||
DomainName: domain,
|
||||
CustomOriginConfig: {
|
||||
HTTPPort: portNumber ?? 80,
|
||||
HTTPSPort: portNumber ?? 443,
|
||||
OriginProtocolPolicy: protocol,
|
||||
OriginSslProtocols: {Quantity: 1, Items: ['TLSv1.2']},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function behaviorsOf(config: DistributionConfig | undefined) {
|
||||
const describe = (behavior: DefaultCacheBehavior | CacheBehavior, pathPattern: string) => ({
|
||||
pathPattern,
|
||||
originId: behavior.TargetOriginId ?? '',
|
||||
viewerProtocol: behavior.ViewerProtocolPolicy ?? '',
|
||||
methods: behavior.AllowedMethods?.Items ?? READ_METHODS,
|
||||
})
|
||||
return [
|
||||
...(config?.CacheBehaviors?.Items ?? []).map((behavior) => describe(behavior, behavior.PathPattern ?? '')),
|
||||
...(config?.DefaultCacheBehavior ? [describe(config.DefaultCacheBehavior, 'Default (*)')] : []),
|
||||
]
|
||||
}
|
||||
|
||||
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() : ''
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return (error as {name?: string} | null)?.name ?? ''
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
ChangeResourceRecordSetsCommand,
|
||||
CreateHostedZoneCommand,
|
||||
DeleteHostedZoneCommand,
|
||||
GetHostedZoneCommand,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
type ResourceRecordSet,
|
||||
type Route53Client,
|
||||
} from '@aws-sdk/client-route-53'
|
||||
import {AwsDnsAdapter} from './AwsDnsAdapter'
|
||||
import {ConflictError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubRoute53(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as Route53Client
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
function awsError(name: string, status = 400): Error {
|
||||
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
|
||||
}
|
||||
|
||||
const ZONE_ID = 'ZG9PAE0M9KRWJ4F'
|
||||
const zone = {Id: `/hostedzone/${ZONE_ID}`, Name: 'example.test.', CallerReference: 'r', ResourceRecordSetCount: 3, Config: {Comment: 'Local zone', PrivateZone: false}}
|
||||
const apex = [
|
||||
{Name: 'example.test.', Type: 'NS', TTL: 172800, ResourceRecords: [{Value: 'ns-1.awsdns-01.org.'}]},
|
||||
{Name: 'example.test.', Type: 'SOA', TTL: 900, ResourceRecords: [{Value: 'ns-1.awsdns-01.org awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400'}]},
|
||||
]
|
||||
|
||||
function zoneStub(records: object[] = apex, more: (command: object) => SendResult | undefined = () => undefined) {
|
||||
return stubRoute53((command) => {
|
||||
const extra = more(command)
|
||||
if (extra) return extra
|
||||
if (command instanceof GetHostedZoneCommand) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
|
||||
if (command instanceof ListResourceRecordSetsCommand) return {ResourceRecordSets: records, IsTruncated: false}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
describe('AwsDnsAdapter hosted zones', () => {
|
||||
test('lists zones by bare ID and filters by name', async () => {
|
||||
const {client} = stubRoute53((command) => {
|
||||
if (command instanceof ListHostedZonesCommand) {
|
||||
return {HostedZones: [zone, {...zone, Id: '/hostedzone/ZPRIVATE', Name: 'internal.test.', Config: {PrivateZone: true}}], IsTruncated: false}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsDnsAdapter(client)
|
||||
|
||||
const zones = await adapter.list()
|
||||
expect(zones.map((resource) => [resource.id, resource.name, resource.metadata.visibility])).toEqual([[ZONE_ID, 'example.test', 'public'], ['ZPRIVATE', 'internal.test', 'private']])
|
||||
expect((await adapter.list({search: 'INTERNAL'})).map((resource) => resource.id)).toEqual(['ZPRIVATE'])
|
||||
})
|
||||
|
||||
test('describes a zone with its name servers, and treats a missing zone as absent', async () => {
|
||||
const {client} = stubRoute53((command) => {
|
||||
if (command instanceof GetHostedZoneCommand && command.input.Id === ZONE_ID) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}}
|
||||
throw awsError('NoSuchHostedZone', 404)
|
||||
})
|
||||
const adapter = new AwsDnsAdapter(client)
|
||||
|
||||
expect((await adapter.get(ZONE_ID))?.metadata).toEqual({visibility: 'public', recordCount: 3, comment: 'Local zone', nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']})
|
||||
expect(await adapter.get('ZMISSING')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a public zone, or a private one in a VPC', async () => {
|
||||
const {client, sent} = stubRoute53((command) => command instanceof CreateHostedZoneCommand
|
||||
? {HostedZone: {...zone, Name: `${command.input.Name}.`, Config: command.input.HostedZoneConfig}, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}}
|
||||
: {})
|
||||
const adapter = new AwsDnsAdapter(client)
|
||||
|
||||
expect((await adapter.create({values: {name: ' Example.TEST ', comment: 'Local zone'}})).name).toBe('example.test')
|
||||
await adapter.create({values: {name: 'internal.test', vpcId: 'vpc-0abc1234'}})
|
||||
|
||||
const [publicZone, privateZone] = sent.filter((command) => command instanceof CreateHostedZoneCommand) as CreateHostedZoneCommand[]
|
||||
expect(publicZone.input).toMatchObject({Name: 'example.test', HostedZoneConfig: {Comment: 'Local zone', PrivateZone: false}})
|
||||
expect(publicZone.input.VPC).toBeUndefined()
|
||||
expect(privateZone.input).toMatchObject({HostedZoneConfig: {PrivateZone: true}, VPC: {VPCId: 'vpc-0abc1234', VPCRegion: 'us-east-1'}})
|
||||
await expect(adapter.create({values: {name: 'not a domain'}})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.create({values: {name: 'example.test', vpcId: 'subnet-1'}})).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('explains that a zone with records cannot be deleted', async () => {
|
||||
const {client} = stubRoute53((command) => {
|
||||
if (command instanceof DeleteHostedZoneCommand) throw awsError('HostedZoneNotEmpty')
|
||||
return {}
|
||||
})
|
||||
await expect(new AwsDnsAdapter(client).delete(ZONE_ID)).rejects.toThrow(ConflictError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsDnsAdapter records', () => {
|
||||
test('lists records keyed by name and type, marking the zone\'s own NS and SOA', async () => {
|
||||
const {client} = zoneStub([
|
||||
...apex,
|
||||
{Name: '\\052.example.test.', Type: 'A', TTL: 60, ResourceRecords: [{Value: '10.0.0.9'}]},
|
||||
{Name: 'cdn.example.test.', Type: 'A', AliasTarget: {DNSName: 'd111.cloudfront.net.', HostedZoneId: 'Z2FDTNDATAQYW2', EvaluateTargetHealth: false}},
|
||||
{Name: 'api.example.test.', Type: 'A', SetIdentifier: 'blue', Weight: 10, TTL: 60, ResourceRecords: [{Value: '10.0.0.2'}]},
|
||||
])
|
||||
const {items} = await new AwsDnsAdapter(client).items.listItems(ZONE_ID)
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(['example.test|NS', 'example.test|SOA', '*.example.test|A', 'cdn.example.test|A', 'api.example.test|A|blue'])
|
||||
expect(items[0].body).toMatchObject({managed: true, editable: true})
|
||||
expect(items[2].body).toMatchObject({name: '*.example.test', ttl: 60, values: ['10.0.0.9'], managed: false, editable: true})
|
||||
expect(items[3].body).toMatchObject({alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2FDTNDATAQYW2', evaluateTargetHealth: false}, editable: false})
|
||||
expect(items[4].body).toMatchObject({setIdentifier: 'blue', editable: false})
|
||||
})
|
||||
|
||||
test('pages with an opaque cursor built from Route 53\'s next record', async () => {
|
||||
const {client, sent} = zoneStub(apex, (command) => command instanceof ListResourceRecordSetsCommand && !command.input.StartRecordName
|
||||
? {ResourceRecordSets: apex, IsTruncated: true, NextRecordName: 'www.example.test.', NextRecordType: 'A'}
|
||||
: undefined)
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
const first = await store.listItems(ZONE_ID, {limit: 2})
|
||||
expect(first.nextCursor).toBeTruthy()
|
||||
await store.listItems(ZONE_ID, {cursor: first.nextCursor!})
|
||||
const second = sent.filter((command) => command instanceof ListResourceRecordSetsCommand).at(-1) as ListResourceRecordSetsCommand
|
||||
expect(second.input).toMatchObject({StartRecordName: 'www.example.test.', StartRecordType: 'A'})
|
||||
await expect(store.listItems(ZONE_ID, {cursor: 'not-a-cursor'})).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('upserts a record, naming it inside the zone and quoting TXT text', async () => {
|
||||
const {client, sent} = zoneStub()
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
expect((await store.putItem!(ZONE_ID, {name: 'www', type: 'a', ttl: '120', values: ['10.0.0.1', '10.0.0.2']})).id).toBe('www.example.test|A')
|
||||
await store.putItem!(ZONE_ID, {name: '@', type: 'TXT', values: 'v=spf1 -all\n"already quoted"'})
|
||||
await store.putItem!(ZONE_ID, {name: 'mail.example.test.', type: 'MX', values: ['10 mx.example.test']})
|
||||
|
||||
const changes = sent.filter((command) => command instanceof ChangeResourceRecordSetsCommand)
|
||||
.map((command) => (command as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0])
|
||||
expect(changes[0]).toEqual({Action: 'UPSERT', ResourceRecordSet: {Name: 'www.example.test', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}, {Value: '10.0.0.2'}]}})
|
||||
expect(changes[1].ResourceRecordSet).toMatchObject({Name: 'example.test', Type: 'TXT', TTL: 300, ResourceRecords: [{Value: '"v=spf1 -all"'}, {Value: '"already quoted"'}]})
|
||||
expect(changes[2].ResourceRecordSet).toMatchObject({Name: 'mail.example.test', Type: 'MX'})
|
||||
})
|
||||
|
||||
test('applies Route 53\'s value rules before calling it', async () => {
|
||||
const {client, sent} = zoneStub()
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: ['not-an-ip']})).rejects.toThrow(/IPv4/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'AAAA', values: ['10.0.0.1']})).rejects.toThrow(/IPv6/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'CNAME', values: ['a.test', 'b.test']})).rejects.toThrow(/exactly one/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: '@', type: 'CNAME', values: ['a.test']})).rejects.toThrow(/apex/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'mail', type: 'MX', values: ['mx.example.test']})).rejects.toThrow(/priority/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'BOGUS', values: ['x']})).rejects.toThrow(/type/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: []})).rejects.toThrow(/value/)
|
||||
await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', ttl: -1, values: ['10.0.0.1']})).rejects.toThrow(/ttl/)
|
||||
expect(sent.some((command) => command instanceof ChangeResourceRecordSetsCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('deletes exactly the stored record set, and never the zone\'s own NS or SOA', async () => {
|
||||
const stored: ResourceRecordSet = {Name: 'www.example.test.', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}]}
|
||||
const {client, sent} = zoneStub([stored])
|
||||
const store = new AwsDnsAdapter(client).items
|
||||
|
||||
await store.deleteItem!(ZONE_ID, 'www.example.test|A')
|
||||
const change = (sent.find((command) => command instanceof ChangeResourceRecordSetsCommand) as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0]
|
||||
expect(change).toEqual({Action: 'DELETE', ResourceRecordSet: stored})
|
||||
|
||||
await expect(store.deleteItem!(ZONE_ID, 'example.test|NS')).rejects.toThrow(ConflictError)
|
||||
await expect(store.deleteItem!(ZONE_ID, 'gone.example.test|A')).rejects.toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import {isIPv4, isIPv6} from 'node:net'
|
||||
import {
|
||||
ChangeResourceRecordSetsCommand,
|
||||
CreateHostedZoneCommand,
|
||||
DeleteHostedZoneCommand,
|
||||
GetHostedZoneCommand,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
type HostedZone,
|
||||
type ResourceRecordSet,
|
||||
type Route53Client,
|
||||
type RRType,
|
||||
} from '@aws-sdk/client-route-53'
|
||||
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
|
||||
import {DNS_RECORD_TYPES, DNS_ZONE_NAME_PATTERN, awsDnsSchema} from '../cloud-spi/dnsSchema'
|
||||
import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** Route 53 caps ListResourceRecordSets pages at 300 and ListHostedZones at 100. */
|
||||
const RECORD_PAGE_LIMIT = 300
|
||||
const ZONE_PAGE_LIMIT = 100
|
||||
/** Route 53's TTL range. */
|
||||
const MAX_TTL = 2_147_483_647
|
||||
|
||||
/**
|
||||
* Talks to Route 53 hosted zones, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - Zone IDs come back as `/hostedzone/Z…`; the bare ID is accepted everywhere, so
|
||||
* resources are keyed by it.
|
||||
* - A new zone holds the apex NS and SOA records, and `DeleteHostedZone` refuses
|
||||
* with `HostedZoneNotEmpty` (400) while any other record remains, as on AWS.
|
||||
* - A missing zone is `NoSuchHostedZone` with HTTP 404.
|
||||
* - The runtime stores record values without checking them (an A record of
|
||||
* `not-an-ip`, unquoted TXT text), so the AWS rules are applied here.
|
||||
*
|
||||
* Records are the zone's child items, keyed by name, type and set identifier,
|
||||
* which is what makes a record set unique in Route 53.
|
||||
*/
|
||||
export class AwsDnsAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'dns' as const
|
||||
readonly items: ItemStoreAdapter
|
||||
|
||||
constructor(private readonly route53: Route53Client, private readonly region = 'us-east-1') {
|
||||
this.items = new RecordStore(route53, (id) => this.zoneName(id))
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsDnsSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const zones: HostedZone[] = []
|
||||
let marker: string | undefined
|
||||
do {
|
||||
const res = await this.route53.send(new ListHostedZonesCommand({MaxItems: ZONE_PAGE_LIMIT, ...(marker ? {Marker: marker} : {})}))
|
||||
zones.push(...(res.HostedZones ?? []))
|
||||
marker = res.IsTruncated ? res.NextMarker : undefined
|
||||
} while (marker)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return zones.map((zone) => toResource(zone))
|
||||
.filter((zone) => !search || zone.name.toLowerCase().includes(search) || zone.id.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
try {
|
||||
const res = await this.route53.send(new GetHostedZoneCommand({Id: zoneId(id)}))
|
||||
return res.HostedZone ? toResource(res.HostedZone, res.DelegationSet?.NameServers, res.VPCs?.map((vpc) => `${vpc.VPCId} (${vpc.VPCRegion})`)) : null
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NoSuchHostedZone') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = typeof input.values.name === 'string' ? input.values.name.trim().toLowerCase() : ''
|
||||
if (!new RegExp(DNS_ZONE_NAME_PATTERN).test(name)) throw new ValidationError('name must be a domain name such as example.com')
|
||||
const comment = typeof input.values.comment === 'string' ? input.values.comment.trim() : ''
|
||||
const vpcId = typeof input.values.vpcId === 'string' ? input.values.vpcId.trim() : ''
|
||||
if (vpcId && !/^vpc-[0-9a-f]{8,17}$/.test(vpcId)) throw new ValidationError('vpcId must be a VPC ID such as vpc-0abc1234')
|
||||
|
||||
const res = await this.route53.send(new CreateHostedZoneCommand({
|
||||
Name: name,
|
||||
CallerReference: crypto.randomUUID(),
|
||||
HostedZoneConfig: {Comment: comment || undefined, PrivateZone: Boolean(vpcId)},
|
||||
...(vpcId ? {VPC: {VPCId: vpcId, VPCRegion: this.region as never}} : {}),
|
||||
}))
|
||||
if (!res.HostedZone) throw new ValidationError(`Route 53 did not return the hosted zone ${name}`)
|
||||
return toResource(res.HostedZone, res.DelegationSet?.NameServers, vpcId ? [`${vpcId} (${this.region})`] : undefined)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await this.route53.send(new DeleteHostedZoneCommand({Id: zoneId(id)}))
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'HostedZoneNotEmpty') {
|
||||
throw new ConflictError('Delete the zone\'s records first: Route 53 only deletes a hosted zone that holds just its NS and SOA records')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async zoneName(id: string): Promise<string> {
|
||||
const zone = await this.get(id)
|
||||
if (!zone) throw new NotFoundError(`Hosted zone ${id} was not found`)
|
||||
return zone.name
|
||||
}
|
||||
}
|
||||
|
||||
class RecordStore implements ItemStoreAdapter {
|
||||
constructor(private readonly route53: Route53Client, private readonly zoneName: (id: string) => Promise<string>) {}
|
||||
|
||||
async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
const start = decodeCursor(page.cursor)
|
||||
const res = await this.route53.send(new ListResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
MaxItems: Math.min(page.limit ?? RECORD_PAGE_LIMIT, RECORD_PAGE_LIMIT),
|
||||
...(start ? {StartRecordName: start.name, StartRecordType: start.type as RRType, StartRecordIdentifier: start.identifier} : {}),
|
||||
}))
|
||||
const zone = await this.zoneName(resourceId)
|
||||
return {
|
||||
items: (res.ResourceRecordSets ?? []).map((record) => toRecordItem(record, zone)),
|
||||
nextCursor: res.IsTruncated && res.NextRecordName
|
||||
? encodeCursor({name: res.NextRecordName, type: res.NextRecordType ?? '', identifier: res.NextRecordIdentifier})
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the record set, or replaces the one with the same name and type (UPSERT). */
|
||||
async putItem(resourceId: string, body: Record<string, unknown>): Promise<ChildItem> {
|
||||
const zone = await this.zoneName(resourceId)
|
||||
const record = recordFromInput(body, zone)
|
||||
await this.route53.send(new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
ChangeBatch: {Changes: [{Action: 'UPSERT', ResourceRecordSet: record}]},
|
||||
}))
|
||||
return toRecordItem(record, zone)
|
||||
}
|
||||
|
||||
/** Route 53 deletes a record set only when given it exactly, so the stored set is read first. */
|
||||
async deleteItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const key = parseRecordId(itemId)
|
||||
const zone = await this.zoneName(resourceId)
|
||||
if (key.name === zone && (key.type === 'NS' || key.type === 'SOA')) {
|
||||
throw new ConflictError(`The zone's own ${key.type} record cannot be deleted`)
|
||||
}
|
||||
const res = await this.route53.send(new ListResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
StartRecordName: key.name,
|
||||
StartRecordType: key.type as RRType,
|
||||
...(key.identifier ? {StartRecordIdentifier: key.identifier} : {}),
|
||||
MaxItems: 1,
|
||||
}))
|
||||
const stored = res.ResourceRecordSets?.find((record) => recordId(record) === itemId)
|
||||
if (!stored) throw new NotFoundError(`Record ${key.type} ${key.name} was not found`)
|
||||
await this.route53.send(new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: zoneId(resourceId),
|
||||
ChangeBatch: {Changes: [{Action: 'DELETE', ResourceRecordSet: stored}]},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(zone: HostedZone, nameServers?: string[], vpcs?: string[]): CloudResource {
|
||||
const id = zoneId(zone.Id ?? '')
|
||||
const privateZone = zone.Config?.PrivateZone ?? false
|
||||
return {
|
||||
id,
|
||||
name: withoutDot(zone.Name ?? id),
|
||||
cloud: 'aws',
|
||||
service: 'dns',
|
||||
type: 'hosted-zone',
|
||||
region: null,
|
||||
createdAt: null,
|
||||
status: null,
|
||||
metadata: {
|
||||
visibility: privateZone ? 'private' : 'public',
|
||||
recordCount: zone.ResourceRecordSetCount ?? null,
|
||||
comment: zone.Config?.Comment ?? null,
|
||||
...(nameServers ? {nameServers} : {}),
|
||||
...(vpcs ? {vpcs} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toRecordItem(record: ResourceRecordSet, zone: string): ChildItem {
|
||||
const name = withoutDot(unescapeName(record.Name ?? ''))
|
||||
return {
|
||||
id: recordId(record),
|
||||
collectionId: null,
|
||||
timestamp: null,
|
||||
body: {
|
||||
name,
|
||||
type: record.Type ?? '',
|
||||
ttl: record.TTL ?? null,
|
||||
values: (record.ResourceRecords ?? []).map((value) => value.Value ?? ''),
|
||||
alias: record.AliasTarget ? {dnsName: withoutDot(record.AliasTarget.DNSName ?? ''), hostedZoneId: record.AliasTarget.HostedZoneId ?? '', evaluateTargetHealth: record.AliasTarget.EvaluateTargetHealth ?? false} : null,
|
||||
setIdentifier: record.SetIdentifier ?? null,
|
||||
// The zone's own NS and SOA records, and routing-policy sets this console cannot edit faithfully.
|
||||
managed: (name === zone && (record.Type === 'NS' || record.Type === 'SOA')),
|
||||
editable: !record.AliasTarget && !record.SetIdentifier,
|
||||
},
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates a simple record the way Route 53 does and returns it as a record set. */
|
||||
function recordFromInput(body: Record<string, unknown>, zone: string): ResourceRecordSet {
|
||||
const type = typeof body.type === 'string' ? body.type.trim().toUpperCase() : ''
|
||||
if (!(DNS_RECORD_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${DNS_RECORD_TYPES.join(', ')}`)
|
||||
|
||||
const name = qualifiedName(typeof body.name === 'string' ? body.name : '', zone)
|
||||
const ttl = Number(body.ttl ?? 300)
|
||||
if (!Number.isInteger(ttl) || ttl < 0 || ttl > MAX_TTL) throw new ValidationError('ttl must be a whole number of seconds')
|
||||
|
||||
const rawValues = Array.isArray(body.values) ? body.values : typeof body.values === 'string' ? body.values.split('\n') : []
|
||||
const values = rawValues.map((value) => String(value).trim()).filter(Boolean)
|
||||
if (values.length === 0) throw new ValidationError('Add at least one value')
|
||||
if (type === 'CNAME' && values.length > 1) throw new ValidationError('A CNAME record has exactly one value')
|
||||
if (type === 'CNAME' && name === zone) throw new ValidationError('The zone apex cannot have a CNAME record; use an A or alias record')
|
||||
for (const value of values) {
|
||||
if (type === 'A' && !isIPv4(value)) throw new ValidationError(`${value} is not an IPv4 address`)
|
||||
if (type === 'AAAA' && !isIPv6(value)) throw new ValidationError(`${value} is not an IPv6 address`)
|
||||
if (type === 'MX' && !/^\d{1,5}\s+\S+$/.test(value)) throw new ValidationError(`MX values are a priority and a mail server, such as "10 mail.${zone}"`)
|
||||
}
|
||||
|
||||
return {
|
||||
Name: name,
|
||||
Type: type as RRType,
|
||||
TTL: ttl,
|
||||
ResourceRecords: values.map((value) => ({Value: type === 'TXT' || type === 'SPF' ? quoted(value) : value})),
|
||||
}
|
||||
}
|
||||
|
||||
/** "www", "www.example.com" and "@" all name a record in example.com. */
|
||||
function qualifiedName(input: string, zone: string): string {
|
||||
const name = withoutDot(input.trim().toLowerCase())
|
||||
if (!name || name === '@') return zone
|
||||
const full = name === zone || name.endsWith(`.${zone}`) ? name : `${name}.${zone}`
|
||||
if (!/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(full) || full.length > 253) {
|
||||
throw new ValidationError(`${input} is not a valid record name`)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
/** Route 53 wants each TXT string quoted; text the user already quoted is kept as written. */
|
||||
function quoted(value: string): string {
|
||||
if (value.startsWith('"') && value.endsWith('"') && value.length > 1) return value
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
function recordId(record: Pick<ResourceRecordSet, 'Name' | 'Type' | 'SetIdentifier'>): string {
|
||||
return [withoutDot(unescapeName(record.Name ?? '')), record.Type ?? '', ...(record.SetIdentifier ? [record.SetIdentifier] : [])].join('|')
|
||||
}
|
||||
|
||||
function parseRecordId(id: string): {name: string; type: string; identifier?: string} {
|
||||
const [name, type, ...rest] = id.split('|')
|
||||
if (!name || !type) throw new ValidationError('Record id must be name|type')
|
||||
return {name, type, ...(rest.length ? {identifier: rest.join('|')} : {})}
|
||||
}
|
||||
|
||||
function encodeCursor(cursor: {name: string; type: string; identifier?: string}): string {
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined): {name: string; type: string; identifier?: string} | null {
|
||||
if (!cursor) return null
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as {name?: unknown; type?: unknown; identifier?: unknown}
|
||||
if (typeof value.name !== 'string' || typeof value.type !== 'string') throw new Error('bad cursor')
|
||||
return {name: value.name, type: value.type, ...(typeof value.identifier === 'string' ? {identifier: value.identifier} : {})}
|
||||
} catch {
|
||||
throw new ValidationError('cursor is not valid for this hosted zone')
|
||||
}
|
||||
}
|
||||
|
||||
/** Route 53 returns `*` as `\052` and other special characters as octal escapes. */
|
||||
function unescapeName(name: string): string {
|
||||
return name.replace(/\\(\d{3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8)))
|
||||
}
|
||||
|
||||
function zoneId(id: string): string {
|
||||
return id.replace(/^\/hostedzone\//, '')
|
||||
}
|
||||
|
||||
function withoutDot(name: string): string {
|
||||
return name.endsWith('.') ? name.slice(0, -1) : name
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return (error as {name?: string} | null)?.name ?? ''
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export class AwsLogsAdapter implements CloudServiceAdapter {
|
||||
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 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) : ''
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -76,10 +76,10 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
try {
|
||||
body = await response.json() as FlociSesInbox
|
||||
} 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)) {
|
||||
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
|
||||
}
|
||||
@@ -89,14 +89,14 @@ export class AwsSesAdapter implements CloudServiceAdapter {
|
||||
try {
|
||||
response = await this.fetcher(`${this.endpoint.replace(/\/$/, '')}${path}`, init)
|
||||
} 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
|
||||
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw httpStatusToCloudError(
|
||||
response.status,
|
||||
detail || `Floci SES inspection returned HTTP ${response.status}`,
|
||||
detail || `TheCloud SES inspection returned HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
UpdateUserPoolClientCommand,
|
||||
type CognitoIdentityProviderClient,
|
||||
} from '@aws-sdk/client-cognito-identity-provider'
|
||||
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
|
||||
@@ -125,9 +126,31 @@ describe('AwsUserPoolsAdapter', () => {
|
||||
mfaConfiguration: 'OFF',
|
||||
deletionProtection: 'INACTIVE',
|
||||
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
|
||||
lambdaTriggers: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('lists the pool\'s Lambda triggers in sign-in order, with the trigger version when set', async () => {
|
||||
const arn = (name: string) => `arn:aws:lambda:us-east-1:000000000000:function:${name}`
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, LambdaConfig: {
|
||||
PreTokenGeneration: arn('claims'),
|
||||
PreTokenGenerationConfig: {LambdaArn: arn('claims'), LambdaVersion: 'V2_0'},
|
||||
PreSignUp: `${arn('signup')}:live`,
|
||||
CustomEmailSender: {LambdaArn: arn('mailer'), LambdaVersion: 'V1_0'},
|
||||
KMSKeyID: 'arn:aws:kms:us-east-1:000000000000:key/1',
|
||||
}}}
|
||||
return {}
|
||||
})
|
||||
|
||||
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
|
||||
expect(resource?.metadata.lambdaTriggers).toEqual([
|
||||
{trigger: 'PreSignUp', label: 'Pre sign-up', functionArn: `${arn('signup')}:live`, functionName: 'signup', version: null},
|
||||
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: arn('claims'), functionName: 'claims', version: 'V2_0'},
|
||||
{trigger: 'CustomEmailSender', label: 'Custom email sender', functionArn: arn('mailer'), functionName: 'mailer', version: 'V1_0'},
|
||||
])
|
||||
})
|
||||
|
||||
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
|
||||
const {client} = stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
|
||||
@@ -387,3 +410,63 @@ describe('AwsUserPoolsAdapter user actions and groups', () => {
|
||||
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('app client OAuth settings', () => {
|
||||
const CLIENT_ID = '40808d60e188483d876bbbf646'
|
||||
const stored = {
|
||||
UserPoolId: POOL_ID, ClientId: CLIENT_ID, ClientName: 'notes-demo-web',
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
|
||||
SupportedIdentityProviders: ['COGNITO'],
|
||||
}
|
||||
|
||||
function clientStub() {
|
||||
return stubCognito((command) => {
|
||||
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: stored}
|
||||
if (command instanceof UpdateUserPoolClientCommand) return {UserPoolClient: {...stored, ...command.input}}
|
||||
return {}
|
||||
})
|
||||
}
|
||||
|
||||
test('reads a client with no OAuth set up', async () => {
|
||||
const settings = await new AwsUserPoolsAdapter(clientStub().client).describeUserPoolClient(POOL_ID, CLIENT_ID)
|
||||
expect(settings).toEqual({
|
||||
clientId: CLIENT_ID, clientName: 'notes-demo-web', hasSecret: false,
|
||||
explicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO'],
|
||||
})
|
||||
})
|
||||
|
||||
test('turns on the code flow and sends the client\'s other settings back unchanged', async () => {
|
||||
const {client, sent} = clientStub()
|
||||
const settings = await new AwsUserPoolsAdapter(client).updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {
|
||||
callbackUrls: ['http://localhost:4600/api/auth/callback', ' http://localhost:4600/api/auth/callback '],
|
||||
logoutUrls: ['http://localhost:4600/'],
|
||||
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
|
||||
})
|
||||
|
||||
const update = sent.find((command) => command instanceof UpdateUserPoolClientCommand) as UpdateUserPoolClientCommand
|
||||
expect(update.input).toMatchObject({
|
||||
ClientName: 'notes-demo-web', ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
|
||||
CallbackURLs: ['http://localhost:4600/api/auth/callback'], LogoutURLs: ['http://localhost:4600/'],
|
||||
AllowedOAuthFlows: ['code'], AllowedOAuthScopes: ['openid', 'email'], AllowedOAuthFlowsUserPoolClient: true,
|
||||
SupportedIdentityProviders: ['COGNITO'],
|
||||
})
|
||||
expect(settings.oauthFlows).toEqual(['code'])
|
||||
})
|
||||
|
||||
test('applies Cognito\'s rules before calling it', async () => {
|
||||
const {client, sent} = clientStub()
|
||||
const adapter = new AwsUserPoolsAdapter(client)
|
||||
const valid = {callbackUrls: ['https://app.example/callback'], logoutUrls: [], oauthFlows: ['code' as const], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
|
||||
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['http://app.example/callback']})).rejects.toThrow(/https/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['https://app.example/#done']})).rejects.toThrow(/fragment/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: []})).rejects.toThrow(/callback URL/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthScopes: []})).rejects.toThrow(/scope/)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['code', 'client_credentials']})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['client_credentials']})).rejects.toThrow(/secret/)
|
||||
expect(sent.some((command) => command instanceof UpdateUserPoolClientCommand)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
ListUserPoolClientsCommand,
|
||||
ListUserPoolsCommand,
|
||||
ListUsersCommand,
|
||||
UpdateUserPoolClientCommand,
|
||||
type AttributeType,
|
||||
type CognitoIdentityProviderClient,
|
||||
type GroupType,
|
||||
type PasswordPolicyType,
|
||||
type UserPoolClientDescription,
|
||||
type UserPoolClientType,
|
||||
type UserPoolDescriptionType,
|
||||
type UserPoolType,
|
||||
type UserType,
|
||||
@@ -38,6 +40,7 @@ import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
USER_POOL_GROUP_NAME_PATTERN,
|
||||
USER_POOL_NAME_PATTERN,
|
||||
USER_POOL_OAUTH_FLOWS,
|
||||
USER_POOL_PASSWORD_POLICIES,
|
||||
USER_POOL_SIGN_IN,
|
||||
awsUserPoolsSchema,
|
||||
@@ -49,7 +52,10 @@ import type {
|
||||
CreateUserPoolGroupInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolClientSettings,
|
||||
UserPoolGroup,
|
||||
UserPoolOAuthFlow,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolUserAction,
|
||||
@@ -285,6 +291,54 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async describeUserPoolClient(poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||
return toClientSettings(await this.fetchClient(poolId, clientId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the settings the hosted login and OAuth endpoints use. UpdateUserPoolClient
|
||||
* resets every setting it is not given, so the client's other settings are sent back as they are.
|
||||
*/
|
||||
async updateUserPoolClientOAuth(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
|
||||
const settings = validateOAuthSettings(input)
|
||||
const current = await this.fetchClient(poolId, clientId)
|
||||
if (settings.oauthFlows.includes('client_credentials') && !current.ClientSecret) {
|
||||
throw new ValidationError('The client_credentials flow needs an app client with a secret')
|
||||
}
|
||||
const res = await this.cognito.send(new UpdateUserPoolClientCommand({
|
||||
UserPoolId: poolId,
|
||||
ClientId: clientId,
|
||||
ClientName: current.ClientName,
|
||||
RefreshTokenValidity: current.RefreshTokenValidity,
|
||||
AccessTokenValidity: current.AccessTokenValidity,
|
||||
IdTokenValidity: current.IdTokenValidity,
|
||||
TokenValidityUnits: current.TokenValidityUnits,
|
||||
ReadAttributes: current.ReadAttributes,
|
||||
WriteAttributes: current.WriteAttributes,
|
||||
ExplicitAuthFlows: current.ExplicitAuthFlows,
|
||||
DefaultRedirectURI: current.DefaultRedirectURI && settings.callbackUrls.includes(current.DefaultRedirectURI) ? current.DefaultRedirectURI : undefined,
|
||||
AnalyticsConfiguration: current.AnalyticsConfiguration,
|
||||
PreventUserExistenceErrors: current.PreventUserExistenceErrors,
|
||||
EnableTokenRevocation: current.EnableTokenRevocation,
|
||||
EnablePropagateAdditionalUserContextData: current.EnablePropagateAdditionalUserContextData,
|
||||
AuthSessionValidity: current.AuthSessionValidity,
|
||||
RefreshTokenRotation: current.RefreshTokenRotation,
|
||||
CallbackURLs: settings.callbackUrls,
|
||||
LogoutURLs: settings.logoutUrls,
|
||||
AllowedOAuthFlows: settings.oauthFlows,
|
||||
AllowedOAuthScopes: settings.oauthScopes,
|
||||
AllowedOAuthFlowsUserPoolClient: settings.oauthFlows.length > 0,
|
||||
SupportedIdentityProviders: settings.identityProviders,
|
||||
}))
|
||||
return toClientSettings(res.UserPoolClient ?? current)
|
||||
}
|
||||
|
||||
private async fetchClient(poolId: string, clientId: string): Promise<UserPoolClientType> {
|
||||
const res = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: requiredString(clientId, 'clientId')}))
|
||||
if (!res.UserPoolClient) throw new RuntimeError(`Cognito returned no app client ${clientId}`)
|
||||
return res.UserPoolClient
|
||||
}
|
||||
|
||||
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
|
||||
return listGroupNamesForUser(this.cognito, poolId, username)
|
||||
}
|
||||
@@ -460,10 +514,103 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], us
|
||||
mfaConfiguration: pool.MfaConfiguration ?? null,
|
||||
deletionProtection: pool.DeletionProtection ?? null,
|
||||
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
|
||||
lambdaTriggers: lambdaTriggersOf(pool.LambdaConfig),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Labels as the AWS console names them, in the order a sign-up and sign-in reach them.
|
||||
const LAMBDA_TRIGGERS = [
|
||||
['PreSignUp', 'Pre sign-up'],
|
||||
['UserMigration', 'User migration'],
|
||||
['CustomMessage', 'Custom message'],
|
||||
['PostConfirmation', 'Post confirmation'],
|
||||
['PreAuthentication', 'Pre authentication'],
|
||||
['DefineAuthChallenge', 'Define auth challenge'],
|
||||
['CreateAuthChallenge', 'Create auth challenge'],
|
||||
['VerifyAuthChallengeResponse', 'Verify auth challenge response'],
|
||||
['PostAuthentication', 'Post authentication'],
|
||||
['PreTokenGeneration', 'Pre token generation'],
|
||||
['CustomEmailSender', 'Custom email sender'],
|
||||
['CustomSMSSender', 'Custom SMS sender'],
|
||||
] as const
|
||||
|
||||
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
|
||||
|
||||
function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] {
|
||||
if (!config) return []
|
||||
return LAMBDA_TRIGGERS.flatMap(([trigger, label]) => {
|
||||
// PreTokenGenerationConfig carries the trigger version and supersedes the older PreTokenGeneration field.
|
||||
const versioned = trigger === 'PreTokenGeneration' ? config.PreTokenGenerationConfig
|
||||
: trigger === 'CustomEmailSender' ? config.CustomEmailSender
|
||||
: trigger === 'CustomSMSSender' ? config.CustomSMSSender
|
||||
: undefined
|
||||
const functionArn = versioned?.LambdaArn ?? config[trigger]
|
||||
if (typeof functionArn !== 'string' || !functionArn) return []
|
||||
return [{trigger, label, functionArn, functionName: functionArn.split(':')[6] ?? functionArn, version: versioned?.LambdaVersion ?? null}]
|
||||
})
|
||||
}
|
||||
|
||||
function toClientSettings(client: UserPoolClientType): UserPoolClientSettings {
|
||||
return {
|
||||
clientId: client.ClientId ?? '',
|
||||
clientName: client.ClientName ?? '',
|
||||
hasSecret: Boolean(client.ClientSecret),
|
||||
explicitAuthFlows: client.ExplicitAuthFlows ?? [],
|
||||
callbackUrls: client.CallbackURLs ?? [],
|
||||
logoutUrls: client.LogoutURLs ?? [],
|
||||
oauthFlows: client.AllowedOAuthFlowsUserPoolClient ? (client.AllowedOAuthFlows ?? []).filter(isOAuthFlow) : [],
|
||||
oauthScopes: client.AllowedOAuthScopes ?? [],
|
||||
identityProviders: client.SupportedIdentityProviders ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function isOAuthFlow(value: string): value is UserPoolOAuthFlow {
|
||||
return (USER_POOL_OAUTH_FLOWS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Cognito's own rules for these settings, checked here so the form can say what is wrong. */
|
||||
function validateOAuthSettings(input: UpdateUserPoolClientOAuthInput): UpdateUserPoolClientOAuthInput {
|
||||
const callbackUrls = urlList(input?.callbackUrls, 'Callback URL')
|
||||
const logoutUrls = urlList(input.logoutUrls, 'Sign-out URL')
|
||||
const oauthFlows = stringList(input.oauthFlows, 'oauthFlows')
|
||||
const unknown = oauthFlows.find((flow) => !isOAuthFlow(flow))
|
||||
if (unknown) throw new ValidationError(`Unknown OAuth flow ${unknown}`)
|
||||
if (oauthFlows.includes('client_credentials') && oauthFlows.length > 1) {
|
||||
throw new ValidationError('client_credentials cannot be combined with the code or implicit flows')
|
||||
}
|
||||
if ((oauthFlows.includes('code') || oauthFlows.includes('implicit')) && callbackUrls.length === 0) {
|
||||
throw new ValidationError('Add at least one callback URL for the code or implicit flow')
|
||||
}
|
||||
const oauthScopes = stringList(input.oauthScopes, 'oauthScopes')
|
||||
if (oauthFlows.length > 0 && oauthScopes.length === 0) throw new ValidationError('Choose at least one OAuth scope')
|
||||
const identityProviders = stringList(input.identityProviders, 'identityProviders')
|
||||
return {callbackUrls, logoutUrls, oauthFlows: oauthFlows as UserPoolOAuthFlow[], oauthScopes, identityProviders}
|
||||
}
|
||||
|
||||
function stringList(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new ValidationError(`${field} must be a list of strings`)
|
||||
return [...new Set(value.map((entry: string) => entry.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
// Cognito accepts https URLs, and http only for localhost; fragments are never allowed.
|
||||
function urlList(value: unknown, label: string): string[] {
|
||||
return stringList(value, label).map((entry) => {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(entry)
|
||||
} catch {
|
||||
throw new ValidationError(`${label} ${entry} is not a valid URL`)
|
||||
}
|
||||
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
|
||||
throw new ValidationError(`${label} ${entry} must use https (http is allowed only for localhost)`)
|
||||
}
|
||||
if (url.hash || entry.includes('#')) throw new ValidationError(`${label} ${entry} must not contain a fragment`)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
function toUserItem(
|
||||
username: string | undefined,
|
||||
attributes: AttributeType[] | undefined,
|
||||
|
||||
@@ -106,7 +106,7 @@ export class AzureAksAdapter implements CloudServiceAdapter {
|
||||
|
||||
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
||||
const id = body?.value?.[0]?.subscriptionId
|
||||
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope cluster requests to')
|
||||
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope cluster requests to')
|
||||
|
||||
this.subscriptionId = id
|
||||
return id
|
||||
|
||||
@@ -182,7 +182,7 @@ export class AzureComputeAdapter implements CloudServiceAdapter {
|
||||
|
||||
const body = await this.json<ArmList<{subscriptionId?: string}>>('/subscriptions')
|
||||
const id = body?.value?.[0]?.subscriptionId
|
||||
if (!id) throw new RuntimeError('Floci-AZ returned no subscription to scope compute requests to')
|
||||
if (!id) throw new RuntimeError('The Azure runtime returned no subscription to scope compute requests to')
|
||||
|
||||
this.subscriptionId = id
|
||||
return id
|
||||
|
||||
@@ -49,7 +49,7 @@ export class AzureServerlessAdapter implements CloudServiceAdapter {
|
||||
descriptorOverride(): CloudServiceDescriptorOverride {
|
||||
return {
|
||||
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 {
|
||||
readonly status: CloudErrorStatus = 502
|
||||
readonly code = 'cosmos_nosql_unavailable' as const
|
||||
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Floci-AZ runtime'
|
||||
protected readonly label = 'Cosmos NoSQL endpoint is not available on the selected Azure runtime'
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ function connectionConfig(connection: SqlDataConnection): MssqlConfig {
|
||||
options: {
|
||||
encrypt: connection.tlsMode !== 'disable',
|
||||
trustServerCertificate: connection.tlsMode !== 'verify',
|
||||
appName: 'Floci UI',
|
||||
appName: 'TheCloud',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class PostgresDataClient implements SqlDataClient {
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 30_000,
|
||||
statement_timeout: 30_000,
|
||||
application_name: 'Floci UI',
|
||||
application_name: 'TheCloud',
|
||||
})
|
||||
const startedAt = performance.now()
|
||||
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="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'))
|
||||
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 {
|
||||
|
||||
@@ -31,6 +31,8 @@ import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||
import { Route53Client } from "@aws-sdk/client-route-53";
|
||||
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -93,6 +95,8 @@ export type AwsClients = {
|
||||
elasticache: ElastiCacheClient;
|
||||
lightsail: LightsailClient;
|
||||
cognito: CognitoIdentityProviderClient;
|
||||
route53: Route53Client;
|
||||
cloudfront: CloudFrontClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -139,6 +143,8 @@ function buildClients(accountId: string): AwsClients {
|
||||
elasticache: new ElastiCacheClient(base),
|
||||
lightsail: new LightsailClient(base),
|
||||
cognito: new CognitoIdentityProviderClient(base),
|
||||
route53: new Route53Client(base),
|
||||
cloudfront: new CloudFrontClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('AzureRestRuntimeClient', () => {
|
||||
const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1')
|
||||
|
||||
await expect(client.fetch('/container?restype=container', {method: 'PUT'})).rejects.toThrow(
|
||||
'Cannot reach Floci-AZ at http://localhost:4577: connection refused',
|
||||
"Cannot reach TheCloud's Azure runtime at http://localhost:4577: connection refused",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient {
|
||||
})
|
||||
} catch (error) {
|
||||
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},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
export const CDN_ORIGIN_TYPES = ['s3', 'custom'] as const
|
||||
export const CDN_VIEWER_PROTOCOLS = ['redirect-to-https', 'allow-all', 'https-only'] as const
|
||||
export const CDN_ORIGIN_PROTOCOLS = ['https-only', 'http-only', 'match-viewer'] as const
|
||||
export const CDN_ALLOWED_METHODS = ['read', 'all'] as const
|
||||
/** An S3 bucket name as S3 allows it for new buckets. */
|
||||
export const CDN_BUCKET_PATTERN = '^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$'
|
||||
/** A host name, optionally with a port, as a custom origin's DomainName. */
|
||||
export const CDN_ORIGIN_DOMAIN_PATTERN = '^[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])?)*$'
|
||||
|
||||
const distributionColumns: TableColumnSchema[] = [
|
||||
{name: 'id', label: 'Distribution ID', format: 'code'},
|
||||
{name: 'viewerUrl', label: 'Local URL', path: 'metadata.viewerUrl', format: 'code'},
|
||||
{name: 'status', label: 'Status', format: 'badge'},
|
||||
{name: 'enabled', label: 'Enabled', path: 'metadata.enabled', format: 'boolean'},
|
||||
{name: 'origins', label: 'Origins', path: 'metadata.originDomains', format: 'list'},
|
||||
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
|
||||
]
|
||||
|
||||
const distributionFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsCdnSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'cdn',
|
||||
displayName: 'Amazon CloudFront distributions',
|
||||
fields: [
|
||||
{
|
||||
name: 'originType',
|
||||
label: 'Origin',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 's3',
|
||||
options: [{label: 'S3 bucket', value: 's3'}, {label: 'Custom origin (a web server or API)', value: 'custom'}],
|
||||
},
|
||||
{
|
||||
name: 'bucket',
|
||||
label: 'S3 bucket',
|
||||
type: 'text',
|
||||
required: false,
|
||||
requiredWhen: {field: 'originType', equals: 's3'},
|
||||
visibleWhen: {field: 'originType', values: ['s3']},
|
||||
validation: {pattern: CDN_BUCKET_PATTERN, message: 'Use the name of an existing bucket'},
|
||||
},
|
||||
{
|
||||
name: 'originDomain',
|
||||
label: 'Origin domain',
|
||||
type: 'text',
|
||||
required: false,
|
||||
requiredWhen: {field: 'originType', equals: 'custom'},
|
||||
visibleWhen: {field: 'originType', values: ['custom']},
|
||||
description: 'For example api.example.com. A local server needs FLOCI_SERVICES_CLOUDFRONT_ALLOWED_PRIVATE_ORIGIN_HOSTS on the runtime.',
|
||||
validation: {pattern: CDN_ORIGIN_DOMAIN_PATTERN, message: 'Use a host name such as api.example.com'},
|
||||
},
|
||||
{
|
||||
name: 'originProtocol',
|
||||
label: 'Origin protocol',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'https-only',
|
||||
visibleWhen: {field: 'originType', values: ['custom']},
|
||||
options: [
|
||||
{label: 'HTTPS only', value: 'https-only'},
|
||||
{label: 'HTTP only', value: 'http-only'},
|
||||
{label: 'Match the viewer', value: 'match-viewer'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'originPort',
|
||||
label: 'Origin port',
|
||||
type: 'text',
|
||||
required: false,
|
||||
visibleWhen: {field: 'originType', values: ['custom']},
|
||||
description: 'Leave empty for 443 (HTTPS) and 80 (HTTP).',
|
||||
validation: {pattern: '^\\d{1,5}$', message: 'Use a port number'},
|
||||
},
|
||||
{
|
||||
name: 'defaultRootObject',
|
||||
label: 'Default root object',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: 'index.html',
|
||||
description: 'What the distribution serves for a request to /.',
|
||||
},
|
||||
{
|
||||
name: 'viewerProtocol',
|
||||
label: 'Viewer protocol',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'allow-all',
|
||||
options: [
|
||||
{label: 'HTTP and HTTPS', value: 'allow-all'},
|
||||
{label: 'Redirect HTTP to HTTPS', value: 'redirect-to-https'},
|
||||
{label: 'HTTPS only', value: 'https-only'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'allowedMethods',
|
||||
label: 'Allowed methods',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'read',
|
||||
options: [
|
||||
{label: 'GET and HEAD (a website or files)', value: 'read'},
|
||||
{label: 'All methods (an API)', value: 'all'},
|
||||
],
|
||||
},
|
||||
{name: 'comment', label: 'Description', type: 'text', required: false},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List distributions', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Disable and delete distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
distributionActions: [
|
||||
{name: 'setEnabled', label: 'Enable or disable a distribution', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createInvalidation', label: 'Invalidate cached paths', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: distributionFilters,
|
||||
columns: distributionColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function cdnSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsCdnSchema() : null
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function awsCodeBuildSchema(): ServiceSchema {
|
||||
{
|
||||
name: 'buildspec', label: 'Buildspec', type: 'textarea', required: false, group: 'Source', span: true,
|
||||
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',
|
||||
|
||||
@@ -131,7 +131,7 @@ export function awsDatabaseSchema(): ServiceSchema {
|
||||
label: 'Create DB snapshot',
|
||||
enabled: true,
|
||||
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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** A domain name of dot-separated labels, with or without the trailing dot. */
|
||||
export const DNS_ZONE_NAME_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 record types Route 53 accepts in ChangeResourceRecordSets. */
|
||||
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CAA', 'CNAME', 'DS', 'HTTPS', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT'] as const
|
||||
|
||||
const zoneColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Domain'},
|
||||
{name: 'visibility', label: 'Type', path: 'metadata.visibility', format: 'badge'},
|
||||
{name: 'recordCount', label: 'Records', path: 'metadata.recordCount'},
|
||||
{name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'},
|
||||
{name: 'id', label: 'Hosted zone ID', format: 'code'},
|
||||
]
|
||||
|
||||
const zoneFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsDnsSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'dns',
|
||||
displayName: 'Amazon Route 53 hosted zones',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Domain name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
description: 'For example example.com. TheCloud does not resolve DNS: records are stored and returned by the Route 53 API.',
|
||||
validation: {pattern: DNS_ZONE_NAME_PATTERN, message: 'Use a domain name such as example.com'},
|
||||
},
|
||||
{name: 'comment', label: 'Description', type: 'text', required: false},
|
||||
{
|
||||
name: 'vpcId',
|
||||
label: 'VPC ID (private zone)',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Leave empty for a public zone. With a VPC ID the zone is private and answers only inside that VPC.',
|
||||
validation: {pattern: '^vpc-[0-9a-f]{8,17}$', message: 'Use a VPC ID such as vpc-0abc1234'},
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List hosted zones', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect hosted zone', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
itemActions: [
|
||||
{name: 'list', label: 'List records', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'put', label: 'Create or update a record', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete a record', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: zoneFilters,
|
||||
columns: zoneColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function dnsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsDnsSchema() : 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
|
||||
}
|
||||
@@ -239,6 +239,20 @@ export const SERVICE_CATALOG = {
|
||||
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
|
||||
routeByCloud: {aws: '/secretsmanager'},
|
||||
},
|
||||
cdn: {
|
||||
displayName: 'CDN',
|
||||
displayNameByCloud: {aws: 'CloudFront'},
|
||||
iconKey: 'cdn',
|
||||
group: 'Networking',
|
||||
order: 50,
|
||||
},
|
||||
dns: {
|
||||
displayName: 'DNS',
|
||||
displayNameByCloud: {aws: 'Route 53'},
|
||||
iconKey: 'dns',
|
||||
group: 'Networking',
|
||||
order: 40,
|
||||
},
|
||||
userpools: {
|
||||
displayName: 'User Directories',
|
||||
displayNameByCloud: {aws: 'Cognito'},
|
||||
@@ -267,6 +281,13 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Integration',
|
||||
order: 20,
|
||||
},
|
||||
monitoring: {
|
||||
displayName: 'Monitoring',
|
||||
displayNameByCloud: {aws: 'CloudWatch'},
|
||||
iconKey: 'monitoring',
|
||||
group: 'Observability',
|
||||
order: 5,
|
||||
},
|
||||
logs: {
|
||||
displayName: 'Logs',
|
||||
displayNameByCloud: {aws: 'CloudWatch Logs'},
|
||||
|
||||
@@ -178,6 +178,10 @@ export type UserPoolActionName =
|
||||
| 'createGroup'
|
||||
| 'deleteGroup'
|
||||
| 'testSignIn'
|
||||
| 'describeClient'
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -222,6 +226,8 @@ export interface ServiceSchema {
|
||||
databaseActions?: CapabilitySchema<DatabaseActionName>[]
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||
// Child levels advertise separately from the resource level because a
|
||||
// store can be readable at the leaf and writable at the collection —
|
||||
// CloudWatch Logs creates streams but cannot delete an event.
|
||||
@@ -453,6 +459,72 @@ export interface UserPoolSignInTestInput {
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string | null
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||
|
||||
/** The app client settings that decide how the hosted login and OAuth endpoints treat it. */
|
||||
export interface UserPoolClientSettings {
|
||||
clientId: string
|
||||
clientName: string
|
||||
hasSecret: boolean
|
||||
explicitAuthFlows: string[]
|
||||
callbackUrls: string[]
|
||||
logoutUrls: string[]
|
||||
oauthFlows: UserPoolOAuthFlow[]
|
||||
oauthScopes: string[]
|
||||
identityProviders: string[]
|
||||
}
|
||||
|
||||
export interface UpdateUserPoolClientOAuthInput {
|
||||
callbackUrls: string[]
|
||||
logoutUrls: string[]
|
||||
oauthFlows: UserPoolOAuthFlow[]
|
||||
oauthScopes: string[]
|
||||
identityProviders: string[]
|
||||
}
|
||||
|
||||
export type UserPoolSignInTestResult =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
@@ -970,6 +1042,15 @@ export interface CloudServiceAdapter {
|
||||
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
|
||||
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
|
||||
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
||||
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
||||
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>
|
||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||
clearEmailInbox?(): Promise<void>
|
||||
// AppConfig nested resources, keyed by the ids the provider needs. Inspect
|
||||
|
||||
@@ -81,6 +81,8 @@ export function awsUserPoolsSchema(): ServiceSchema {
|
||||
{name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'describeClient', label: 'Show app client settings', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'updateClientOAuth', label: 'Change hosted sign-in and OAuth settings', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: userPoolFilters,
|
||||
@@ -92,6 +94,10 @@ export function awsUserPoolsSchema(): ServiceSchema {
|
||||
}
|
||||
}
|
||||
|
||||
export const USER_POOL_OAUTH_FLOWS = ['code', 'implicit', 'client_credentials'] as const
|
||||
/** Scopes every pool offers; resource servers can add their own. */
|
||||
export const USER_POOL_STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin'] as const
|
||||
|
||||
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsUserPoolsSchema() : null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -47,6 +47,19 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
||||
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
||||
}
|
||||
|
||||
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
|
||||
listMetrics: 'listMetrics',
|
||||
getMetricData: 'getMetricSeries',
|
||||
alarmHistory: 'describeAlarmHistory',
|
||||
setAlarmState: 'setAlarmState',
|
||||
setAlarmActions: 'setAlarmActionsEnabled',
|
||||
}
|
||||
|
||||
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
||||
setEnabled: 'setDistributionEnabled',
|
||||
createInvalidation: 'createDistributionInvalidation',
|
||||
}
|
||||
|
||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||
updateUser: 'updateUserPoolUser',
|
||||
setUserGroups: 'setUserPoolUserGroups',
|
||||
@@ -54,6 +67,8 @@ const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudService
|
||||
createGroup: 'createUserPoolGroup',
|
||||
deleteGroup: 'deleteUserPoolGroup',
|
||||
testSignIn: 'testUserPoolSignIn',
|
||||
describeClient: 'describeUserPoolClient',
|
||||
updateClientOAuth: 'updateUserPoolClientOAuth',
|
||||
}
|
||||
|
||||
const registry = createCloudAdapterRegistry()
|
||||
@@ -139,6 +154,24 @@ describe('registered adapters honour their schema', () => {
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).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 distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of distributionClaims) {
|
||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
test(`${label} child collections match their advertised capabilities`, () => {
|
||||
@@ -152,6 +185,8 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.databaseActions ?? []),
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
@@ -216,7 +251,7 @@ describe('adapter capability advertisements match the runtime reality', () => {
|
||||
label: 'Create DB snapshot',
|
||||
enabled: true,
|
||||
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,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -48,6 +48,9 @@ import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -98,6 +101,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsElastiCacheAdapter(clients.elasticache),
|
||||
new AwsLightsailAdapter(clients.lightsail),
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AwsDnsAdapter(clients.route53),
|
||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -122,6 +122,6 @@ describe('GcpRestRuntimeClient.health', () => {
|
||||
stubFetch(() => {
|
||||
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)
|
||||
} catch (error) {
|
||||
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},
|
||||
)
|
||||
}
|
||||
@@ -76,12 +76,12 @@ export class GcpRestRuntimeClient implements GcpRuntimeClient {
|
||||
res = await globalThis.fetch(url, {method: 'GET'})
|
||||
} catch (error) {
|
||||
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},
|
||||
)
|
||||
}
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {awsKmsSchema} from '../cloud-spi/kmsSchema'
|
||||
import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||
@@ -747,6 +749,80 @@ describe('cloud schema routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('reads and updates an app client\'s OAuth settings', async () => {
|
||||
const settings = {clientId: 'c1', clientName: 'web', hasSecret: false, explicitAuthFlows: [], callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'userpools',
|
||||
schema: awsUserPoolsSchema,
|
||||
describeUserPoolClient: async () => settings,
|
||||
updateUserPoolClientOAuth: async (poolId, clientId, input) => {
|
||||
calls.push([poolId, clientId, input])
|
||||
return {...settings, ...input}
|
||||
},
|
||||
})])
|
||||
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x/clients/c1'
|
||||
const input = {callbackUrls: ['http://localhost:4600/cb'], logoutUrls: [], oauthFlows: ['code'], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
|
||||
|
||||
expect(await (await app.request(base)).json()).toEqual(settings)
|
||||
const updated = await app.request(`${base}/oauth`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify(input)})
|
||||
expect(updated.status).toBe(200)
|
||||
expect((await updated.json()).oauthFlows).toEqual(['code'])
|
||||
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 () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'cdn',
|
||||
schema: awsCdnSchema,
|
||||
setDistributionEnabled: async (id, enabled) => {
|
||||
calls.push(['enabled', id, enabled])
|
||||
return {id, name: 'd.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution', region: null, createdAt: null, metadata: {enabled}}
|
||||
},
|
||||
createDistributionInvalidation: async (id, paths) => {
|
||||
calls.push(['invalidate', id, paths])
|
||||
return {id: 'I1', status: 'Completed', createdAt: null, paths}
|
||||
},
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/cdn/resources/E123'
|
||||
|
||||
const enabled = await app.request(`${base}/enabled`, json('PUT', {enabled: false}))
|
||||
expect(enabled.status).toBe(200)
|
||||
expect((await enabled.json()).metadata.enabled).toBe(false)
|
||||
const invalidated = await app.request(`${base}/invalidations`, json('POST', {paths: ['/*']}))
|
||||
expect(invalidated.status).toBe(201)
|
||||
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
||||
})
|
||||
|
||||
test('clears the SES mailbox through the email adapter', async () => {
|
||||
let cleared = false
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
AlarmStateValue,
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
@@ -447,6 +448,83 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/userpools/resources/:id/clients/:clientId', 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).describeUserPoolClient(cloud, c.req.param('id'), c.req.param('clientId'))))
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/userpools/resources/:id/clients/:clientId/oauth', 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).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) => {
|
||||
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}>()
|
||||
return c.json(await svc(c).setDistributionEnabled(cloud, c.req.param('id'), body.enabled as boolean))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/cdn/resources/:id/invalidations', 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<{paths?: string[]}>()
|
||||
return c.json(await svc(c).createDistributionInvalidation(cloud, c.req.param('id'), body.paths as string[]), 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
@@ -81,6 +81,14 @@ import type {
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
import type {
|
||||
@@ -831,6 +839,60 @@ export class CloudProxyService {
|
||||
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> {
|
||||
const adapter = this.requireAdapter(cloud, 'cdn')
|
||||
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
|
||||
return adapter.setDistributionEnabled(distributionId, enabled)
|
||||
}
|
||||
|
||||
async createDistributionInvalidation(cloud: CloudProvider, distributionId: string, paths: string[]): Promise<DistributionInvalidation> {
|
||||
const adapter = this.requireAdapter(cloud, 'cdn')
|
||||
if (!adapter.createDistributionInvalidation) throw new NotSupportedError(`Invalidations are not supported for ${cloud}/cdn`)
|
||||
return adapter.createDistributionInvalidation(distributionId, paths)
|
||||
}
|
||||
|
||||
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||
return adapter.describeUserPoolClient(poolId, clientId)
|
||||
}
|
||||
|
||||
async updateUserPoolClientOAuth(cloud: CloudProvider, poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.updateUserPoolClientOAuth) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||
return adapter.updateUserPoolClientOAuth(poolId, clientId, input)
|
||||
}
|
||||
|
||||
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'k8s')
|
||||
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
|
||||
|
||||
@@ -16,7 +16,7 @@ export type RuntimeProbe = () => Promise<void>
|
||||
export const runtimeProbes: Record<CloudProvider, RuntimeProbe> = {
|
||||
// Every runtime exposes a health endpoint, but under its own path: core and
|
||||
// Floci-AZ use /_floci/health, Floci-GCP uses /_floci-gcp/health.
|
||||
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'Floci core'),
|
||||
aws: () => probeHttp(`${awsEndpoint()}/_floci/health`, 'TheCloud AWS runtime'),
|
||||
azure: async () => {
|
||||
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> {
|
||||
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
|
||||
try {
|
||||
@@ -13,19 +13,19 @@ export async function deployLightsailStaticFiles(instanceName: string, form: For
|
||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
} 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}
|
||||
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') {
|
||||
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}
|
||||
}
|
||||
|
||||
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
|
||||
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
|
||||
try {
|
||||
@@ -33,12 +33,12 @@ export async function deployLightsailNodeApplication(instanceName: string, form:
|
||||
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
|
||||
})
|
||||
} 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}
|
||||
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') {
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -59,23 +59,23 @@ type SshCheckDependencies = {
|
||||
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
|
||||
const endpoint = process.env.FLOCI_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
|
||||
try {
|
||||
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
|
||||
} catch {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
|
||||
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'TheCloud diagnostics are unavailable', 503)
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json() as DiagnosticResponse
|
||||
} 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()
|
||||
} catch (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'
|
||||
|| !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)
|
||||
if (!instance) {
|
||||
|
||||
@@ -130,6 +130,17 @@ 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"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
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",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
},
|
||||
userpools: {
|
||||
userAction: "clouds.services.userpools.users.action",
|
||||
userGroups: "clouds.services.userpools.users.groups",
|
||||
@@ -139,6 +150,10 @@ export const apiEndpointKeys = {
|
||||
delete: "clouds.services.userpools.groups.delete",
|
||||
},
|
||||
signInTest: "clouds.services.userpools.sign-in-test",
|
||||
client: {
|
||||
get: "clouds.services.userpools.clients.get",
|
||||
updateOAuth: "clouds.services.userpools.clients.update-oauth",
|
||||
},
|
||||
},
|
||||
k8s: {
|
||||
nodegroups: {
|
||||
@@ -882,6 +897,39 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
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.cdn.enabled,
|
||||
{
|
||||
path: "/clouds/:cloud/services/cdn/resources/:id/enabled",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.invalidations,
|
||||
{
|
||||
path: "/clouds/:cloud/services/cdn/resources/:id/invalidations",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.client.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.client.updateOAuth,
|
||||
{
|
||||
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId/oauth",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||
{
|
||||
|
||||
@@ -31,6 +31,14 @@ import type {
|
||||
UserPoolGroup,
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
@@ -1474,6 +1482,124 @@ export async function testUserPoolSignIn(
|
||||
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 async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
enabled: boolean,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, { enabled: boolean }>(
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
requestOptions(cloud, "cdn", { body: { enabled } }),
|
||||
{ cloud, id: distributionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createDistributionInvalidation(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
paths: string[],
|
||||
): Promise<DistributionInvalidation> {
|
||||
const res = await apiClient.call<DistributionInvalidation, { paths: string[] }>(
|
||||
apiEndpointKeys.clouds.cdn.invalidations,
|
||||
requestOptions(cloud, "cdn", { body: { paths } }),
|
||||
{ cloud, id: distributionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeUserPoolClient(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
clientId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UserPoolClientSettings> {
|
||||
const res = await apiClient.call<UserPoolClientSettings>(
|
||||
apiEndpointKeys.clouds.userpools.client.get,
|
||||
requestOptions(cloud, "userpools", { signal }),
|
||||
{ cloud, id: poolId, clientId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateUserPoolClientOAuth(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
clientId: string,
|
||||
input: UpdateUserPoolClientOAuthInput,
|
||||
): Promise<UserPoolClientSettings> {
|
||||
const res = await apiClient.call<UserPoolClientSettings, UpdateUserPoolClientOAuthInput>(
|
||||
apiEndpointKeys.clouds.userpools.client.updateOAuth,
|
||||
requestOptions(cloud, "userpools", { body: input }),
|
||||
{ cloud, id: poolId, clientId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function putFlatItem(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
|
||||
@@ -234,7 +234,7 @@ export function AzureSqlPanel({
|
||||
)}
|
||||
<small className="sql-credential-note">
|
||||
{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."}
|
||||
</small>
|
||||
{connectionError && <div className="form-error">{connectionError}</div>}
|
||||
@@ -243,7 +243,7 @@ export function AzureSqlPanel({
|
||||
{!connected ? (
|
||||
<div className="empty compact sql-connect-empty">
|
||||
<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 className="sql-explorer">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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 {CloudFrontPanel} from './CloudFrontPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
setDistributionEnabled: vi.fn(),
|
||||
createDistributionInvalidation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost:4566/'
|
||||
const distribution: CloudResource = {
|
||||
id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution',
|
||||
region: null, createdAt: null, status: 'Deployed',
|
||||
metadata: {
|
||||
viewerUrl: VIEWER_URL, domainName: 'e7cpm0p0g0vc8d.cloudfront.net', enabled: true, defaultRootObject: 'index.html',
|
||||
origins: [{id: 's3-site', domainName: 'site.s3.amazonaws.com', kind: 's3', protocol: null}],
|
||||
behaviors: [{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'redirect-to-https', methods: ['GET', 'HEAD']}],
|
||||
invalidations: [{id: 'I1', status: 'Completed', createdAt: null, paths: []}],
|
||||
},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudFrontPanel resource={distribution} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue(distribution)
|
||||
api.setDistributionEnabled.mockImplementation(async (_cloud, _id, enabled) => ({...distribution, metadata: {...distribution.metadata, enabled}}))
|
||||
api.createDistributionInvalidation.mockImplementation(async (_cloud, _id, paths) => ({id: 'I2', status: 'Completed', createdAt: null, paths}))
|
||||
})
|
||||
|
||||
test('opens the distribution locally and shows its origins and behaviours', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByRole('link', {name: /Open/})).toHaveAttribute('href', VIEWER_URL)
|
||||
expect(screen.getByText('site.s3.amazonaws.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Redirect to HTTPS')).toBeInTheDocument()
|
||||
expect(screen.getByText('I1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('disables the distribution', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Disable'}))
|
||||
expect(api.setDistributionEnabled).toHaveBeenCalledWith('aws', distribution.id, false)
|
||||
expect(await screen.findByText(/Distribution disabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('invalidates the paths entered, one per line', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Create invalidation'})
|
||||
const field = within(form).getByLabelText(/Paths/)
|
||||
await user.clear(field)
|
||||
await user.type(field, '/index.html{enter}/assets/*')
|
||||
await user.click(within(form).getByRole('button', {name: 'Create invalidation'}))
|
||||
|
||||
expect(api.createDistributionInvalidation).toHaveBeenCalledWith('aws', distribution.id, ['/index.html', '/assets/*'])
|
||||
expect(await screen.findByText(/Invalidation I2 completed for \/index.html, \/assets\/\*/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import {ExternalLink, RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {createDistributionInvalidation, getCloudResource, setDistributionEnabled} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type Origin = {id: string; domainName: string; kind: string; protocol: string | null}
|
||||
type Behavior = {pathPattern: string; originId: string; viewerProtocol: string; methods: string[]}
|
||||
type Invalidation = {id: string; status: string; createdAt: string | null}
|
||||
|
||||
const VIEWER_PROTOCOL: Record<string, string> = {
|
||||
'allow-all': 'HTTP and HTTPS',
|
||||
'redirect-to-https': 'Redirect to HTTPS',
|
||||
'https-only': 'HTTPS only',
|
||||
}
|
||||
|
||||
export function CloudFrontPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [paths, setPaths] = useState('/*')
|
||||
const [notice, setNotice] = useState('')
|
||||
const distributionKey = ['cdn-distribution', accountId, resource?.id]
|
||||
|
||||
// The table row is a list summary; behaviours and invalidations come from the full distribution.
|
||||
const distributionQuery = useQuery({
|
||||
queryKey: distributionKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'cdn', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPaths('/*')
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: distributionKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'cdn']}),
|
||||
])
|
||||
|
||||
const enabledMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => setDistributionEnabled('aws', resource?.id ?? '', enabled),
|
||||
onSuccess: (updated) => {
|
||||
setNotice(updated.metadata.enabled ? 'Distribution enabled.' : 'Distribution disabled. It no longer answers viewer requests.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const invalidationMutation = useMutation({
|
||||
mutationFn: () => createDistributionInvalidation('aws', resource?.id ?? '', paths.split('\n')),
|
||||
onSuccess: (invalidation) => {
|
||||
setNotice(`Invalidation ${invalidation.id} ${invalidation.status === 'Completed' ? 'completed' : 'started'} for ${invalidation.paths.join(', ')}.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a distribution</h3><p>Choose a distribution to open it, change its state or invalidate cached files.</p></div></section>
|
||||
}
|
||||
|
||||
const distribution = distributionQuery.data ?? resource
|
||||
const metadata = distribution.metadata
|
||||
const viewerUrl = typeof metadata.viewerUrl === 'string' ? metadata.viewerUrl : ''
|
||||
const enabled = metadata.enabled === true
|
||||
const origins = list<Origin>(metadata.origins)
|
||||
const behaviors = list<Behavior>(metadata.behaviors)
|
||||
const invalidations = list<Invalidation>(metadata.invalidations)
|
||||
const pending = enabledMutation.isPending || invalidationMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = distributionQuery.error ?? enabledMutation.error ?? invalidationMutation.error
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Distribution ${distribution.id}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Distribution</p><span className="muted">{enabled ? 'Enabled' : 'Disabled'} · {distribution.status ?? 'Unknown'}{metadata.defaultRootObject ? ` · serves ${String(metadata.defaultRootObject)} at /` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); enabledMutation.mutate(!enabled) }}>{enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || distributionQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{distributionQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewerUrl && <div className="cdn-viewer">
|
||||
<span className="copy-value mono">{viewerUrl}<CopyValueButton value={viewerUrl} label="Local URL"/></span>
|
||||
<a className="button" href={viewerUrl} target="_blank" rel="noreferrer"><ExternalLink size={13}/> Open</a>
|
||||
<small className="muted">Served by TheCloud on this machine. On AWS the same distribution answers at <code>{String(metadata.domainName ?? distribution.name)}</code>.</small>
|
||||
</div>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Origins</p></div></div>
|
||||
{origins.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Origin</th><th>Domain</th><th>Type</th></tr></thead><tbody>{origins.map((origin) => (
|
||||
<tr key={origin.id}><td className="mono">{origin.id}</td><td className="mono">{origin.domainName}</td><td>{origin.kind === 's3' ? 'S3 bucket' : `Custom (${origin.protocol ?? 'https-only'})`}</td></tr>
|
||||
))}</tbody></table></div>}
|
||||
|
||||
{behaviors.length > 0 && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Cache behaviours</p><span className="muted">The first path pattern that matches a request decides how it is served.</span></div></div>
|
||||
<div className="cognito-table"><table className="table"><thead><tr><th>Path pattern</th><th>Origin</th><th>Viewer protocol</th><th>Methods</th></tr></thead><tbody>{behaviors.map((behavior) => (
|
||||
<tr key={behavior.pathPattern}><td className="mono">{behavior.pathPattern}</td><td className="mono">{behavior.originId}</td><td>{VIEWER_PROTOCOL[behavior.viewerProtocol] ?? behavior.viewerProtocol}</td><td>{behavior.methods.join(', ')}</td></tr>
|
||||
))}</tbody></table></div>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Invalidations</p><span className="muted">Clear cached copies so the next request fetches them from the origin.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Create invalidation" onSubmit={(event) => { event.preventDefault(); setNotice(''); invalidationMutation.mutate() }}>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Paths</span>
|
||||
<textarea className="textarea code-textarea small" rows={2} value={paths} onChange={(event) => setPaths(event.target.value)} disabled={disabled} required/>
|
||||
<small className="muted">One per line, each starting with /. Use /* for everything.</small>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !paths.trim()}>{invalidationMutation.isPending ? 'Invalidating' : 'Create invalidation'}</button></div>
|
||||
</form>
|
||||
{invalidations.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Invalidation</th><th>Status</th><th>Created</th></tr></thead><tbody>{invalidations.map((invalidation) => (
|
||||
<tr key={invalidation.id}><td className="mono">{invalidation.id}</td><td><code>{invalidation.status}</code></td><td>{formatDateTime(invalidation.createdAt) ?? '—'}</td></tr>
|
||||
))}</tbody></table></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 list<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value as T[] : []
|
||||
}
|
||||
@@ -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,145 @@
|
||||
import {X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {describeUserPoolClient, updateUserPoolClientOAuth} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {UserPoolClientSettings, UserPoolOAuthFlow} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
poolId: string
|
||||
clientId: string
|
||||
runtimeReachable: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FLOWS: {flow: UserPoolOAuthFlow; label: string; hint: string}[] = [
|
||||
{flow: 'code', label: 'Authorization code', hint: 'Recommended for web and mobile apps, with PKCE.'},
|
||||
{flow: 'implicit', label: 'Implicit', hint: 'Legacy: tokens come back in the URL.'},
|
||||
{flow: 'client_credentials', label: 'Client credentials', hint: 'Machine-to-machine. Needs a client secret.'},
|
||||
]
|
||||
const STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin']
|
||||
const COGNITO = 'COGNITO'
|
||||
|
||||
type Draft = {callbackUrls: string; logoutUrls: string; oauthFlows: UserPoolOAuthFlow[]; oauthScopes: string[]; identityProviders: string[]}
|
||||
|
||||
export function CognitoAppClientSettings({poolId, clientId, runtimeReachable, onClose}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const clientKey = ['cognito-client', accountId, poolId, clientId]
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const clientQuery = useQuery({
|
||||
queryKey: clientKey,
|
||||
queryFn: ({signal}) => describeUserPoolClient('aws', poolId, clientId, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const client = clientQuery.data
|
||||
|
||||
useEffect(() => {
|
||||
if (client) setDraft(draftFrom(client))
|
||||
}, [client])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (value: Draft) => updateUserPoolClientOAuth('aws', poolId, clientId, {
|
||||
callbackUrls: lines(value.callbackUrls),
|
||||
logoutUrls: lines(value.logoutUrls),
|
||||
oauthFlows: value.oauthFlows,
|
||||
oauthScopes: value.oauthScopes,
|
||||
identityProviders: value.identityProviders,
|
||||
}),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(clientKey, saved)
|
||||
setNotice(saved.oauthFlows.includes('code') && saved.identityProviders.includes(COGNITO)
|
||||
? 'Saved. Apps using this client can now send users to the hosted login page.'
|
||||
: 'Saved.')
|
||||
},
|
||||
})
|
||||
|
||||
const disabled = !runtimeReachable || saveMutation.isPending
|
||||
const scopes = draft ? [...STANDARD_SCOPES, ...draft.oauthScopes.filter((scope) => !STANDARD_SCOPES.includes(scope))] : STANDARD_SCOPES
|
||||
const otherProviders = draft?.identityProviders.filter((provider) => provider !== COGNITO) ?? []
|
||||
const changed = Boolean(client && draft && JSON.stringify(normalized(draft)) !== JSON.stringify(normalized(draftFrom(client))))
|
||||
const update = (patch: Partial<Draft>) => { setNotice(''); setDraft((current) => current && {...current, ...patch}) }
|
||||
const toggle = <T extends string>(list: T[], value: T, on: boolean) => on ? [...list.filter((entry) => entry !== value), value] : list.filter((entry) => entry !== value)
|
||||
const error = clientQuery.error ?? saveMutation.error
|
||||
|
||||
return <section className="cognito-user" aria-label={`App client ${client?.clientName || clientId}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">App client</p><strong>{client?.clientName || clientId}</strong></div>
|
||||
<button className="icon-btn" type="button" title="Close" aria-label="Close app client settings" onClick={onClose}><X size={14}/></button>
|
||||
</div>
|
||||
{clientQuery.isLoading && <p className="muted padded">Loading app client</p>}
|
||||
{client && draft && <form className="cognito-user-grid" aria-label="Hosted sign-in settings" onSubmit={(event) => { event.preventDefault(); saveMutation.mutate(draft) }}>
|
||||
<dl className="cognito-facts">
|
||||
<dt>Client ID</dt><dd className="mono">{client.clientId}</dd>
|
||||
<dt>Client secret</dt><dd>{client.hasSecret ? 'Yes' : 'No (public client)'}</dd>
|
||||
<dt>Sign-in flows</dt><dd>{client.explicitAuthFlows.length ? client.explicitAuthFlows.map((flow) => <code key={flow} className="cognito-flow">{flow.replace(/^ALLOW_/, '')}</code>) : '—'}</dd>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Hosted sign-in</p>
|
||||
<label className="cognito-check">
|
||||
<input type="checkbox" checked={draft.identityProviders.includes(COGNITO)} disabled={disabled} onChange={(event) => update({identityProviders: toggle(draft.identityProviders, COGNITO, event.target.checked)})}/>
|
||||
Hosted login page for this pool's users
|
||||
</label>
|
||||
{otherProviders.length > 0 && <p className="muted compact-text">Also signs in with: {otherProviders.map((provider) => <code key={provider}>{provider}</code>)}</p>}
|
||||
<p className="eyebrow">OAuth flows</p>
|
||||
{FLOWS.map(({flow, label, hint}) => (
|
||||
<label key={flow} className="cognito-check" title={hint}>
|
||||
<input type="checkbox" checked={draft.oauthFlows.includes(flow)} disabled={disabled || (flow === 'client_credentials' && !client.hasSecret)} onChange={(event) => update({oauthFlows: toggle(draft.oauthFlows, flow, event.target.checked)})}/>
|
||||
{label} <small className="muted">{hint}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow">Scopes</p>
|
||||
{scopes.map((scope) => (
|
||||
<label key={scope} className="cognito-check">
|
||||
<input type="checkbox" checked={draft.oauthScopes.includes(scope)} disabled={disabled} onChange={(event) => update({oauthScopes: toggle(draft.oauthScopes, scope, event.target.checked)})}/>
|
||||
<code>{scope}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cognito-client-urls">
|
||||
<label className="dynamic-field"><span>Callback URLs</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={draft.callbackUrls} disabled={disabled} placeholder="http://localhost:3000/api/auth/callback" onChange={(event) => update({callbackUrls: event.target.value})}/>
|
||||
<small className="muted">One per line. Where Cognito sends users back with a code. https, or http for localhost.</small>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Sign-out URLs</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={draft.logoutUrls} disabled={disabled} placeholder="http://localhost:3000/" onChange={(event) => update({logoutUrls: event.target.value})}/>
|
||||
<small className="muted">One per line. Allowed <code>logout_uri</code> values after sign-out.</small>
|
||||
</label>
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{saveMutation.isPending ? 'Saving' : 'Save settings'}</button></div>
|
||||
</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 draftFrom(client: UserPoolClientSettings): Draft {
|
||||
return {
|
||||
callbackUrls: client.callbackUrls.join('\n'),
|
||||
logoutUrls: client.logoutUrls.join('\n'),
|
||||
oauthFlows: client.oauthFlows,
|
||||
oauthScopes: client.oauthScopes,
|
||||
identityProviders: client.identityProviders,
|
||||
}
|
||||
}
|
||||
|
||||
function normalized(draft: Draft) {
|
||||
return {
|
||||
callbackUrls: lines(draft.callbackUrls),
|
||||
logoutUrls: lines(draft.logoutUrls),
|
||||
oauthFlows: [...draft.oauthFlows].sort(),
|
||||
oauthScopes: [...draft.oauthScopes].sort(),
|
||||
identityProviders: [...draft.identityProviders].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
function lines(value: string): string[] {
|
||||
return value.split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {ScrollText, Zap} from 'lucide-react'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
|
||||
|
||||
export function CognitoTriggersSection({pool}: {pool?: CloudResource}) {
|
||||
const triggers = triggersOf(pool)
|
||||
return <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Lambda triggers</p><span className="muted">Functions Cognito runs during sign-up, sign-in and token issue</span></div>
|
||||
</div>
|
||||
{pool && triggers.length === 0 && <p className="muted padded">This pool runs no Lambda triggers.</p>}
|
||||
{triggers.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Trigger</th><th>Function</th><th>Version</th><th/></tr></thead><tbody>{triggers.map((trigger) => (
|
||||
<tr key={trigger.trigger}>
|
||||
<td>{trigger.label}</td>
|
||||
<td><code title={trigger.functionArn}>{trigger.functionName}</code></td>
|
||||
<td>{trigger.version ? <code>{trigger.version}</code> : '—'}</td>
|
||||
<td className="cognito-row-actions">
|
||||
<a className="button" href={`/cloud-explorer/aws/serverless?resource=${encodeURIComponent(trigger.functionName)}`} aria-label={`Open function ${trigger.functionName}`}><Zap size={13}/> Function</a>
|
||||
<a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${trigger.functionName}`)}`} aria-label={`Open logs for ${trigger.functionName}`}><ScrollText size={13}/> Logs</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
</>
|
||||
}
|
||||
|
||||
function triggersOf(pool?: CloudResource): LambdaTrigger[] {
|
||||
const triggers = pool?.metadata.lambdaTriggers
|
||||
if (!Array.isArray(triggers)) return []
|
||||
return triggers.filter((trigger): trigger is LambdaTrigger => typeof trigger?.functionName === 'string' && typeof trigger?.label === 'string')
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const api = vi.hoisted(() => ({
|
||||
createUserPoolGroup: vi.fn(),
|
||||
deleteUserPoolGroup: vi.fn(),
|
||||
testUserPoolSignIn: vi.fn(),
|
||||
describeUserPoolClient: vi.fn(),
|
||||
updateUserPoolClientOAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
@@ -64,6 +66,50 @@ test('shows the app client id and the pool users', async () => {
|
||||
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
|
||||
})
|
||||
|
||||
test('lists the pool\'s Lambda triggers with links to the function and its logs', async () => {
|
||||
api.getCloudResource.mockResolvedValue({...pool('email'), metadata: {...pool('email').metadata, lambdaTriggers: [
|
||||
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: 'arn:aws:lambda:us-east-1:000000000000:function:notes-demo-pre-token', functionName: 'notes-demo-pre-token', version: null},
|
||||
]}})
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('Pre token generation')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', {name: 'Open function notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless?resource=notes-demo-pre-token')
|
||||
expect(screen.getByRole('link', {name: 'Open logs for notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fnotes-demo-pre-token')
|
||||
})
|
||||
|
||||
test('says when a pool has no Lambda triggers', async () => {
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('This pool runs no Lambda triggers.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('turns on hosted sign-in for an app client', async () => {
|
||||
const user = userEvent.setup()
|
||||
const settings = {clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client', hasSecret: false, explicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
api.describeUserPoolClient.mockResolvedValue(settings)
|
||||
api.updateUserPoolClientOAuth.mockImplementation(async (_cloud, _pool, _client, input) => ({...settings, ...input}))
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Manage app client orders-users-client'}))
|
||||
const form = await screen.findByRole('form', {name: 'Hosted sign-in settings'})
|
||||
expect(within(form).getByRole('checkbox', {name: /Client credentials/})).toBeDisabled()
|
||||
await user.click(within(form).getByRole('checkbox', {name: /Authorization code/}))
|
||||
await user.click(within(form).getByRole('checkbox', {name: 'openid'}))
|
||||
await user.click(within(form).getByRole('checkbox', {name: 'email'}))
|
||||
await user.type(within(form).getByLabelText(/Callback URLs/), 'http://localhost:4600/api/auth/callback')
|
||||
await user.type(within(form).getByLabelText(/Sign-out URLs/), 'http://localhost:4600/')
|
||||
await user.click(within(form).getByRole('button', {name: 'Save settings'}))
|
||||
|
||||
expect(api.updateUserPoolClientOAuth).toHaveBeenCalledWith('aws', summary.id, 'a8e5c7d38d3b4a1a97c9d822de', {
|
||||
callbackUrls: ['http://localhost:4600/api/auth/callback'], logoutUrls: ['http://localhost:4600/'],
|
||||
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
|
||||
})
|
||||
expect(await screen.findByText(/can now send users to the hosted login page/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('creates an email user who can sign in immediately', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudResource.mockResolvedValue(pool('email'))
|
||||
|
||||
@@ -2,8 +2,10 @@ import {RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CognitoAppClientSettings} from '@/components/CognitoAppClientSettings'
|
||||
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
|
||||
import {CognitoSignInTest} from '@/components/CognitoSignInTest'
|
||||
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
|
||||
import {CognitoUserDetail} from '@/components/CognitoUserDetail'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
@@ -24,6 +26,7 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [permanent, setPermanent] = useState(true)
|
||||
const [openUser, setOpenUser] = useState<string | null>(null)
|
||||
const [openClient, setOpenClient] = useState<string | null>(null)
|
||||
const usersKey = ['cognito-users', accountId, resource?.id]
|
||||
const groupsKey = ['cognito-groups', accountId, resource?.id]
|
||||
|
||||
@@ -93,9 +96,16 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
|
||||
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
|
||||
</div>
|
||||
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
|
||||
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => (
|
||||
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr>
|
||||
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th><th/></tr></thead><tbody>{appClients.map((client) => (
|
||||
<tr key={client.clientId}>
|
||||
<td>{client.clientName || '—'}</td>
|
||||
<td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td>
|
||||
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openClient === client.clientId} aria-label={`Manage app client ${client.clientName || client.clientId}`} onClick={() => setOpenClient(openClient === client.clientId ? null : client.clientId)}>Manage</button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
{openClient && <CognitoAppClientSettings key={openClient} poolId={resource.id} clientId={openClient} runtimeReachable={runtimeReachable} onClose={() => setOpenClient(null)}/>}
|
||||
|
||||
<CognitoTriggersSection pool={poolQuery.data ?? undefined}/>
|
||||
|
||||
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function DatabaseSnapshotsPanel({
|
||||
"code" in error.body &&
|
||||
(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) {
|
||||
return error.message;
|
||||
|
||||
@@ -65,6 +65,10 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
@@ -979,6 +983,18 @@ export function DynamicResourceView({
|
||||
{service === "userpools" && cloud === "aws" && (
|
||||
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "dns" && cloud === "aws" && (
|
||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "monitoring" && cloud === "aws" && (
|
||||
<>
|
||||
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
|
||||
</>
|
||||
)}
|
||||
{service === "compute" && (
|
||||
<ComputePanel
|
||||
cloud={cloud}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span className="sidebar-footer-text">Floci DevTools · Local</span>
|
||||
<span className="sidebar-footer-text">TheCloud · Local</span>
|
||||
<button
|
||||
ref={toggleRef}
|
||||
className="icon-btn"
|
||||
|
||||
@@ -58,7 +58,7 @@ export function LightsailLaunchPanel({isSubmitting, submitError, onCancel, onSub
|
||||
<div>
|
||||
<p className="eyebrow">Amazon Lightsail</p>
|
||||
<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>
|
||||
<button className="icon-btn" type="button" onClick={onCancel} aria-label="Close create instance">×</button>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +190,7 @@ function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult;
|
||||
</div>
|
||||
{stillRunning && (
|
||||
<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
|
||||
(query id: {result.queryId}).
|
||||
</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,94 @@
|
||||
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 {Route53RecordsPanel} from './Route53RecordsPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
deleteFlatItem: vi.fn(),
|
||||
getCloudResource: vi.fn(),
|
||||
listFlatItems: vi.fn(),
|
||||
putFlatItem: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const zone: CloudResource = {
|
||||
id: 'ZG9PAE0M9KRWJ4F', name: 'example.test', cloud: 'aws', service: 'dns', type: 'hosted-zone',
|
||||
region: null, createdAt: null, metadata: {visibility: 'public', recordCount: 3},
|
||||
}
|
||||
|
||||
function item(id: string, body: Record<string, unknown>) {
|
||||
return {id, collectionId: null, timestamp: null, metadata: {}, body: {ttl: 300, values: [], alias: null, setIdentifier: null, managed: false, editable: true, ...body}}
|
||||
}
|
||||
|
||||
const records = [
|
||||
item('example.test|NS', {name: 'example.test', type: 'NS', ttl: 172800, values: ['ns-1.awsdns-01.org.'], managed: true}),
|
||||
item('www.example.test|A', {name: 'www.example.test', type: 'A', values: ['10.0.0.1']}),
|
||||
item('example.test|TXT', {name: 'example.test', type: 'TXT', values: ['"v=spf1 -all"']}),
|
||||
item('cdn.example.test|A', {name: 'cdn.example.test', type: 'A', ttl: null, alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2'}, editable: false}),
|
||||
]
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><Route53RecordsPanel resource={zone} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue({...zone, metadata: {...zone.metadata, nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}})
|
||||
api.listFlatItems.mockResolvedValue({items: records, nextCursor: null})
|
||||
api.putFlatItem.mockImplementation(async (_cloud, _service, _zone, body) => item('x', {name: `${body.name}.example.test`, type: body.type}))
|
||||
api.deleteFlatItem.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the name servers and records, keeping the zone defaults read-only', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('ns-2.awsdns-02.net')).toBeInTheDocument()
|
||||
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alias → d111.cloudfront.net')).toBeInTheDocument()
|
||||
expect(screen.getByText('zone default')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {name: 'Delete NS example.test'})).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {name: 'Edit A cdn.example.test'})).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {name: 'Delete A cdn.example.test'})).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('creates a record from a name inside the zone', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Create record'})
|
||||
await user.type(within(form).getByLabelText(/Record name/), 'api')
|
||||
await user.selectOptions(within(form).getByLabelText('Type'), 'CNAME')
|
||||
await user.clear(within(form).getByLabelText(/TTL/))
|
||||
await user.type(within(form).getByLabelText(/TTL/), '60')
|
||||
await user.type(within(form).getByLabelText(/Values/), 'app.example.net')
|
||||
await user.click(within(form).getByRole('button', {name: 'Create record'}))
|
||||
|
||||
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, {name: 'api', type: 'CNAME', ttl: 60, values: ['app.example.net']})
|
||||
expect(await screen.findByText(/Created CNAME api.example.test/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('edits a record with its name and type fixed, showing TXT text without quotes', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Edit TXT example.test'}))
|
||||
const form = screen.getByRole('form', {name: 'Edit record'})
|
||||
expect(within(form).getByLabelText(/Record name/)).toHaveValue('@')
|
||||
expect(within(form).getByLabelText(/Record name/)).toBeDisabled()
|
||||
expect(within(form).getByLabelText('Type')).toBeDisabled()
|
||||
expect(within(form).getByLabelText(/Values/)).toHaveValue('v=spf1 -all')
|
||||
})
|
||||
|
||||
test('deletes a record by its name and type', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Delete A www.example.test'}))
|
||||
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, 'www.example.test|A')
|
||||
expect(await screen.findByText('Deleted A www.example.test.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import {Pencil, RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {ChildItem, CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type DnsRecord = {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
ttl: number | null
|
||||
values: string[]
|
||||
alias: {dnsName: string; hostedZoneId: string} | null
|
||||
setIdentifier: string | null
|
||||
managed: boolean
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS', 'PTR', 'SPF', 'DS', 'HTTPS', 'SVCB', 'NAPTR', 'SSHFP', 'TLSA']
|
||||
const VALUE_HINTS: Record<string, string> = {
|
||||
A: 'IPv4 addresses, one per line: 192.0.2.10',
|
||||
AAAA: 'IPv6 addresses, one per line: 2001:db8::10',
|
||||
CNAME: 'One host name: app.example.net',
|
||||
TXT: 'Text, one string per line. Quotes are added for you.',
|
||||
MX: 'Priority and mail server, one per line: 10 mail.example.com',
|
||||
SRV: 'Priority, weight, port and target: 10 5 5060 sip.example.com',
|
||||
CAA: 'Flags, tag and value: 0 issue "amazon.com"',
|
||||
}
|
||||
/** Every page of a zone is fetched; zones on a local runtime are small. */
|
||||
const MAX_PAGES = 20
|
||||
|
||||
const emptyDraft = {name: '', type: 'A', ttl: '300', values: ''}
|
||||
|
||||
export function Route53RecordsPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState(emptyDraft)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const recordsKey = ['dns-records', accountId, resource?.id]
|
||||
|
||||
const zoneQuery = useQuery({
|
||||
queryKey: ['dns-zone', accountId, resource?.id],
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'dns', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const recordsQuery = useQuery({
|
||||
queryKey: recordsKey,
|
||||
queryFn: async ({signal}) => {
|
||||
const items: ChildItem[] = []
|
||||
let cursor: string | undefined
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const result = await listFlatItems('aws', 'dns', resource?.id ?? '', cursor, signal)
|
||||
items.push(...result.items)
|
||||
if (!result.nextCursor) break
|
||||
cursor = result.nextCursor
|
||||
}
|
||||
return items.map(toRecord)
|
||||
},
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(emptyDraft)
|
||||
setEditing(null)
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
|
||||
// The zone list shows each zone's record count.
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: recordsKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['dns-zone', accountId, resource?.id]}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'dns']}),
|
||||
])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => putFlatItem('aws', 'dns', resource?.id ?? '', {
|
||||
name: draft.name,
|
||||
type: draft.type,
|
||||
ttl: Number(draft.ttl),
|
||||
values: draft.values.split('\n'),
|
||||
}),
|
||||
onSuccess: (saved) => {
|
||||
setNotice(`${editing ? 'Saved' : 'Created'} ${String(saved.body.type)} ${String(saved.body.name)}.`)
|
||||
setDraft(emptyDraft)
|
||||
setEditing(null)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteFlatItem('aws', 'dns', resource?.id ?? '', id),
|
||||
onSuccess: (_result, id) => {
|
||||
setNotice(`Deleted ${id.split('|').slice(0, 2).reverse().join(' ')}.`)
|
||||
if (editing === id) { setEditing(null); setDraft(emptyDraft) }
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a hosted zone</h3><p>Choose a hosted zone to see and change its DNS records.</p></div></section>
|
||||
}
|
||||
|
||||
const zoneName = resource.name
|
||||
const records = recordsQuery.data ?? []
|
||||
const nameServers = stringList(zoneQuery.data?.metadata.nameServers)
|
||||
const pending = saveMutation.isPending || deleteMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = zoneQuery.error ?? recordsQuery.error ?? saveMutation.error ?? deleteMutation.error
|
||||
const edit = (record: DnsRecord) => {
|
||||
setNotice('')
|
||||
setEditing(record.id)
|
||||
setDraft({name: relativeName(record.name, zoneName), type: record.type, ttl: String(record.ttl ?? 300), values: record.values.map((value) => record.type === 'TXT' || record.type === 'SPF' ? unquote(value) : value).join('\n')})
|
||||
}
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Records in ${zoneName}`}>
|
||||
{nameServers.length > 0 && <>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Name servers</p><span className="muted">Delegate {zoneName} to these at your registrar. TheCloud stores records but does not answer DNS queries.</span></div>
|
||||
</div>
|
||||
<ul className="dns-name-servers">{nameServers.map((server) => <li key={server}><span className="copy-value mono">{server}<CopyValueButton value={server} label="Name server"/></span></li>)}</ul>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Records</p><span className="muted">{records.length} {records.length === 1 ? 'record' : 'records'} in {zoneName}</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || recordsQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{recordsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
|
||||
<form className="dynamic-form" aria-label={editing ? 'Edit record' : 'Create record'} onSubmit={(event) => { event.preventDefault(); setNotice(''); saveMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>Record name</span>
|
||||
<div className="dns-name-input"><input className="input" value={draft.name} placeholder="www" onChange={(event) => setDraft({...draft, name: event.target.value})} disabled={disabled || Boolean(editing)} autoComplete="off"/><span className="dns-zone-suffix">.{zoneName}</span></div>
|
||||
<small className="muted">Leave empty or use @ for {zoneName} itself.</small>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Type</span>
|
||||
<select className="input" value={draft.type} onChange={(event) => setDraft({...draft, type: event.target.value})} disabled={disabled || Boolean(editing)}>
|
||||
{RECORD_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>TTL (seconds)</span>
|
||||
<input className="input" type="number" min={0} value={draft.ttl} onChange={(event) => setDraft({...draft, ttl: event.target.value})} disabled={disabled} required/>
|
||||
</label>
|
||||
<label className="dynamic-field dynamic-field--span"><span>Values</span>
|
||||
<textarea className="textarea code-textarea small" rows={3} value={draft.values} onChange={(event) => setDraft({...draft, values: event.target.value})} disabled={disabled} required/>
|
||||
<small className="muted">{VALUE_HINTS[draft.type] ?? 'One value per line.'}</small>
|
||||
</label>
|
||||
<div className="dns-form-actions">
|
||||
<button className="button primary" type="submit" disabled={disabled || !draft.values.trim()}>{saveMutation.isPending ? 'Saving' : editing ? 'Save record' : 'Create record'}</button>
|
||||
{editing && <button className="button" type="button" disabled={pending} onClick={() => { setEditing(null); setDraft(emptyDraft) }}>Cancel</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>}
|
||||
{recordsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
||||
{records.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>TTL</th><th>Value</th><th/></tr></thead><tbody>{records.map((record) => (
|
||||
<tr key={record.id} aria-current={editing === record.id ? 'true' : undefined}>
|
||||
<td className="mono">{record.name}{record.managed && <span className="badge dns-badge">zone default</span>}{record.setIdentifier && <span className="badge dns-badge">{record.setIdentifier}</span>}</td>
|
||||
<td><code>{record.type}</code></td>
|
||||
<td>{record.alias ? '—' : record.ttl ?? '—'}</td>
|
||||
<td className="dns-values">{record.alias
|
||||
? <span className="mono">Alias → {record.alias.dnsName}</span>
|
||||
: record.values.map((value, index) => <div key={index} className="mono">{value}</div>)}</td>
|
||||
<td><div className="cognito-row-actions">
|
||||
{record.editable && !record.managed && <button className="icon-btn" type="button" title="Edit record" aria-label={`Edit ${record.type} ${record.name}`} disabled={disabled} onClick={() => edit(record)}><Pencil size={14}/></button>}
|
||||
{!record.managed && <button className="icon-btn danger" type="button" title="Delete record" aria-label={`Delete ${record.type} ${record.name}`} disabled={disabled} onClick={() => { setNotice(''); deleteMutation.mutate(record.id) }}><Trash2 size={14}/></button>}
|
||||
</div></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function toRecord(item: ChildItem): DnsRecord {
|
||||
const body = item.body
|
||||
const alias = body.alias && typeof body.alias === 'object' ? body.alias as {dnsName?: unknown; hostedZoneId?: unknown} : null
|
||||
return {
|
||||
id: item.id,
|
||||
name: String(body.name ?? ''),
|
||||
type: String(body.type ?? ''),
|
||||
ttl: typeof body.ttl === 'number' ? body.ttl : null,
|
||||
values: stringList(body.values),
|
||||
alias: alias ? {dnsName: String(alias.dnsName ?? ''), hostedZoneId: String(alias.hostedZoneId ?? '')} : null,
|
||||
setIdentifier: typeof body.setIdentifier === 'string' ? body.setIdentifier : null,
|
||||
managed: body.managed === true,
|
||||
editable: body.editable === true,
|
||||
}
|
||||
}
|
||||
|
||||
function relativeName(name: string, zone: string): string {
|
||||
if (name === zone) return '@'
|
||||
return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
return value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') : value
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
@@ -13,12 +13,12 @@ export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean})
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState(SIMULATORS[0])
|
||||
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 [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 [templateName, setTemplateName] = useState('')
|
||||
const [templateData, setTemplateData] = useState('{"name":"Floci"}')
|
||||
const [templateData, setTemplateData] = useState('{"name":"TheCloud"}')
|
||||
const [editingTemplate, setEditingTemplate] = useState('')
|
||||
const [templateSubject, setTemplateSubject] = 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
|
||||
|
||||
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">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>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
Earth,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
@@ -31,6 +34,9 @@ import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.s
|
||||
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
|
||||
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.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 cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
@@ -87,6 +93,9 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
@@ -131,6 +140,9 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
serverless: lambdaIcon,
|
||||
storage: s3Icon,
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function runtimeClassFor(status: CloudStatus | undefined, loading: boolea
|
||||
export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
if (status?.runtime === 'reachable') return 'Connected through Cloud Proxy API'
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ export function cloudName(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 === 'azure') return 'Storage is backed by Floci-AZ through the same normalized storage contract.'
|
||||
return 'Storage is backed by Floci-GCP through the same normalized storage contract.'
|
||||
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 TheCloud\'s Azure runtime 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 {
|
||||
@@ -63,7 +63,7 @@ export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string
|
||||
}
|
||||
|
||||
export function runtimeName(cloud: CloudProvider): string {
|
||||
if (cloud === 'aws') return 'Floci AWS Core'
|
||||
if (cloud === 'azure') return 'Floci-AZ'
|
||||
return 'Floci-GCP'
|
||||
if (cloud === 'aws') return 'TheCloud AWS runtime'
|
||||
if (cloud === 'azure') return 'TheCloud Azure runtime'
|
||||
return 'TheCloud GCP runtime'
|
||||
}
|
||||
|
||||
@@ -243,13 +243,13 @@ export function EKSPage() {
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Cannot load clusters"
|
||||
description="EKS did not respond from the Floci endpoint."
|
||||
description="EKS did not respond from TheCloud's runtime."
|
||||
/>
|
||||
) : clusters.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No EKS clusters"
|
||||
description="No Kubernetes clusters were returned by Floci."
|
||||
description="No Kubernetes clusters were returned by TheCloud's runtime."
|
||||
/>
|
||||
) : (
|
||||
clusters.map((cluster) => (
|
||||
|
||||
@@ -729,7 +729,7 @@ export function SecretsManagerPage() {
|
||||
<h3>Secrets</h3>
|
||||
</div>
|
||||
{query.isError ? (
|
||||
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from the Floci endpoint." />
|
||||
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from TheCloud's runtime." />
|
||||
) : query.isLoading ? (
|
||||
<div className="empty"><p>Loading secrets…</p></div>
|
||||
) : secrets.length === 0 ? (
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
--status-error: #ff9a9a;
|
||||
|
||||
--accent: #6cc4ff;
|
||||
--chart-line: #2f9ce0;
|
||||
--accent-tint: rgba(108, 196, 255, 0.16);
|
||||
--accent-tint-faint: rgba(108, 196, 255, 0.07);
|
||||
--accent-tint-border: rgba(108, 196, 255, 0.35);
|
||||
@@ -148,6 +149,7 @@
|
||||
--status-error: #bd3f30;
|
||||
|
||||
--accent: var(--coral);
|
||||
--chart-line: #d9573f;
|
||||
--accent-tint: rgba(237, 111, 92, 0.14);
|
||||
--accent-tint-faint: rgba(237, 111, 92, 0.07);
|
||||
--accent-tint-border: rgba(237, 111, 92, 0.3);
|
||||
@@ -244,6 +246,7 @@
|
||||
--status-error: #ff8da1;
|
||||
|
||||
--accent: #4eff96;
|
||||
--chart-line: #2fae67;
|
||||
--accent-tint: rgba(78, 255, 150, 0.14);
|
||||
--accent-tint-faint: rgba(78, 255, 150, 0.07);
|
||||
--accent-tint-border: rgba(78, 255, 150, 0.34);
|
||||
@@ -4395,6 +4398,21 @@ pre,
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cognito-check small { flex-basis: 100%; padding-left: 24px; margin-top: -4px; }
|
||||
.cognito-check:has(small) { flex-wrap: wrap; row-gap: 0; }
|
||||
|
||||
.cognito-flow { display: inline-block; margin: 0 6px 4px 0; }
|
||||
|
||||
.cognito-client-urls {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cognito-client-urls > div { align-self: end; }
|
||||
|
||||
.delete-impact {
|
||||
color: var(--status-warn);
|
||||
font-size: 13px;
|
||||
@@ -6474,3 +6492,108 @@ button.console-service-card {
|
||||
.lightsail-port-form { align-items: end; margin: 16px 0; }
|
||||
.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
|
||||
@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
|
||||
|
||||
.dns-name-servers {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0 16px 12px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dns-name-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dns-name-input .input { flex: 1; min-width: 0; }
|
||||
.dynamic-field .dns-zone-suffix {
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dns-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.dns-values { overflow-wrap: anywhere; }
|
||||
.dns-badge { margin-left: 8px; }
|
||||
tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
|
||||
.cdn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.cdn-viewer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.cdn-viewer small { flex-basis: 100%; }
|
||||
|
||||
/* CloudWatch: alarm states carry a dot in the status colour next to their label. */
|
||||
.badge.ok::before, .badge.alarm::before, .badge.insufficient-data::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.badge.ok::before { background: var(--status-ok); }
|
||||
.badge.alarm::before { background: var(--status-error); }
|
||||
.badge.insufficient-data::before { background: var(--text-3); }
|
||||
.badge.alarm { border: 1px solid var(--status-error); }
|
||||
|
||||
.cloudwatch-state-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cloudwatch-facts { padding: 0 16px 12px; }
|
||||
.cloudwatch-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; padding: 0 16px 12px; }
|
||||
.cloudwatch-range { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 8px; }
|
||||
.cloudwatch-range .input { width: auto; }
|
||||
.cloudwatch-chart { padding: 4px 16px 12px; }
|
||||
.cloudwatch-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cloudwatch-chart-head .cloudwatch-range { padding: 0; }
|
||||
.cloudwatch-chart-title { margin: 0 0 6px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
|
||||
.cognito-row-actions-cell { text-align: right; }
|
||||
|
||||
.metric-chart { position: relative; width: 100%; min-width: 0; }
|
||||
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
|
||||
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
.metric-chart--empty { padding: 24px 0; }
|
||||
.metric-chart-grid { stroke: var(--border-faint); stroke-width: 1; }
|
||||
.metric-chart-axis { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-line { fill: none; stroke: var(--chart-line); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.metric-chart-marker { fill: var(--chart-line); stroke: var(--surface); stroke-width: 2; }
|
||||
.metric-chart-marker--active { stroke-width: 2.5; }
|
||||
.metric-chart-threshold { stroke: var(--text-3); stroke-width: 1.5; stroke-dasharray: 5 4; }
|
||||
.metric-chart-crosshair { stroke: var(--text-3); stroke-width: 1; }
|
||||
.metric-chart-label { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-label--strong { fill: var(--text-bright, var(--text)); font-weight: 600; }
|
||||
.metric-chart-tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--elevated);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-chart-tooltip strong { font-size: 14px; color: var(--text-bright, var(--text)); }
|
||||
.metric-chart-key { display: inline-block; width: 12px; height: 2px; margin: 0 6px 3px 0; background: var(--chart-line); vertical-align: middle; }
|
||||
.metric-chart-table { margin-top: 8px; }
|
||||
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
||||
.metric-chart-table table { margin-top: 8px; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {formatValue, niceTicks} from './metricChart'
|
||||
|
||||
test('picks about five round ticks that include zero', () => {
|
||||
expect(niceTicks(0, 7)).toEqual([0, 2, 4, 6, 8])
|
||||
expect(niceTicks(0, 100)).toEqual([0, 25, 50, 75, 100])
|
||||
expect(niceTicks(-3, 3)).toEqual([-4, -2, 0, 2, 4])
|
||||
})
|
||||
|
||||
test('formats values compactly from a thousand up', () => {
|
||||
expect([formatValue(7), formatValue(0.25), formatValue(1500), formatValue(2_000_000), formatValue(12.5)]).toEqual(['7', '0.25', '1.5K', '2M', '12.5'])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Time ranges offered above CloudWatch charts. */
|
||||
export const RANGES = [{label: '1 hour', minutes: 60}, {label: '3 hours', minutes: 180}, {label: '24 hours', minutes: 1440}, {label: '7 days', minutes: 10080}]
|
||||
|
||||
/** About five round ticks covering [min, max], always including zero when the range does. */
|
||||
export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
const span = max - min
|
||||
const raw = span / (count - 1)
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
||||
const step = [1, 2, 2.5, 5, 10].map((factor) => factor * magnitude).find((candidate) => candidate >= raw) ?? 10 * magnitude
|
||||
const start = Math.floor(min / step) * step
|
||||
const end = Math.ceil(max / step) * step
|
||||
const ticks: number[] = []
|
||||
for (let value = start; value <= end + step / 2; value += step) ticks.push(Number(value.toPrecision(12)))
|
||||
return ticks
|
||||
}
|
||||
|
||||
export function timeTicks(start: number, end: number, count: number): number[] {
|
||||
if (end <= start) return [start]
|
||||
const step = (end - start) / count
|
||||
return Array.from({length: count + 1}, (_, index) => start + index * step)
|
||||
}
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
const abs = Math.abs(value)
|
||||
if (abs >= 1e9) return `${trim(value / 1e9)}B`
|
||||
if (abs >= 1e6) return `${trim(value / 1e6)}M`
|
||||
if (abs >= 1e3) return `${trim(value / 1e3)}K`
|
||||
return trim(value)
|
||||
}
|
||||
|
||||
function trim(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(Math.abs(value) < 10 ? 2 : 1).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
@@ -302,6 +302,64 @@ export interface UserPoolSignInTestInput {
|
||||
password: string
|
||||
}
|
||||
|
||||
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
|
||||
rangeMinutes?: number
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
label: string
|
||||
statistic: string
|
||||
periodSeconds: number
|
||||
points: Array<{timestamp: string; value: number}>
|
||||
}
|
||||
|
||||
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||
|
||||
export interface AlarmHistoryItem {
|
||||
timestamp: string | null
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string | null
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||
|
||||
export interface UserPoolClientSettings {
|
||||
clientId: string
|
||||
clientName: string
|
||||
hasSecret: boolean
|
||||
explicitAuthFlows: string[]
|
||||
callbackUrls: string[]
|
||||
logoutUrls: string[]
|
||||
oauthFlows: UserPoolOAuthFlow[]
|
||||
oauthScopes: string[]
|
||||
identityProviders: string[]
|
||||
}
|
||||
|
||||
export type UpdateUserPoolClientOAuthInput = Pick<UserPoolClientSettings, 'callbackUrls' | 'logoutUrls' | 'oauthFlows' | 'oauthScopes' | 'identityProviders'>
|
||||
|
||||
export type UserPoolSignInTestResult =
|
||||
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
|
||||
| {outcome: 'challenge'; challengeName: string}
|
||||
|
||||
@@ -27,7 +27,9 @@ export type KubernetesActionName =
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -88,6 +90,8 @@ export interface ServiceSchema {
|
||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
|
||||
@@ -19,6 +19,9 @@ importers:
|
||||
'@aws-sdk/client-cloudformation':
|
||||
specifier: ^3.1111.0
|
||||
version: 3.1111.0
|
||||
'@aws-sdk/client-cloudfront':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-cloudwatch':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1146.0
|
||||
@@ -88,6 +91,9 @@ importers:
|
||||
'@aws-sdk/client-rds-data':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1144.0
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
@@ -301,6 +307,10 @@ packages:
|
||||
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudfront@3.1156.0':
|
||||
resolution: {integrity: sha512-z4V/ecv6b5uGwVVMaE8NbczaPVfM2HwbZAGUFp9XDjm+Q8WoFrF0vRlWaRWpW+rox4yPZc4tSo+cv8drud+r8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
||||
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -393,6 +403,10 @@ packages:
|
||||
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-route-53@3.1156.0':
|
||||
resolution: {integrity: sha512-1IwDSboTwUovZhuyDBhjAgPxFeIdy5+XCmZNKYFdg0XJax27qx4Ir7/m5CA60pdAHWBTMpUKm0jtyNEOlZnsuA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-s3@3.1076.0':
|
||||
resolution: {integrity: sha512-gqI0asnN73cm5W71rixc7NYvOr3+/tuyzMhG2hW+4aC5q5XcHP0JAeF/Dpac6vEsQhJtmlhXJ0oDCsXrFFnvUQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -708,6 +722,10 @@ packages:
|
||||
resolution: {integrity: sha512-37S4hocQzAoCDKVPk+uFRZZlIivnDNAOkObDp59tjGau60amEJVSh8SQnSGxakRDwoMpDCThIDI2+XFFrKD+tA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.28':
|
||||
resolution: {integrity: sha512-93l3lB5ngw0XQDGvuK/R9R3UAN4+xeYgLOW8E6nzldlNhu0E8vfkYMTlh5km643i9F193b5ZO21wOWWT47RxHw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
||||
resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2905,6 +2923,17 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cloudfront@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cloudwatch-logs@3.1131.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -3174,6 +3203,18 @@ snapshots:
|
||||
'@smithy/types': 4.15.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-route-53@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/middleware-sdk-route53': 3.972.28
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-s3@3.1076.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
@@ -3578,55 +3619,55 @@ snapshots:
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.60':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.69':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.77':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.78':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.79':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.46
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3990,6 +4031,12 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.28':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.59':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
@@ -4008,10 +4055,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.28':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -4019,10 +4066,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.36':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -4030,10 +4077,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.43':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -4041,10 +4088,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.44':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -4052,10 +4099,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.45':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -4063,10 +4110,10 @@ snapshots:
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.46':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.47
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -4113,55 +4160,55 @@ snapshots:
|
||||
|
||||
'@aws-sdk/token-providers@3.1080.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1096.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.36
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1111.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1116.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1129.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.45
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1138.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/nested-clients': 3.997.46
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
|
||||