From 2ab601e4989215f66ff9229c849f2770f5dcf9d9 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Sep 2024 19:37:35 -0400 Subject: [PATCH] Cluster v2 and Redis --- examples/aws-cluster/sst-env.d.ts | 7 +- examples/aws-cluster/sst.config.ts | 3 +- examples/aws-redis/index.ts | 30 + examples/aws-redis/package.json | 19 + examples/aws-redis/sst-env.d.ts | 25 + examples/aws-redis/sst.config.ts | 26 + platform/src/components/aws/cluster-v1.ts | 929 ++++++++++++++++++++++ platform/src/components/aws/cluster.ts | 55 +- platform/src/components/aws/index.ts | 1 + platform/src/components/aws/redis.ts | 347 ++++++++ platform/src/components/aws/service-v1.ts | 867 ++++++++++++++++++++ platform/src/components/aws/service.ts | 116 ++- platform/src/components/aws/vpc-v1.ts | 1 - platform/src/components/aws/vpc.ts | 32 +- platform/src/components/component.ts | 11 +- www/astro.config.mjs | 6 +- www/generate.ts | 3 + 17 files changed, 2414 insertions(+), 64 deletions(-) create mode 100644 examples/aws-redis/index.ts create mode 100644 examples/aws-redis/package.json create mode 100644 examples/aws-redis/sst-env.d.ts create mode 100644 examples/aws-redis/sst.config.ts create mode 100644 platform/src/components/aws/cluster-v1.ts create mode 100644 platform/src/components/aws/redis.ts create mode 100644 platform/src/components/aws/service-v1.ts diff --git a/examples/aws-cluster/sst-env.d.ts b/examples/aws-cluster/sst-env.d.ts index 41b164426..99616cc2b 100644 --- a/examples/aws-cluster/sst-env.d.ts +++ b/examples/aws-cluster/sst-env.d.ts @@ -1,6 +1,8 @@ +/* This file is auto-generated by SST. Do not edit. */ /* tslint:disable */ /* eslint-disable */ import "sst" +export {} declare module "sst" { export interface Resource { "MyBucket": { @@ -8,9 +10,12 @@ declare module "sst" { "type": "sst.aws.Bucket" } "MyService": { + "service": string "type": "sst.aws.Service" "url": string } + "MyVpc": { + "type": "sst.aws.Vpc" + } } } -export {} diff --git a/examples/aws-cluster/sst.config.ts b/examples/aws-cluster/sst.config.ts index f0bd87450..aa3faf970 100644 --- a/examples/aws-cluster/sst.config.ts +++ b/examples/aws-cluster/sst.config.ts @@ -13,10 +13,9 @@ export default $config({ public: true, }); - const vpc = new sst.aws.Vpc("MyVpc", { nat: "managed" }); + const vpc = new sst.aws.Vpc("MyVpc"); const cluster = new sst.aws.Cluster("MyCluster", { vpc }); - cluster.addService("MyService", { public: { ports: [{ listen: "80/http" }], diff --git a/examples/aws-redis/index.ts b/examples/aws-redis/index.ts new file mode 100644 index 000000000..07da57201 --- /dev/null +++ b/examples/aws-redis/index.ts @@ -0,0 +1,30 @@ +import { Cluster } from "ioredis"; +import { Resource } from "sst"; + +const client = new Cluster( + [ + { + host: Resource.MyRedis.host, + port: Resource.MyRedis.port, + }, + ], + { + redisOptions: { + tls: { + checkServerIdentity: () => undefined, + }, + username: Resource.MyRedis.username, + password: Resource.MyRedis.password, + }, + } +); + +export async function handler() { + await client.set("foo", `bar-${Date.now()}`); + return { + statusCode: 200, + body: JSON.stringify({ + foo: await client.get("foo"), + }), + }; +} diff --git a/examples/aws-redis/package.json b/examples/aws-redis/package.json new file mode 100644 index 000000000..32a28f8ec --- /dev/null +++ b/examples/aws-redis/package.json @@ -0,0 +1,19 @@ +{ + "name": "aws-redis", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "index.js", + "scripts": { + "deploy": "go run ../../cmd/sst deploy", + "remove": "go run ../../cmd/sst remove", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "ioredis": "^5.4.1", + "sst": "latest" + } +} diff --git a/examples/aws-redis/sst-env.d.ts b/examples/aws-redis/sst-env.d.ts new file mode 100644 index 000000000..522504d36 --- /dev/null +++ b/examples/aws-redis/sst-env.d.ts @@ -0,0 +1,25 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +import "sst" +export {} +declare module "sst" { + export interface Resource { + "MyApp": { + "name": string + "type": "sst.aws.Function" + "url": string + } + "MyRedis": { + "clusterArn": string + "host": string + "password": string + "port": number + "type": "sst.aws.Redis" + "username": string + } + "MyVpc": { + "type": "sst.aws.Vpc" + } + } +} diff --git a/examples/aws-redis/sst.config.ts b/examples/aws-redis/sst.config.ts new file mode 100644 index 000000000..982ea9a32 --- /dev/null +++ b/examples/aws-redis/sst.config.ts @@ -0,0 +1,26 @@ +/// + +export default $config({ + app(input) { + return { + name: "aws-redis", + removal: input?.stage === "production" ? "retain" : "remove", + home: "aws", + }; + }, + async run() { + // NAT Gateways are required for Lambda functions + const vpc = new sst.aws.Vpc("MyVpc", { nat: "managed" }); + const redis = new sst.aws.Redis("MyRedis", { vpc }); + const app = new sst.aws.Function("MyApp", { + handler: "index.handler", + url: true, + vpc, + link: [redis], + }); + + return { + app: app.url, + }; + }, +}); diff --git a/platform/src/components/aws/cluster-v1.ts b/platform/src/components/aws/cluster-v1.ts new file mode 100644 index 000000000..e9a2501f5 --- /dev/null +++ b/platform/src/components/aws/cluster-v1.ts @@ -0,0 +1,929 @@ +import { ComponentResourceOptions } from "@pulumi/pulumi"; +import { Component, Transform, transform } from "../component.js"; +import { Input } from "../input.js"; +import { Dns } from "../dns.js"; +import { FunctionArgs } from "./function.js"; +import { Service as ServiceV1 } from "./service-v1.js"; +import { RETENTION } from "./logging.js"; +import { cloudwatch, ec2, ecs, iam, lb } from "@pulumi/aws"; +import { ImageArgs } from "@pulumi/docker-build"; + +export const supportedCpus = { + "0.25 vCPU": 256, + "0.5 vCPU": 512, + "1 vCPU": 1024, + "2 vCPU": 2048, + "4 vCPU": 4096, + "8 vCPU": 8192, + "16 vCPU": 16384, +}; + +export const supportedMemories = { + "0.25 vCPU": { + "0.5 GB": 512, + "1 GB": 1024, + "2 GB": 2048, + }, + "0.5 vCPU": { + "1 GB": 1024, + "2 GB": 2048, + "3 GB": 3072, + "4 GB": 4096, + }, + "1 vCPU": { + "2 GB": 2048, + "3 GB": 3072, + "4 GB": 4096, + "5 GB": 5120, + "6 GB": 6144, + "7 GB": 7168, + "8 GB": 8192, + }, + "2 vCPU": { + "4 GB": 4096, + "5 GB": 5120, + "6 GB": 6144, + "7 GB": 7168, + "8 GB": 8192, + "9 GB": 9216, + "10 GB": 10240, + "11 GB": 11264, + "12 GB": 12288, + "13 GB": 13312, + "14 GB": 14336, + "15 GB": 15360, + "16 GB": 16384, + }, + "4 vCPU": { + "8 GB": 8192, + "9 GB": 9216, + "10 GB": 10240, + "11 GB": 11264, + "12 GB": 12288, + "13 GB": 13312, + "14 GB": 14336, + "15 GB": 15360, + "16 GB": 16384, + "17 GB": 17408, + "18 GB": 18432, + "19 GB": 19456, + "20 GB": 20480, + "21 GB": 21504, + "22 GB": 22528, + "23 GB": 23552, + "24 GB": 24576, + "25 GB": 25600, + "26 GB": 26624, + "27 GB": 27648, + "28 GB": 28672, + "29 GB": 29696, + "30 GB": 30720, + }, + "8 vCPU": { + "16 GB": 16384, + "20 GB": 20480, + "24 GB": 24576, + "28 GB": 28672, + "32 GB": 32768, + "36 GB": 36864, + "40 GB": 40960, + "44 GB": 45056, + "48 GB": 49152, + "52 GB": 53248, + "56 GB": 57344, + "60 GB": 61440, + }, + "16 vCPU": { + "32 GB": 32768, + "40 GB": 40960, + "48 GB": 49152, + "56 GB": 57344, + "64 GB": 65536, + "72 GB": 73728, + "80 GB": 81920, + "88 GB": 90112, + "96 GB": 98304, + "104 GB": 106496, + "112 GB": 114688, + "120 GB": 122880, + }, +}; + +type Port = `${number}/${"http" | "https" | "tcp" | "udp" | "tcp_udp" | "tls"}`; + +export interface ClusterArgs { + /** + * The VPC to use for the cluster. + * + * @example + * ```js + * { + * vpc: { + * id: "vpc-0d19d2b8ca2b268a1", + * publicSubnets: ["subnet-0b6a2b73896dc8c4c", "subnet-021389ebee680c2f0"], + * privateSubnets: ["subnet-0db7376a7ad4db5fd ", "subnet-06fc7ee8319b2c0ce"], + * securityGroups: ["sg-0399348378a4c256c"], + * } + * } + * ``` + * + * Or create a `Vpc` component. + * + * ```js title="sst.config.ts" + * const myVpc = new sst.aws.Vpc("MyVpc"); + * ``` + * + * And pass it in. + * + * ```js + * { + * vpc: myVpc + * } + * ``` + */ + vpc: Input<{ + /** + * The ID of the VPC. + */ + id: Input; + /** + * A list of public subnet IDs in the VPC. If a service has public ports configured, + * its load balancer will be placed in the public subnets. + */ + publicSubnets: Input[]>; + /** + * A list of private subnet IDs in the VPC. The service will be placed in the private + * subnets. + */ + privateSubnets: Input[]>; + /** + * A list of VPC security group IDs for the service. + */ + securityGroups: Input[]>; + }>; + /** + * [Transform](/docs/components#transform) how this component creates its underlying + * resources. + */ + transform?: { + /** + * Transform the ECS Cluster resource. + */ + cluster?: Transform; + }; +} + +export interface ClusterServiceArgs { + /** + * Configure how this component works in `sst dev`. + * + * :::note + * In `sst dev` your service is run locally; it's not deployed. + * ::: + * + * Instead of deploying your service, this starts it locally. It's run + * as a separate process in the `sst dev` multiplexer. Read more about + * [`sst dev`](/docs/reference/cli/#dev). + */ + dev?: { + /** + * The `url` when this is running in dev mode. + * + * Since this component is not deployed in `sst dev`, there is no real URL. But if you are + * using this component's `url` or linking to this component's `url`, it can be useful to + * have a placeholder URL. It avoids having to handle it being `undefined`. + * @default `"http://url-unavailable-in-dev.mode"` + */ + url?: Input; + /** + * The command that `sst dev` runs to start this in dev mode. This is the command you run + * when you want to run your service locally. + */ + command?: Input; + /** + * Configure if you want to automatically start this when `sst dev` starts. You can still + * start it manually later. + * @default `true` + */ + autostart?: Input; + /** + * Change the directory from where the `command` is run. + * @default Uses the `image.dockerfile` path + */ + directory?: Input; + }; + /** + * Configure the docker build command for building the image. + * + * Prior to building the image, SST will automatically add the `.sst` directory + * to the `.dockerignore` if not already present. + * + * @default `{}` + * @example + * ```js + * { + * image: { + * context: "./app", + * dockerfile: "Dockerfile", + * args: { + * MY_VAR: "value" + * } + * } + * } + * ``` + */ + image?: Input<{ + /** + * The path to the [Docker build context](https://docs.docker.com/build/building/context/#local-context). The path is relative to your project's `sst.config.ts`. + * @default `"."` + * @example + * + * To change where the docker build context is located. + * + * ```js + * { + * context: "./app" + * } + * ``` + */ + context?: Input; + /** + * The path to the [Dockerfile](https://docs.docker.com/reference/cli/docker/image/build/#file). + * The path is relative to the build `context`. + * @default `"Dockerfile"` + * @example + * To use a different Dockerfile. + * ```js + * { + * dockerfile: "Dockerfile.prod" + * } + * ``` + */ + dockerfile?: Input; + /** + * Key-value pairs of [build args](https://docs.docker.com/build/guide/build-args/) to pass to the docker build command. + * @example + * ```js + * { + * args: { + * MY_VAR: "value" + * } + * } + * ``` + */ + args?: Input>>; + }>; + /** + * Configure a public endpoint for the service. When configured, a load balancer + * will be created to route traffic to the containers. By default, the endpoint is an + * autogenerated load balancer URL. + * + * You can also add a custom domain for the public endpoint. + * + * @example + * + * ```js + * { + * public: { + * domain: "example.com", + * ports: [ + * { listen: "80/http" }, + * { listen: "443/https", forward: "80/http" } + * ] + * } + * } + * ``` + */ + public?: Input<{ + /** + * Set a custom domain for your public endpoint. + * + * Automatically manages domains hosted on AWS Route 53, Cloudflare, and Vercel. For other + * providers, you'll need to pass in a `cert` that validates domain ownership and add the + * DNS records. + * + * :::tip + * Built-in support for AWS Route 53, Cloudflare, and Vercel. And manual setup for other + * providers. + * ::: + * + * @example + * + * By default this assumes the domain is hosted on Route 53. + * + * ```js + * { + * domain: "example.com" + * } + * ``` + * + * For domains hosted on Cloudflare. + * + * ```js + * { + * domain: { + * name: "example.com", + * dns: sst.cloudflare.dns() + * } + * } + * ``` + */ + domain?: Input< + | string + | { + /** + * The custom domain you want to use. + * + * @example + * ```js + * { + * domain: { + * name: "example.com" + * } + * } + * ``` + * + * Can also include subdomains based on the current stage. + * + * ```js + * { + * domain: { + * name: `${$app.stage}.example.com` + * } + * } + * ``` + */ + name: Input; + /** + * The ARN of an ACM (AWS Certificate Manager) certificate that proves ownership of the + * domain. By default, a certificate is created and validated automatically. + * + * :::tip + * You need to pass in a `cert` for domains that are not hosted on supported `dns` providers. + * ::: + * + * To manually set up a domain on an unsupported provider, you'll need to: + * + * 1. [Validate that you own the domain](https://docs.aws.amazon.com/acm/latest/userguide/domain-ownership-validation.html) by creating an ACM certificate. You can either validate it by setting a DNS record or by verifying an email sent to the domain owner. + * 2. Once validated, set the certificate ARN as the `cert` and set `dns` to `false`. + * 3. Add the DNS records in your provider to point to the load balancer endpoint. + * + * @example + * ```js + * { + * domain: { + * name: "example.com", + * dns: false, + * cert: "arn:aws:acm:us-east-1:112233445566:certificate/3a958790-8878-4cdc-a396-06d95064cf63" + * } + * } + * ``` + */ + cert?: Input; + /** + * The DNS provider to use for the domain. Defaults to the AWS. + * + * Takes an adapter that can create the DNS records on the provider. This can automate + * validating the domain and setting up the DNS routing. + * + * Supports Route 53, Cloudflare, and Vercel adapters. For other providers, you'll need + * to set `dns` to `false` and pass in a certificate validating ownership via `cert`. + * + * @default `sst.aws.dns` + * + * @example + * + * Specify the hosted zone ID for the Route 53 domain. + * + * ```js + * { + * domain: { + * name: "example.com", + * dns: sst.aws.dns({ + * zone: "Z2FDTNDATAQYW2" + * }) + * } + * } + * ``` + * + * Use a domain hosted on Cloudflare, needs the Cloudflare provider. + * + * ```js + * { + * domain: { + * name: "example.com", + * dns: sst.cloudflare.dns() + * } + * } + * ``` + * + * Use a domain hosted on Vercel, needs the Vercel provider. + * + * ```js + * { + * domain: { + * name: "example.com", + * dns: sst.vercel.dns() + * } + * } + * ``` + */ + dns?: Input; + } + >; + /** + * Configure the mapping for the ports the public endpoint listens to and forwards to + * the service. + * This supports two types of protocols: + * + * 1. Application Layer Protocols: `http` and `https`. This'll create an [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). + * 2. Network Layer Protocols: `tcp`, `udp`, `tcp_udp`, and `tls`. This'll create a [Network Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html). + * + * :::note + * If you are listening on `https` or `tls`, you need to specify a custom `public.domain`. + * ::: + * + * You can **not** configure both application and network layer protocols for the same + * service. + * + * @example + * Here we are listening on port `80` and forwarding it to the service on port `8080`. + * ```js + * { + * public: { + * ports: [ + * { listen: "80/http", forward: "8080/http" } + * ] + * } + * } + * ``` + * + * The `forward` port and protocol defaults to the `listen` port and protocol. So in this + * case both are `80/http`. + * + * ```js + * { + * public: { + * ports: [ + * { listen: "80/http" } + * ] + * } + * } + * ``` + */ + ports: Input< + { + /** + * The port and protocol the service listens on. Uses the format `{port}/{protocol}`. + */ + listen: Input; + /** + * The port and protocol of the container the service forwards the traffic to. Uses the + * format `{port}/{protocol}`. + * @default The same port and protocol as `listen`. + */ + forward?: Input; + }[] + >; + }>; + /** + * The CPU architecture of the container in this service. + * @default `"x86_64"` + * @example + * ```js + * { + * architecture: "arm64" + * } + * ``` + */ + architecture?: Input<"x86_64" | "arm64">; + /** + * The amount of CPU allocated to the container in this service. + * + * :::note + * [View the valid combinations](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-tasks-services.html#fargate-tasks-size) of CPU and memory. + * ::: + * + * @default `"0.25 vCPU"` + * @example + * ```js + * { + * cpu: "1 vCPU" + * } + *``` + */ + cpu?: keyof typeof supportedCpus; + /** + * The amount of memory allocated to the container in this service. + * + * :::note + * [View the valid combinations](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-tasks-services.html#fargate-tasks-size) of CPU and memory. + * ::: + * + * @default `"0.5 GB"` + * + * @example + * ```js + * { + * memory: "2 GB" + * } + *``` + */ + memory?: `${number} GB`; + /** + * The amount of ephemeral storage (in GB) allocated to a container in this service. + * + * @default `"21 GB"` + * + * @example + * ```js + * { + * storage: "100 GB" + * } + * ``` + */ + storage?: `${number} GB`; + /** + * [Link resources](/docs/linking/) to your service. This will: + * + * 1. Grant the permissions needed to access the resources. + * 2. Allow you to access it in your app using the [SDK](/docs/reference/sdk/). + * + * @example + * + * Takes a list of components to link to the service. + * + * ```js + * { + * link: [bucket, stripeKey] + * } + * ``` + */ + link?: FunctionArgs["link"]; + /** + * Permissions and the resources that the service needs to access. These permissions are + * used to create the service's [task role](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html). + * + * :::tip + * If you `link` the service to a resource, the permissions to access it are + * automatically added. + * ::: + * + * @example + * Allow the service to read and write to an S3 bucket called `my-bucket`. + * + * ```js + * { + * permissions: [ + * { + * actions: ["s3:GetObject", "s3:PutObject"], + * resources: ["arn:aws:s3:::my-bucket/*"] + * }, + * ] + * } + * ``` + * + * Allow the service to perform all actions on an S3 bucket called `my-bucket`. + * + * ```js + * { + * permissions: [ + * { + * actions: ["s3:*"], + * resources: ["arn:aws:s3:::my-bucket/*"] + * }, + * ] + * } + * ``` + * + * Granting the service permissions to access all resources. + * + * ```js + * { + * permissions: [ + * { + * actions: ["*"], + * resources: ["*"] + * }, + * ] + * } + * ``` + */ + permissions?: FunctionArgs["permissions"]; + /** + * Key-value pairs of values that are set as [container environment variables](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/taskdef-envfiles.html). + * The keys need to: + * - Start with a letter + * - Be at least 2 characters long + * - Contain only letters, numbers, or underscores + * + * @example + * + * ```js + * { + * environment: { + * DEBUG: "true" + * } + * } + * ``` + */ + environment?: FunctionArgs["environment"]; + /** + * Configure the service's logs in CloudWatch. + * @default `{ retention: "forever" }` + * @example + * ```js + * { + * logging: { + * retention: "1 week" + * } + * } + * ``` + */ + logging?: Input<{ + /** + * The duration the logs are kept in CloudWatch. + * @default `"forever"` + */ + retention?: Input; + }>; + /** + * Configure the service to automatically scale up or down based on the CPU or memory + * utilization of a container. By default, scaling is disabled and the service will run + * in a single container. + * + * @default `{ min: 1, max: 1 }` + * + * @example + * ```js + * { + * scaling: { + * min: 4, + * max: 16, + * cpuUtilization: 50, + * memoryUtilization: 50 + * } + * } + * ``` + */ + scaling?: Input<{ + /** + * The minimum number of containers to scale down to. + * @default `1` + * @example + * ```js + * { + * scaling: { + * min: 4 + * } + * } + *``` + */ + min?: Input; + /** + * The maximum number of containers to scale up to. + * @default `1` + * @example + * ```js + * { + * scaling: { + * max: 16 + * } + * } + *``` + */ + max?: Input; + /** + * The target CPU utilization percentage to scale up or down. It'll scale up + * when the CPU utilization is above the target and scale down when it's below the target. + * @default `70` + * @example + * ```js + * { + * scaling: { + * cpuUtilization: 50 + * } + * } + *``` + */ + cpuUtilization?: Input; + /** + * The target memory utilization percentage to scale up or down. It'll scale up + * when the memory utilization is above the target and scale down when it's below the target. + * @default `70` + * @example + * ```js + * { + * scaling: { + * memoryUtilization: 50 + * } + * } + *``` + */ + memoryUtilization?: Input; + }>; + /** + * [Transform](/docs/components#transform) how this component creates its underlying + * resources. + */ + transform?: { + /** + * Transform the Docker Image resource. + */ + image?: Transform; + /** + * Transform the ECS Service resource. + */ + service?: Transform; + /** + * Transform the ECS Task IAM Role resource. + */ + taskRole?: Transform; + /** + * Transform the ECS Task Definition resource. + */ + taskDefinition?: Transform; + /** + * Transform the AWS Load Balancer resource. + */ + loadBalancer?: Transform; + /** + * Transform the AWS Security Group resource for the Load Balancer. + */ + loadBalancerSecurityGroup?: Transform; + /** + * Transform the AWS Load Balancer listener resource. + */ + listener?: Transform; + /** + * Transform the AWS Load Balancer target group resource. + */ + target?: Transform; + /** + * Transform the CloudWatch log group resource. + */ + logGroup?: Transform; + }; +} + +/** + * The `Cluster` component lets you create a cluster of containers and add services to them. + * It uses [Amazon ECS](https://aws.amazon.com/ecs/) on [AWS Fargate](https://aws.amazon.com/fargate/). + * + * For existing usage, rename `sst.aws.Cluster` to `sst.aws.Cluster.v1`. For new Clusters, use + * the latest [`Cluster`](/docs/component/aws/cluster) component instead. + * + * :::caution + * This component has been deprecated . + * ::: + * + * @example + * + * #### Create a Cluster + * + * ```ts title="sst.config.ts" + * const vpc = new sst.aws.Vpc("MyVpc"); + * const cluster = new sst.aws.Cluster.v1("MyCluster", { vpc }); + * ``` + * + * #### Add a service + * + * ```ts title="sst.config.ts" + * cluster.addService("MyService"); + * ``` + * + * #### Add a public custom domain + * + * ```ts title="sst.config.ts" + * cluster.addService("MyService", { + * public: { + * domain: "example.com", + * ports: [ + * { listen: "80/http" }, + * { listen: "443/https", forward: "80/http" }, + * ] + * } + * }); + * ``` + * + * #### Enable auto-scaling + * + * ```ts title="sst.config.ts" + * cluster.addService("MyService", { + * scaling: { + * min: 4, + * max: 16, + * cpuUtilization: 50, + * memoryUtilization: 50, + * } + * }); + * ``` + * + * #### Link resources + * + * [Link resources](/docs/linking/) to your service. This will grant permissions + * to the resources and allow you to access it in your app. + * + * ```ts {4} title="sst.config.ts" + * const bucket = new sst.aws.Bucket("MyBucket"); + * + * cluster.addService("MyService", { + * link: [bucket], + * }); + * ``` + * + * If your service is written in Node.js, you can use the [SDK](/docs/reference/sdk/) + * to access the linked resources. + * + * ```ts title="app.ts" + * import { Resource } from "sst"; + * + * console.log(Resource.MyBucket.name); + * ``` + */ +export class Cluster extends Component { + private args: ClusterArgs; + private cluster: ecs.Cluster; + + constructor( + name: string, + args: ClusterArgs, + opts?: ComponentResourceOptions, + ) { + super(__pulumiType, name, args, opts); + + const parent = this; + + const cluster = createCluster(); + + this.args = args; + this.cluster = cluster; + + function createCluster() { + return new ecs.Cluster( + ...transform(args.transform?.cluster, `${name}Cluster`, {}, { parent }), + ); + } + } + + /** + * The underlying [resources](/docs/components/#nodes) this component creates. + */ + public get nodes() { + return { + /** + * The Amazon ECS Cluster. + */ + cluster: this.cluster, + }; + } + + /** + * Add a service to the cluster. + * + * @param name Name of the service. + * @param args Configure the service. + * + * @example + * + * ```ts title="sst.config.ts" + * cluster.addService("MyService"); + * ``` + * + * Set a custom domain for the service. + * + * ```js {2} title="sst.config.ts" + * cluster.addService("MyService", { + * domain: "example.com" + * }); + * ``` + * + * #### Enable auto-scaling + * + * ```ts title="sst.config.ts" + * cluster.addService("MyService", { + * scaling: { + * min: 4, + * max: 16, + * cpuUtilization: 50, + * memoryUtilization: 50, + * } + * }); + * ``` + */ + public addService(name: string, args?: ClusterServiceArgs) { + // Do not prefix the service to allow `Resource.MyService` to work. + return new ServiceV1(name, { + cluster: { + name: this.cluster.name, + arn: this.cluster.arn, + }, + vpc: this.args.vpc, + ...args, + }); + } +} + +const __pulumiType = "sst:aws:Cluster"; +// @ts-expect-error +Cluster.__pulumiType = __pulumiType; diff --git a/platform/src/components/aws/cluster.ts b/platform/src/components/aws/cluster.ts index a79ad9669..6953e10e5 100644 --- a/platform/src/components/aws/cluster.ts +++ b/platform/src/components/aws/cluster.ts @@ -7,6 +7,9 @@ import { Service } from "./service"; import { RETENTION } from "./logging.js"; import { cloudwatch, ec2, ecs, iam, lb } from "@pulumi/aws"; import { ImageArgs } from "@pulumi/docker-build"; +import { Cluster as ClusterV1 } from "./cluster-v1"; +import { Vpc } from "./vpc"; +export type { ClusterArgs as ClusterV1Args } from "./cluster-v1"; export const supportedCpus = { "0.25 vCPU": 256, @@ -141,26 +144,34 @@ export interface ClusterArgs { * } * ``` */ - vpc: Input<{ - /** - * The ID of the VPC. - */ - id: Input; - /** - * A list of public subnet IDs in the VPC. If a service has public ports configured, - * its load balancer will be placed in the public subnets. - */ - publicSubnets: Input[]>; - /** - * A list of private subnet IDs in the VPC. The service will be placed in the private - * subnets. - */ - privateSubnets: Input[]>; - /** - * A list of VPC security group IDs for the service. - */ - securityGroups: Input[]>; - }>; + vpc: + | Vpc + | Input<{ + /** + * The ID of the VPC. + */ + id: Input; + /** + * A list of subnet IDs in the VPC to place the load balancer in. + */ + loadBalancerSubnets: Input[]>; + /** + * A list of private subnet IDs in the VPC to place the services in. + */ + serviceSubnets: Input[]>; + /** + * A list of VPC security group IDs for the service. + */ + securityGroups: Input[]>; + /** + * The ID of the Cloud Map namespace to use for the service. + */ + cloudmapNamespaceId: Input; + /** + * The name of the Cloud Map namespace to use for the service. + */ + cloudmapNamespaceName: Input; + }>; /** * [Transform](/docs/components#transform) how this component creates its underlying * resources. @@ -837,13 +848,15 @@ export interface ClusterServiceArgs { export class Cluster extends Component { private args: ClusterArgs; private cluster: ecs.Cluster; + public static v1 = ClusterV1; constructor( name: string, args: ClusterArgs, opts?: ComponentResourceOptions, ) { - super(__pulumiType, name, args, opts); + const _version = 2; + super(__pulumiType, name, args, opts, _version); const parent = this; diff --git a/platform/src/components/aws/index.ts b/platform/src/components/aws/index.ts index ada0b4f92..4688a2138 100644 --- a/platform/src/components/aws/index.ts +++ b/platform/src/components/aws/index.ts @@ -19,6 +19,7 @@ export * from "./nextjs.js"; export * from "./postgres.js"; export * from "./queue.js"; export * from "./realtime.js"; +export * from "./redis.js"; export * from "./remix.js"; export * from "./router.js"; export * from "./sns-topic.js"; diff --git a/platform/src/components/aws/redis.ts b/platform/src/components/aws/redis.ts new file mode 100644 index 000000000..6b1cbc039 --- /dev/null +++ b/platform/src/components/aws/redis.ts @@ -0,0 +1,347 @@ +import { + ComponentResourceOptions, + interpolate, + Output, + output, +} from "@pulumi/pulumi"; +import { RandomPassword } from "@pulumi/random"; +import { Component, Transform, transform } from "../component.js"; +import { Link } from "../link.js"; +import { Input } from "../input.js"; +import { elasticache } from "@pulumi/aws"; +import { Vpc } from "./vpc.js"; +import { physicalName } from "../naming.js"; + +export interface RedisArgs { + /** + * The Redis engine version. Check out the [supported versions](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/supported-engine-versions.html). + * @default `"7.1"` + * @example + * ```js + * { + * version: "6.2" + * } + * ``` + */ + version?: Input; + /** + * The node instance type to use for the Redis cluster. Check out the [supported instance types](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html). + * + * @default `"t4g.micro"` + * @example + * ```js + * { + * instance: "m7g.xlarge" + * } + * ``` + */ + instance?: Input; + /** + * The number of nodes to use for the Redis cluster. + * + * @default `1` + * @example + * ```js + * { + * nodes: 4 + * } + * ``` + */ + nodes?: Input; + /** + * The VPC to use for the Redis cluster. + * + * @example + * Create a VPC component. + * + * ```js + * const myVpc = new sst.aws.Vpc("MyVpc"); + * ``` + * + * And pass it in. + * + * ```js + * { + * vpc: myVpc + * } + * ``` + * + * Or pass in a custom VPC configuration. + * + * ```js + * { + * vpc: { + * subnets: ["subnet-0db7376a7ad4db5fd ", "subnet-06fc7ee8319b2c0ce"], + * securityGroups: ["sg-0399348378a4c256c"], + * } + * } + * ``` + */ + vpc: + | Vpc + | Input<{ + /** + * A list of subnet IDs in the VPC to deploy the Redis cluster in. + */ + subnets: Input[]>; + /** + * A list of VPC security group IDs. + */ + securityGroups: Input[]>; + }>; + /** + * [Transform](/docs/components#transform) how this component creates its underlying + * resources. + */ + transform?: { + /** + * Transform the Redis subnet group. + */ + subnetGroup?: Transform; + /** + * Transform the Redis cluster. + */ + cluster?: Transform; + }; +} + +interface RedisRef { + ref: boolean; + cluster: elasticache.ReplicationGroup; +} + +/** + * The `Redis` component lets you add a Redis cluster to your app using + * [Amazon ElastiCache](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/WhatIs.html). + * + * @example + * + * #### Create the cluster + * + * ```js title="sst.config.ts" + * const vpc = new sst.aws.Vpc("MyVpc"); + * const redis = new sst.aws.Redis("MyRedis", { vpc }); + * ``` + * + * #### Link to a resource + * + * You can link your cluster to other resources, like a function or your Next.js app. + * + * ```ts title="sst.config.ts" + * new sst.aws.Nextjs("MyWeb", { + * link: [redis], + * vpc + * }); + * ``` + * + * Once linked, you can connect to it from your function code. + * + * ```ts title="app/page.tsx" {1,6,7,12,13} + * import { Resource } from "sst"; + * import { Cluster } from "ioredis" + * + * const client = new Cluster( + * [{ + * host: Resource.MyRedis.host, + * port: Resource.MyRedis.port, + * }], + * { + * redisOptions: { + * tls: { checkServerIdentity: () => undefined }, + * username: Resource.MyRedis.username, + * password: Resource.MyRedis.password, + * }, + * } + * ); + * ``` + */ +export class Redis extends Component implements Link.Linkable { + private cluster: elasticache.ReplicationGroup; + + constructor(name: string, args: RedisArgs, opts?: ComponentResourceOptions) { + super(__pulumiType, name, args, opts); + + if (args && "ref" in args) { + const ref = args as unknown as RedisRef; + this.cluster = ref.cluster; + return; + } + + const parent = this; + const version = output(args.version).apply((v) => v ?? "7.1"); + const instance = output(args.instance).apply((v) => v ?? "t4g.micro"); + const nodes = output(args.nodes).apply((v) => v ?? 1); + const vpc = normalizeVpc(); + + const authToken = createAuthToken(); + const subnetGroup = createSubnetGroup(); + const cluster = createCluster(); + + this.cluster = cluster; + + function normalizeVpc() { + // "vpc" is a Vpc component + if (args.vpc instanceof Vpc) { + return { + subnets: args.vpc.privateSubnets, + securityGroups: args.vpc.securityGroups, + }; + } + + // "vpc" is object + return output(args.vpc); + } + + function createAuthToken() { + return new RandomPassword( + `${name}AuthToken`, + { + length: 32, + special: true, + overrideSpecial: "!&#$^<>-", + }, + { parent }, + ); + } + + function createSubnetGroup() { + return new elasticache.SubnetGroup( + ...transform( + args.transform?.subnetGroup, + `${name}SubnetGroup`, + { + description: "Managed by SST", + subnetIds: vpc.subnets, + }, + { parent }, + ), + ); + } + + function createCluster() { + return new elasticache.ReplicationGroup( + ...transform( + args.transform?.cluster, + `${name}Cluster`, + { + replicationGroupId: physicalName(40, name), + description: "Managed by SST", + engine: "redis", + engineVersion: version, + nodeType: interpolate`cache.${instance}`, + dataTieringEnabled: instance.apply((v) => v.startsWith("r6gd.")), + port: 6379, + automaticFailoverEnabled: true, + clusterMode: "enabled", + numNodeGroups: nodes, + replicasPerNodeGroup: 0, + multiAzEnabled: false, + atRestEncryptionEnabled: true, + transitEncryptionEnabled: true, + transitEncryptionMode: "required", + authToken: authToken.result, + subnetGroupName: subnetGroup.name, + securityGroupIds: vpc.securityGroups, + }, + { parent }, + ), + ); + } + } + + /** + * The ID of the Redis cluster. + */ + public get clusterID() { + return this.cluster.id; + } + + /** + * The username to connect to the Redis cluster. + */ + public get username() { + return output("default"); + } + + /** + * The password to connect to the Redis cluster. + */ + public get password() { + return this.cluster.authToken.apply((v) => v!); + } + + /** + * The host to connect to the Redis cluster. + */ + public get host() { + return this.cluster.configurationEndpointAddress; + } + + /** + * The port to connect to the Redis cluster. + */ + public get port() { + return this.cluster.port.apply((v) => v!); + } + + public get nodes() { + return { + cluster: this.cluster, + }; + } + + /** @internal */ + public getSSTLink() { + return { + properties: { + host: this.host, + port: this.port, + username: this.username, + password: this.password, + }, + }; + } + + /** + * Reference an existing Redis cluster with the given cluster name. This is useful when you + * create a Redis cluster in one stage and want to share it in another. It avoids having to + * create a new Redis cluster in the other stage. + * + * :::tip + * You can use the `static get` method to share Redis clusters across stages. + * ::: + * + * @param name The name of the component. + * @param clusterID The id of the existing Redis cluster. + * + * @example + * Imagine you create a cluster in the `dev` stage. And in your personal stage `frank`, + * instead of creating a new cluster, you want to share the same cluster from `dev`. + * + * ```ts title="sst.config.ts" + * const redis = $app.stage === "frank" + * ? sst.aws.Redis.get("MyRedis", "app-dev-myredis") + * : new sst.aws.Redis("MyRedis"); + * ``` + * + * Here `app-dev-myredis` is the ID of the cluster created in the `dev` stage. + * You can find this by outputting the cluster ID in the `dev` stage. + * + * ```ts title="sst.config.ts" + * return { + * cluster: redis.clusterID + * }; + * ``` + */ + public static get(name: string, clusterID: Input) { + const cluster = elasticache.ReplicationGroup.get( + `${name}Cluster`, + clusterID, + ); + return new Redis(name, { ref: true, cluster } as unknown as RedisArgs); + } +} + +const __pulumiType = "sst:aws:Redis"; +// @ts-expect-error +Redis.__pulumiType = __pulumiType; diff --git a/platform/src/components/aws/service-v1.ts b/platform/src/components/aws/service-v1.ts new file mode 100644 index 000000000..43b0cc406 --- /dev/null +++ b/platform/src/components/aws/service-v1.ts @@ -0,0 +1,867 @@ +import fs from "fs"; +import path from "path"; +import { + ComponentResourceOptions, + Input, + Output, + all, + interpolate, + output, + secret, +} from "@pulumi/pulumi"; +import { Image, Platform } from "@pulumi/docker-build"; +import { Component, transform } from "../component.js"; +import { toGBs, toMBs } from "../size.js"; +import { toNumber } from "../cpu.js"; +import { dns as awsDns } from "./dns.js"; +import { VisibleError } from "../error.js"; +import { DnsValidatedCertificate } from "./dns-validated-certificate.js"; +import { Link } from "../link.js"; +import { bootstrap } from "./helpers/bootstrap.js"; +import { + ClusterArgs, + ClusterServiceArgs, + supportedCpus, + supportedMemories, +} from "./cluster-v1.js"; +import { RETENTION } from "./logging.js"; +import { URL_UNAVAILABLE } from "./linkable.js"; +import { + appautoscaling, + cloudwatch, + ec2, + ecr, + ecs, + getCallerIdentityOutput, + getRegionOutput, + iam, + lb, +} from "@pulumi/aws"; +import { Permission } from "./permission.js"; +import { Vpc } from "./vpc.js"; + +export interface ServiceArgs extends ClusterServiceArgs { + /** + * The cluster to use for the service. + */ + cluster: Input<{ + /** + * The name of the cluster. + */ + name: Input; + /** + * The ARN of the cluster. + */ + arn: Input; + }>; + /** + * The VPC to use for the cluster. + */ + vpc: ClusterArgs["vpc"]; +} + +/** + * The `Service` component is internally used by the `Cluster` component to deploy services to + * [Amazon ECS](https://aws.amazon.com/ecs/). It uses [AWS Fargate](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html). + * + * :::note + * This component is not meant to be created directly. + * ::: + * + * This component is returned by the `addService` method of the `Cluster` component. + */ +export class Service extends Component implements Link.Linkable { + private readonly service?: ecs.Service; + private readonly taskRole: iam.Role; + private readonly taskDefinition?: ecs.TaskDefinition; + private readonly loadBalancer?: lb.LoadBalancer; + private readonly domain?: Output; + private readonly _url?: Output; + private readonly devUrl?: Output; + + constructor( + name: string, + args: ServiceArgs, + opts?: ComponentResourceOptions, + ) { + super(__pulumiType, name, args, opts); + + const self = this; + + const cluster = output(args.cluster); + const vpc = normalizeVpc(); + const region = normalizeRegion(); + const architecture = normalizeArchitecture(); + const imageArgs = normalizeImage(); + const cpu = normalizeCpu(); + const memory = normalizeMemory(); + const storage = normalizeStorage(); + const scaling = normalizeScaling(); + const logging = normalizeLogging(); + const pub = normalizePublic(); + + const linkData = buildLinkData(); + const linkPermissions = buildLinkPermissions(); + + const taskRole = createTaskRole(); + this.taskRole = taskRole; + + if ($dev) { + this.devUrl = !pub ? undefined : output(args.dev?.url ?? URL_UNAVAILABLE); + registerReceiver(); + return; + } + + const bootstrapData = region.apply((region) => bootstrap.forRegion(region)); + const executionRole = createExecutionRole(); + const image = createImage(); + const logGroup = createLogGroup(); + const taskDefinition = createTaskDefinition(); + const certificateArn = createSsl(); + const { loadBalancer, targets } = createLoadBalancer(); + const service = createService(); + createAutoScaling(); + createDnsRecords(); + + this.service = service; + this.taskDefinition = taskDefinition; + this.loadBalancer = loadBalancer; + this.domain = pub?.domain + ? pub.domain.apply((domain) => domain?.name) + : output(undefined); + this._url = !self.loadBalancer + ? undefined + : all([self.domain, self.loadBalancer?.dnsName]).apply( + ([domain, loadBalancer]) => + domain ? `https://${domain}/` : `http://${loadBalancer}`, + ); + + registerHint(); + registerReceiver(); + + function normalizeVpc() { + // "vpc" is a Vpc component + if (args.vpc instanceof Vpc) { + const result = { + id: args.vpc.id, + publicSubnets: args.vpc.publicSubnets, + privateSubnets: args.vpc.privateSubnets, + securityGroups: args.vpc.securityGroups, + }; + return args.vpc.nodes.natGateways.apply((natGateways) => { + if (natGateways.length === 0) + throw new VisibleError( + `The VPC configured for the service does not have NAT enabled. Enable NAT by configuring "nat" on the "sst.aws.Vpc" component.`, + ); + return result; + }); + } + + // "vpc" is object + return output(args.vpc); + } + + function normalizeRegion() { + return getRegionOutput(undefined, { parent: self }).name; + } + + function normalizeArchitecture() { + return output(args.architecture ?? "x86_64").apply((v) => v); + } + + function normalizeImage() { + return all([args.image ?? {}, architecture]).apply( + ([image, architecture]) => ({ + ...image, + context: image.context ?? ".", + platform: + architecture === "arm64" + ? Platform.Linux_arm64 + : Platform.Linux_amd64, + }), + ); + } + + function normalizeCpu() { + return output(args.cpu ?? "0.25 vCPU").apply((v) => { + if (!supportedCpus[v]) { + throw new Error( + `Unsupported CPU: ${v}. The supported values for CPU are ${Object.keys( + supportedCpus, + ).join(", ")}`, + ); + } + return v; + }); + } + + function normalizeMemory() { + return all([cpu, args.memory ?? "0.5 GB"]).apply(([cpu, v]) => { + if (!(v in supportedMemories[cpu])) { + throw new Error( + `Unsupported memory: ${v}. The supported values for memory for a ${cpu} CPU are ${Object.keys( + supportedMemories[cpu], + ).join(", ")}`, + ); + } + return v; + }); + } + + function normalizeStorage() { + return output(args.storage ?? "21 GB").apply((v) => { + const storage = toGBs(v); + if (storage < 21 || storage > 200) + throw new Error( + `Unsupported storage: ${v}. The supported value for storage is between "21 GB" and "200 GB"`, + ); + return v; + }); + } + + function normalizeScaling() { + return output(args.scaling).apply((v) => ({ + min: v?.min ?? 1, + max: v?.max ?? 1, + cpuUtilization: v?.cpuUtilization ?? 70, + memoryUtilization: v?.memoryUtilization ?? 70, + })); + } + + function normalizeLogging() { + return output(args.logging).apply((logging) => ({ + ...logging, + retention: logging?.retention ?? "forever", + })); + } + + function normalizePublic() { + if (!args.public) return; + + const ports = output(args.public).apply((pub) => { + // validate ports + if (!pub.ports || pub.ports.length === 0) + throw new VisibleError( + `You must provide the ports to expose via "public.ports".`, + ); + + // parse protocols and ports + const ports = pub.ports.map((v) => { + const listenParts = v.listen.split("/"); + const forwardParts = v.forward ? v.forward.split("/") : listenParts; + return { + listenPort: parseInt(listenParts[0]), + listenProtocol: listenParts[1], + forwardPort: parseInt(forwardParts[0]), + forwardProtocol: forwardParts[1], + }; + }); + + // validate protocols are consistent + const appProtocols = ports.filter( + (port) => + ["http", "https"].includes(port.listenProtocol) && + ["http", "https"].includes(port.forwardProtocol), + ); + if (appProtocols.length > 0 && appProtocols.length < ports.length) + throw new VisibleError( + `Protocols must be either all http/https, or all tcp/udp/tcp_udp/tls.`, + ); + + // validate certificate exists for https/tls protocol + ports.forEach((port) => { + if (["https", "tls"].includes(port.listenProtocol) && !pub.domain) { + throw new VisibleError( + `You must provide a custom domain for ${port.listenProtocol.toUpperCase()} protocol.`, + ); + } + }); + + return ports; + }); + + const domain = output(args.public).apply((pub) => { + if (!pub.domain) return undefined; + + // normalize domain + const domain = + typeof pub.domain === "string" ? { name: pub.domain } : pub.domain; + return { + name: domain.name, + dns: domain.dns === false ? undefined : domain.dns ?? awsDns(), + cert: domain.cert, + }; + }); + + return { ports, domain }; + } + + function buildLinkData() { + return output(args.link || []).apply((links) => Link.build(links)); + } + + function buildLinkPermissions() { + return Link.getInclude("aws.permission", args.link); + } + + function createImage() { + // Edit .dockerignore file + const imageArgsNew = imageArgs.apply((imageArgs) => { + const context = path.join($cli.paths.root, imageArgs.context); + const dockerfile = imageArgs.dockerfile ?? "Dockerfile"; + + // get .dockerignore file + const file = (() => { + let filePath = path.join(context, `${dockerfile}.dockerignore`); + if (fs.existsSync(filePath)) return filePath; + filePath = path.join(context, ".dockerignore"); + if (fs.existsSync(filePath)) return filePath; + })(); + + // add .sst to .dockerignore if not exist + const content = file ? fs.readFileSync(file).toString() : ""; + const lines = content.split("\n"); + if (!lines.find((line) => line === ".sst")) { + fs.writeFileSync( + file ?? path.join(context, ".dockerignore"), + [...lines, "", "# sst", ".sst"].join("\n"), + ); + } + return imageArgs; + }); + + // Build image + return new Image( + ...transform( + args.transform?.image, + `${name}Image`, + { + context: { + location: imageArgsNew.apply((v) => + path.join($cli.paths.root, v.context), + ), + }, + dockerfile: { + location: imageArgsNew.apply((v) => + v.dockerfile + ? path.join($cli.paths.root, v.dockerfile) + : path.join($cli.paths.root, v.context, "Dockerfile"), + ), + }, + buildArgs: imageArgsNew.apply((v) => v.args ?? {}), + platforms: [imageArgs.platform], + tags: [interpolate`${bootstrapData.assetEcrUrl}:${name}`], + registries: [ + ecr + .getAuthorizationTokenOutput({ + registryId: bootstrapData.assetEcrRegistryId, + }) + .apply((authToken) => ({ + address: authToken.proxyEndpoint, + password: secret(authToken.password), + username: authToken.userName, + })), + ], + push: true, + }, + { parent: self }, + ), + ); + } + + function createLoadBalancer() { + if (!pub) return {}; + + const securityGroup = new ec2.SecurityGroup( + ...transform( + args?.transform?.loadBalancerSecurityGroup, + `${name}LoadBalancerSecurityGroup`, + { + vpcId: vpc.id, + egress: [ + { + fromPort: 0, + toPort: 0, + protocol: "-1", + cidrBlocks: ["0.0.0.0/0"], + }, + ], + ingress: [ + { + fromPort: 0, + toPort: 0, + protocol: "-1", + cidrBlocks: ["0.0.0.0/0"], + }, + ], + }, + { parent: self }, + ), + ); + + const loadBalancer = new lb.LoadBalancer( + ...transform( + args.transform?.loadBalancer, + `${name}LoadBalancer`, + { + internal: false, + loadBalancerType: pub.ports.apply((ports) => + ports[0].listenProtocol.startsWith("http") + ? "application" + : "network", + ), + subnets: vpc.publicSubnets, + securityGroups: [securityGroup.id], + enableCrossZoneLoadBalancing: true, + }, + { parent: self }, + ), + ); + + const ret = all([pub.ports, certificateArn]).apply(([ports, cert]) => { + const listeners: Record = {}; + const targets: Record = {}; + + ports.forEach((port) => { + const forwardProtocol = port.forwardProtocol.toUpperCase(); + const forwardPort = port.forwardPort; + const targetId = `${forwardProtocol}${forwardPort}`; + const target = + targets[targetId] ?? + new lb.TargetGroup( + ...transform( + args.transform?.target, + `${name}Target${targetId}`, + { + // TargetGroup names allow for 32 chars, but an 8 letter suffix + // ie. "-1234567" is automatically added. + // - If we don't specify "name" or "namePrefix", we need to ensure + // the component name is less than 24 chars. Hard to guarantee. + // - If we specify "name", we need to ensure the $app-$stage-$name + // if less than 32 chars. Hard to guarantee. + // - Hence we will use "namePrefix". + namePrefix: forwardProtocol, + port: forwardPort, + protocol: forwardProtocol, + targetType: "ip", + vpcId: vpc.id, + }, + { parent: self }, + ), + ); + targets[targetId] = target; + + const listenProtocol = port.listenProtocol.toUpperCase(); + const listenPort = port.listenPort; + const listenerId = `${listenProtocol}${listenPort}`; + const listener = + listeners[listenerId] ?? + new lb.Listener( + ...transform( + args.transform?.listener, + `${name}Listener${listenerId}`, + { + loadBalancerArn: loadBalancer.arn, + port: listenPort, + protocol: listenProtocol, + certificateArn: ["HTTPS", "TLS"].includes(listenProtocol) + ? cert + : undefined, + defaultActions: [ + { + type: "forward", + targetGroupArn: target.arn, + }, + ], + }, + { parent: self }, + ), + ); + listeners[listenerId] = listener; + }); + + return { listeners, targets }; + }); + + return { loadBalancer, targets: ret.targets }; + } + + function createSsl() { + if (!pub) return output(undefined); + + return pub.domain.apply((domain) => { + if (!domain) return output(undefined); + if (domain.cert) return output(domain.cert); + + return new DnsValidatedCertificate( + `${name}Ssl`, + { + domainName: domain.name, + dns: domain.dns!, + }, + { parent: self }, + ).arn; + }); + } + + function createLogGroup() { + return new cloudwatch.LogGroup( + ...transform( + args.transform?.logGroup, + `${name}LogGroup`, + { + name: interpolate`/sst/cluster/${cluster.name}/${name}`, + retentionInDays: logging.apply( + (logging) => RETENTION[logging.retention], + ), + }, + { parent: self }, + ), + ); + } + + function createTaskRole() { + const policy = all([args.permissions || [], linkPermissions]).apply( + ([argsPermissions, linkPermissions]) => + iam.getPolicyDocumentOutput({ + statements: [ + ...argsPermissions, + ...linkPermissions.map((item) => ({ + actions: item.actions, + resources: item.resources, + })), + ], + }), + ); + + return new iam.Role( + ...transform( + args.transform?.taskRole, + `${name}TaskRole`, + { + assumeRolePolicy: !$dev + ? iam.assumeRolePolicyForPrincipal({ + Service: "ecs-tasks.amazonaws.com", + }) + : iam.assumeRolePolicyForPrincipal({ + AWS: interpolate`arn:aws:iam::${ + getCallerIdentityOutput().accountId + }:root`, + }), + inlinePolicies: policy.apply(({ statements }) => + statements ? [{ name: "inline", policy: policy.json }] : [], + ), + }, + { parent: self }, + ), + ); + } + + function createExecutionRole() { + return new iam.Role( + `${name}ExecutionRole`, + { + assumeRolePolicy: iam.assumeRolePolicyForPrincipal({ + Service: "ecs-tasks.amazonaws.com", + }), + managedPolicyArns: [ + "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + ], + }, + { parent: self }, + ); + } + + function createTaskDefinition() { + return new ecs.TaskDefinition( + ...transform( + args.transform?.taskDefinition, + `${name}Task`, + { + family: interpolate`${cluster.name}-${name}`, + trackLatest: true, + cpu: cpu.apply((v) => toNumber(v).toString()), + memory: memory.apply((v) => toMBs(v).toString()), + networkMode: "awsvpc", + ephemeralStorage: { + sizeInGib: storage.apply((v) => toGBs(v)), + }, + requiresCompatibilities: ["FARGATE"], + runtimePlatform: { + cpuArchitecture: architecture.apply((v) => v.toUpperCase()), + operatingSystemFamily: "LINUX", + }, + executionRoleArn: executionRole.arn, + taskRoleArn: taskRole.arn, + containerDefinitions: $jsonStringify([ + { + name, + image: interpolate`${bootstrapData.assetEcrUrl}@${image.digest}`, + pseudoTerminal: true, + portMappings: pub?.ports.apply((ports) => + ports + .map((port) => port.forwardPort) + // ensure unique ports + .filter( + (value, index, self) => self.indexOf(value) === index, + ) + .map((value) => ({ containerPort: value })), + ), + logConfiguration: { + logDriver: "awslogs", + options: { + "awslogs-group": logGroup.name, + "awslogs-region": region, + "awslogs-stream-prefix": "/service", + }, + }, + environment: all([args.environment ?? [], linkData]).apply( + ([env, linkData]) => [ + ...Object.entries(env).map(([name, value]) => ({ + name, + value, + })), + ...linkData.map((d) => ({ + name: `SST_RESOURCE_${d.name}`, + value: JSON.stringify(d.properties), + })), + { + name: "SST_RESOURCE_App", + value: JSON.stringify({ + name: $app.name, + stage: $app.stage, + }), + }, + ], + ), + }, + ]), + }, + { parent: self }, + ), + ); + } + + function createService() { + return new ecs.Service( + ...transform( + args.transform?.service, + `${name}Service`, + { + name, + cluster: cluster.arn, + taskDefinition: taskDefinition.arn, + desiredCount: scaling.min, + launchType: "FARGATE", + networkConfiguration: { + assignPublicIp: false, + subnets: vpc.privateSubnets, + securityGroups: vpc.securityGroups, + }, + deploymentCircuitBreaker: { + enable: true, + rollback: true, + }, + loadBalancers: + targets && + targets.apply((targets) => + Object.values(targets).map((target) => ({ + targetGroupArn: target.arn, + containerName: name, + containerPort: target.port.apply((port) => port!), + })), + ), + }, + { parent: self }, + ), + ); + } + + function createAutoScaling() { + const target = new appautoscaling.Target( + `${name}AutoScalingTarget`, + { + serviceNamespace: "ecs", + scalableDimension: "ecs:service:DesiredCount", + resourceId: interpolate`service/${cluster.name}/${service.name}`, + maxCapacity: scaling.max, + minCapacity: scaling.min, + }, + { parent: self }, + ); + + new appautoscaling.Policy( + `${name}AutoScalingCpuPolicy`, + { + serviceNamespace: target.serviceNamespace, + scalableDimension: target.scalableDimension, + resourceId: target.resourceId, + policyType: "TargetTrackingScaling", + targetTrackingScalingPolicyConfiguration: { + predefinedMetricSpecification: { + predefinedMetricType: "ECSServiceAverageCPUUtilization", + }, + targetValue: scaling.cpuUtilization, + }, + }, + { parent: self }, + ); + + new appautoscaling.Policy( + `${name}AutoScalingMemoryPolicy`, + { + serviceNamespace: target.serviceNamespace, + scalableDimension: target.scalableDimension, + resourceId: target.resourceId, + policyType: "TargetTrackingScaling", + targetTrackingScalingPolicyConfiguration: { + predefinedMetricSpecification: { + predefinedMetricType: "ECSServiceAverageMemoryUtilization", + }, + targetValue: scaling.memoryUtilization, + }, + }, + { parent: self }, + ); + } + + function createDnsRecords() { + if (!pub) return; + + pub.domain.apply((domain) => { + if (!domain?.dns) return; + + domain.dns.createAlias( + name, + { + name: domain.name, + aliasName: loadBalancer!.dnsName, + aliasZone: loadBalancer!.zoneId, + }, + { parent: self }, + ); + }); + } + + function registerHint() { + self.registerOutputs({ _hint: self._url }); + } + + function registerReceiver() { + self.registerOutputs({ + _receiver: imageArgs.apply((imageArgs) => ({ + directory: path.join( + imageArgs.dockerfile + ? path.dirname(imageArgs.dockerfile) + : imageArgs.context, + ), + links: linkData.apply((input) => input.map((item) => item.name)), + environment: { + ...args.environment, + AWS_REGION: region, + }, + aws: { + role: taskRole.arn, + }, + })), + _dev: imageArgs.apply((imageArgs) => ({ + links: linkData.apply((input) => input.map((item) => item.name)), + environment: { + ...args.environment, + AWS_REGION: region, + }, + aws: { + role: taskRole.arn, + }, + autostart: output(args.dev?.autostart).apply((val) => val ?? true), + directory: output(args.dev?.directory).apply( + (dir) => + dir || + path.join( + imageArgs.dockerfile + ? path.dirname(imageArgs.dockerfile) + : imageArgs.context, + ), + ), + command: args.dev?.command, + })), + }); + } + } + + /** + * The URL of the service. + * + * If `public.domain` is set, this is the URL with the custom domain. + * Otherwise, it's the autogenerated load balancer URL. + */ + public get url() { + const errorMessage = + "Cannot access the URL because no public ports are exposed."; + if ($dev) { + if (!this.devUrl) throw new VisibleError(errorMessage); + return this.devUrl; + } + + if (!this._url) throw new VisibleError(errorMessage); + return this._url; + } + + /** + * The underlying [resources](/docs/components/#nodes) this component creates. + */ + public get nodes() { + const self = this; + return { + /** + * The Amazon ECS Service. + */ + get service() { + if ($dev) + throw new VisibleError("Cannot access `nodes.service` in dev mode."); + return self.service!; + }, + /** + * The Amazon ECS Task Role. + */ + get taskRole() { + return self.taskRole; + }, + /** + * The Amazon ECS Task Definition. + */ + get taskDefinition() { + if ($dev) + throw new VisibleError( + "Cannot access `nodes.taskDefinition` in dev mode.", + ); + return self.taskDefinition!; + }, + /** + * The Amazon Elastic Load Balancer. + */ + get loadBalancer() { + if ($dev) + throw new VisibleError( + "Cannot access `nodes.loadBalancer` in dev mode.", + ); + if (!self.loadBalancer) + throw new VisibleError( + "Cannot access `nodes.loadBalancer` when no public ports are exposed.", + ); + return self.loadBalancer; + }, + }; + } + + /** @internal */ + public getSSTLink() { + return { + properties: { url: $dev ? this.devUrl : this._url }, + }; + } +} + +const __pulumiType = "sst:aws:Service"; +// @ts-expect-error +Service.__pulumiType = __pulumiType; diff --git a/platform/src/components/aws/service.ts b/platform/src/components/aws/service.ts index b3bd5f772..f4be4965b 100644 --- a/platform/src/components/aws/service.ts +++ b/platform/src/components/aws/service.ts @@ -10,22 +10,22 @@ import { secret, } from "@pulumi/pulumi"; import { Image, Platform } from "@pulumi/docker-build"; -import { Component, transform } from "../component"; -import { toGBs, toMBs } from "../size"; -import { toNumber } from "../cpu"; +import { Component, transform } from "../component.js"; +import { toGBs, toMBs } from "../size.js"; +import { toNumber } from "../cpu.js"; import { dns as awsDns } from "./dns.js"; -import { VisibleError } from "../error"; -import { DnsValidatedCertificate } from "./dns-validated-certificate"; +import { VisibleError } from "../error.js"; +import { DnsValidatedCertificate } from "./dns-validated-certificate.js"; import { Link } from "../link.js"; -import { bootstrap } from "./helpers/bootstrap"; +import { bootstrap } from "./helpers/bootstrap.js"; import { ClusterArgs, ClusterServiceArgs, supportedCpus, supportedMemories, -} from "./cluster"; +} from "./cluster.js"; import { RETENTION } from "./logging.js"; -import { URL_UNAVAILABLE } from "./linkable"; +import { URL_UNAVAILABLE } from "./linkable.js"; import { appautoscaling, cloudwatch, @@ -36,9 +36,11 @@ import { getRegionOutput, iam, lb, + servicediscovery, } from "@pulumi/aws"; -import { Permission } from "./permission"; -import { Vpc } from "./vpc"; +import { Permission } from "./permission.js"; +import { Vpc } from "./vpc.js"; +import { Vpc as VpcV1 } from "./vpc-v1"; export interface ServiceArgs extends ClusterServiceArgs { /** @@ -71,7 +73,9 @@ export interface ServiceArgs extends ClusterServiceArgs { * This component is returned by the `addService` method of the `Cluster` component. */ export class Service extends Component implements Link.Linkable { - private readonly service?: ecs.Service; + private readonly _service?: ecs.Service; + private readonly cloudmapNamespace?: Output; + private readonly cloudmapService?: servicediscovery.Service; private readonly taskRole: iam.Role; private readonly taskDefinition?: ecs.TaskDefinition; private readonly loadBalancer?: lb.LoadBalancer; @@ -104,6 +108,8 @@ export class Service extends Component implements Link.Linkable { const linkPermissions = buildLinkPermissions(); const taskRole = createTaskRole(); + + this.cloudmapNamespace = vpc.cloudmapNamespaceName; this.taskRole = taskRole; if ($dev) { @@ -119,11 +125,13 @@ export class Service extends Component implements Link.Linkable { const taskDefinition = createTaskDefinition(); const certificateArn = createSsl(); const { loadBalancer, targets } = createLoadBalancer(); + const cloudmapService = createCloudmapService(); const service = createService(); createAutoScaling(); createDnsRecords(); - this.service = service; + this._service = service; + this.cloudmapService = cloudmapService; this.taskDefinition = taskDefinition; this.loadBalancer = loadBalancer; this.domain = pub?.domain @@ -140,21 +148,23 @@ export class Service extends Component implements Link.Linkable { registerReceiver(); function normalizeVpc() { + // "vpc" is a Vpc.v1 component + if (args.vpc instanceof VpcV1) { + throw new VisibleError( + `You are using the "Vpc.v1" component. Please migrate to the latest "Vpc" component.`, + ); + } + // "vpc" is a Vpc component if (args.vpc instanceof Vpc) { - const result = { + return { id: args.vpc.id, - publicSubnets: args.vpc.publicSubnets, - privateSubnets: args.vpc.privateSubnets, + loadBalancerSubnets: args.vpc.publicSubnets, + serviceSubnets: args.vpc.publicSubnets, securityGroups: args.vpc.securityGroups, + cloudmapNamespaceId: args.vpc.nodes.cloudmapNamespace.id, + cloudmapNamespaceName: args.vpc.nodes.cloudmapNamespace.name, }; - return args.vpc.nodes.natGateways.apply((natGateways) => { - if (natGateways.length === 0) - throw new VisibleError( - `The VPC configured for the service does not have NAT enabled. Enable NAT by configuring "nat" on the "sst.aws.Vpc" component.`, - ); - return result; - }); } // "vpc" is object @@ -410,7 +420,7 @@ export class Service extends Component implements Link.Linkable { ? "application" : "network", ), - subnets: vpc.publicSubnets, + subnets: vpc.loadBalancerSubnets, securityGroups: [securityGroup.id], enableCrossZoneLoadBalancing: true, }, @@ -530,6 +540,15 @@ export class Service extends Component implements Link.Linkable { actions: item.actions, resources: item.resources, })), + { + actions: [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ], + resources: ["*"], + }, ], }), ); @@ -598,15 +617,7 @@ export class Service extends Component implements Link.Linkable { name, image: interpolate`${bootstrapData.assetEcrUrl}@${image.digest}`, pseudoTerminal: true, - portMappings: pub?.ports.apply((ports) => - ports - .map((port) => port.forwardPort) - // ensure unique ports - .filter( - (value, index, self) => self.indexOf(value) === index, - ) - .map((value) => ({ containerPort: value })), - ), + portMappings: [{ containerPortRange: "1-65535" }], logConfiguration: { logDriver: "awslogs", options: { @@ -634,6 +645,9 @@ export class Service extends Component implements Link.Linkable { }, ], ), + linuxParameters: { + initProcessEnabled: true, + }, }, ]), }, @@ -642,6 +656,22 @@ export class Service extends Component implements Link.Linkable { ); } + function createCloudmapService() { + return new servicediscovery.Service( + `${name}CloudmapService`, + { + name: `${name}.${$app.stage}.${$app.name}`, + namespaceId: vpc.cloudmapNamespaceId, + forceDestroy: true, + dnsConfig: { + namespaceId: vpc.cloudmapNamespaceId, + dnsRecords: [{ ttl: 60, type: "A" }], + }, + }, + { parent: self }, + ); + } + function createService() { return new ecs.Service( ...transform( @@ -654,8 +684,8 @@ export class Service extends Component implements Link.Linkable { desiredCount: scaling.min, launchType: "FARGATE", networkConfiguration: { - assignPublicIp: false, - subnets: vpc.privateSubnets, + assignPublicIp: true, + subnets: vpc.serviceSubnets, securityGroups: vpc.securityGroups, }, deploymentCircuitBreaker: { @@ -671,6 +701,11 @@ export class Service extends Component implements Link.Linkable { containerPort: target.port.apply((port) => port!), })), ), + enableExecuteCommand: true, + serviceRegistries: { + registryArn: cloudmapService.arn, + containerName: name, + }, }, { parent: self }, ), @@ -807,6 +842,14 @@ export class Service extends Component implements Link.Linkable { return this._url; } + /** + * The name of the Cloud Map service. + */ + public get service() { + if ($dev) return interpolate`dev.${this.cloudmapNamespace}`; + return interpolate`${this.cloudmapService!.name}.${this.cloudmapNamespace}`; + } + /** * The underlying [resources](/docs/components/#nodes) this component creates. */ @@ -857,7 +900,10 @@ export class Service extends Component implements Link.Linkable { /** @internal */ public getSSTLink() { return { - properties: { url: $dev ? this.devUrl : this._url }, + properties: { + url: $dev ? this.devUrl : this._url, + service: this.service, + }, }; } } diff --git a/platform/src/components/aws/vpc-v1.ts b/platform/src/components/aws/vpc-v1.ts index 57ef8814a..752f16dc4 100644 --- a/platform/src/components/aws/vpc-v1.ts +++ b/platform/src/components/aws/vpc-v1.ts @@ -2,7 +2,6 @@ import { ComponentResourceOptions, Output, all, output } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; import { Input } from "../input"; import { ec2, getAvailabilityZonesOutput } from "@pulumi/aws"; -import { InternetGateway } from "@pulumi/aws/ec2"; export interface VpcArgs { /** diff --git a/platform/src/components/aws/vpc.ts b/platform/src/components/aws/vpc.ts index 71303f075..b21c17cf3 100644 --- a/platform/src/components/aws/vpc.ts +++ b/platform/src/components/aws/vpc.ts @@ -1,7 +1,12 @@ import { ComponentResourceOptions, Output, all, output } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; import { Input } from "../input"; -import { ec2, getAvailabilityZonesOutput, iam } from "@pulumi/aws"; +import { + ec2, + getAvailabilityZonesOutput, + iam, + servicediscovery, +} from "@pulumi/aws"; import { Vpc as VpcV1 } from "./vpc-v1"; import { Link } from "../link"; export type { VpcArgs as VpcV1Args } from "./vpc-v1"; @@ -106,6 +111,7 @@ interface VpcRef { natGateways: Output; elasticIps: Output; bastionInstance: Output; + cloudmapNamespace: servicediscovery.PrivateDnsNamespace; } /** @@ -164,6 +170,7 @@ export class Vpc extends Component implements Link.Linkable { private publicRouteTables: Output; private privateRouteTables: Output; private bastionInstance: Output; + private cloudmapNamespace: servicediscovery.PrivateDnsNamespace; public static v1 = VpcV1; constructor(name: string, args?: VpcArgs, opts?: ComponentResourceOptions) { @@ -182,6 +189,7 @@ export class Vpc extends Component implements Link.Linkable { this.natGateways = output(ref.natGateways); this.elasticIps = ref.elasticIps; this.bastionInstance = ref.bastionInstance; + this.cloudmapNamespace = ref.cloudmapNamespace; return; } const parent = this; @@ -196,6 +204,7 @@ export class Vpc extends Component implements Link.Linkable { const { elasticIps, natGateways } = createNatGateways(); const { privateSubnets, privateRouteTables } = createPrivateSubnets(); const bastionInstance = createBastion(); + const cloudmapNamespace = createCloudmapNamespace(); this.vpc = vpc; this.internetGateway = internetGateway; @@ -207,6 +216,7 @@ export class Vpc extends Component implements Link.Linkable { this.publicRouteTables = publicRouteTables; this.privateRouteTables = privateRouteTables; this.bastionInstance = output(bastionInstance); + this.cloudmapNamespace = cloudmapNamespace; function normalizeAz() { const zones = getAvailabilityZonesOutput({ @@ -507,6 +517,17 @@ export class Vpc extends Component implements Link.Linkable { ), ); } + + function createCloudmapNamespace() { + return new servicediscovery.PrivateDnsNamespace( + `${name}CloudmapNamespace`, + { + name: "sst", + vpc: vpc.id, + }, + { parent }, + ); + } } /** @@ -596,6 +617,10 @@ export class Vpc extends Component implements Link.Linkable { * The Amazon EC2 bastion instance. */ bastionInstance: this.bastionInstance, + /** + * The AWS Cloudmap namespace. + */ + cloudmapNamespace: this.cloudmapNamespace, }; } @@ -723,6 +748,10 @@ export class Vpc extends Component implements Link.Linkable { ? ec2.Instance.get(`${name}BastionInstance`, ids[0]) : undefined, ); + const cloudmapNamespace = servicediscovery.PrivateDnsNamespace.get( + `${name}CloudmapNamespace`, + vpc.id, + ); return new Vpc(name, { ref: true, @@ -736,6 +765,7 @@ export class Vpc extends Component implements Link.Linkable { natGateways, elasticIps, bastionInstance, + cloudmapNamespace, } satisfies VpcRef as VpcArgs); } diff --git a/platform/src/components/component.ts b/platform/src/components/component.ts index e7cc45114..c4430770a 100644 --- a/platform/src/components/component.ts +++ b/platform/src/components/component.ts @@ -80,17 +80,20 @@ export class Component extends ComponentResource { args.type.startsWith("sst:") || args.type === "pulumi-nodejs:dynamic:Resource" || args.type === "random:index/randomId:RandomId" || + args.type === "random:index/randomPassword:RandomPassword" || // resources manually named [ "aws:appsync/dataSource:DataSource", "aws:appsync/function:Function", "aws:appsync/resolver:Resolver", + "aws:cloudwatch/eventBus:EventBus", "aws:cognito/identityPool:IdentityPool", "aws:ecs/service:Service", "aws:ecs/taskDefinition:TaskDefinition", "aws:lb/targetGroup:TargetGroup", "aws:s3/bucketV2:BucketV2", - "aws:cloudwatch/eventBus:EventBus", + "aws:servicediscovery/privateDnsNamespace:PrivateDnsNamespace", + "aws:servicediscovery/service:Service", ].includes(args.type) || // resources not prefixed [ @@ -124,6 +127,7 @@ export class Component extends ComponentResource { "aws:cognito/identityPoolRoleAttachment:IdentityPoolRoleAttachment", "aws:cognito/identityProvider:IdentityProvider", "aws:cognito/userPoolClient:UserPoolClient", + "aws:elasticache/replicationGroup:ReplicationGroup", "aws:lambda/eventSourceMapping:EventSourceMapping", "aws:lambda/functionUrl:FunctionUrl", "aws:lambda/invocation:Invocation", @@ -228,7 +232,10 @@ export class Component extends ComponentResource { cb: () => physicalName(255, args.name), }, { - types: ["aws:rds/subnetGroup:SubnetGroup"], + types: [ + "aws:elasticache/subnetGroup:SubnetGroup", + "aws:rds/subnetGroup:SubnetGroup", + ], field: "name", cb: () => physicalName(255, args.name).toLowerCase(), }, diff --git a/www/astro.config.mjs b/www/astro.config.mjs index 186a1ae03..16b06278f 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -72,6 +72,7 @@ const sidebar = [ "docs/component/aws/cron", "docs/component/aws/nuxt", "docs/component/aws/astro", + "docs/component/aws/redis", "docs/component/aws/email", "docs/component/aws/remix", "docs/component/aws/nextjs", @@ -162,7 +163,10 @@ const sidebar = [ { label: "Deprecated", collapsed: true, - items: [{ label: "Vpc.v1", slug: "docs/component/aws/vpc-v1" }], + items: [ + { label: "Vpc.v1", slug: "docs/component/aws/vpc-v1" }, + { label: "Cluster.v1", slug: "docs/component/aws/cluster-v1" }, + ], }, ]; diff --git a/www/generate.ts b/www/generate.ts index 10f21a0ea..79fcc918a 100644 --- a/www/generate.ts +++ b/www/generate.ts @@ -812,6 +812,7 @@ function renderType( Service: "service", SnsTopicLambdaSubscriber: "sns-topic-lambda-subscriber", SnsTopicQueueSubscriber: "sns-topic-queue-subscriber", + Vpc: "vpc", }[type.name]; if (externalModule) { const hash = type.name.endsWith("Args") @@ -1967,6 +1968,7 @@ async function buildComponents() { "../platform/src/components/aws/bucket-queue-subscriber.ts", "../platform/src/components/aws/bucket-topic-subscriber.ts", "../platform/src/components/aws/cluster.ts", + "../platform/src/components/aws/cluster-v1.ts", "../platform/src/components/aws/cognito-identity-pool.ts", "../platform/src/components/aws/cognito-identity-provider.ts", "../platform/src/components/aws/cognito-user-pool.ts", @@ -1983,6 +1985,7 @@ async function buildComponents() { "../platform/src/components/aws/nuxt.ts", "../platform/src/components/aws/realtime.ts", "../platform/src/components/aws/realtime-lambda-subscriber.ts", + "../platform/src/components/aws/redis.ts", "../platform/src/components/aws/remix.ts", "../platform/src/components/aws/queue.ts", "../platform/src/components/aws/queue-lambda-subscriber.ts",