diff --git a/examples/aws-ecs-gpus/.dockerignore b/examples/aws-ecs-gpus/.dockerignore new file mode 100644 index 0000000000..763039c77b --- /dev/null +++ b/examples/aws-ecs-gpus/.dockerignore @@ -0,0 +1,6 @@ + +# sst +.sst +node_modules +__pycache__ +*.pyc diff --git a/examples/aws-ecs-gpus/Dockerfile b/examples/aws-ecs-gpus/Dockerfile new file mode 100644 index 0000000000..f56431cd8b --- /dev/null +++ b/examples/aws-ecs-gpus/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /code + +COPY requirements.txt /code/requirements.txt + +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + +COPY app.py /code/app.py + +EXPOSE 8000 + +CMD ["fastapi", "run", "app.py", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/aws-ecs-gpus/app.py b/examples/aws-ecs-gpus/app.py new file mode 100644 index 0000000000..54897b297d --- /dev/null +++ b/examples/aws-ecs-gpus/app.py @@ -0,0 +1,34 @@ +from pathlib import Path +import glob +import os + +from fastapi import FastAPI + + +app = FastAPI() + + +def read_file(path: str): + try: + return Path(path).read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + + +@app.get("/health") +def health(): + return {"ok": True} + + +@app.get("/") +def index(): + return { + "message": "hello from ecs managed instances", + "gpu": { + "visibleDevicesEnv": os.getenv("NVIDIA_VISIBLE_DEVICES"), + "deviceFiles": sorted(glob.glob("/dev/nvidia*")), + "procGpus": sorted(glob.glob("/proc/driver/nvidia/gpus/*")), + "driverVersion": read_file("/proc/driver/nvidia/version"), + "cudaVersion": os.getenv("CUDA_VERSION"), + }, + } diff --git a/examples/aws-ecs-gpus/package.json b/examples/aws-ecs-gpus/package.json new file mode 100644 index 0000000000..9d67054b8e --- /dev/null +++ b/examples/aws-ecs-gpus/package.json @@ -0,0 +1,9 @@ +{ + "name": "aws-ecs-gpus", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "sst": "^4" + } +} diff --git a/examples/aws-ecs-gpus/requirements.txt b/examples/aws-ecs-gpus/requirements.txt new file mode 100644 index 0000000000..8b6ce6ac72 --- /dev/null +++ b/examples/aws-ecs-gpus/requirements.txt @@ -0,0 +1 @@ +fastapi[standard]>=0.115.0,<1.0.0 diff --git a/examples/aws-ecs-gpus/sst.config.ts b/examples/aws-ecs-gpus/sst.config.ts new file mode 100644 index 0000000000..6bd7f5420e --- /dev/null +++ b/examples/aws-ecs-gpus/sst.config.ts @@ -0,0 +1,45 @@ +/// + +/** + * ## AWS ECS GPUs + * + * A minimal ECS service running on ECS Managed Instances with a GPU-enabled host. + * The service uses top-level `gpu`, `cpu`, `memory`, and `storage` settings, while + * the managed instances IAM resources remain customizable through `transform`. + * + * A private API Gateway HTTP API is used to test the service without exposing a public + * load balancer. + */ +export default $config({ + app(input) { + return { + name: "service-gpu-example", + removal: input?.stage === "production" ? "retain" : "remove", + home: "aws", + }; + }, + async run() { + const vpc = new sst.aws.Vpc("MyVpc"); + const cluster = new sst.aws.Cluster("MyCluster", { vpc }); + + // Provisions g4dn.xlarge + const service = new sst.aws.Service("MyService", { + cluster, + image: { context: "./" }, + gpu: "nvidia/t4", + cpu: "4 vCPU", + memory: "10 GB", + serviceRegistry: { + port: 8000, + }, + }); + + const api = new sst.aws.ApiGatewayV2("MyApi", { vpc }); + api.routePrivate("$default", service.nodes.cloudmapService.arn); + + return { + service: service.service, + api: api.url, + }; + }, +}); diff --git a/platform/src/components/aws/managed-instances.ts b/platform/src/components/aws/managed-instances.ts new file mode 100644 index 0000000000..0b9525e4c2 --- /dev/null +++ b/platform/src/components/aws/managed-instances.ts @@ -0,0 +1,630 @@ +import fs from "fs"; +import path from "path"; +import { + all, + ComponentResourceOptions, + interpolate, + output, + Output, + secret, +} from "@pulumi/pulumi"; +import { + cloudwatch, + ecr, + ecs, + getPartitionOutput, + getRegionOutput, + iam, +} from "@pulumi/aws"; +import { ImageArgs } from "@pulumi/docker-build"; +import { Component, Transform, transform } from "../component.js"; +import { Input } from "../input.js"; +import { VisibleError } from "../error.js"; +import { Link } from "../link.js"; +import { toSeconds } from "../duration.js"; +import { toNumber } from "../cpu.js"; +import { toGBs, toMBs } from "../size.js"; +import { RETENTION } from "./logging.js"; +import { bootstrap } from "./helpers/bootstrap.js"; +import { imageBuilder } from "./helpers/container-builder.js"; +import { normalizeContainers } from "./fargate.js"; + +export const managedGpuManufacturers = [ + "amazon-web-services", + "amd", + "nvidia", + "xilinx", + "habana", +] as const; +export const ManagedGpuAcceleratorName = { + A100: "a100", + A10G: "a10g", + H100: "h100", + INFERENTIA: "inferentia", + K520: "k520", + K80: "k80", + M60: "m60", + RADEON_PRO_V520: "radeon-pro-v520", + T4: "t4", + T4G: "t4g", + V100: "v100", + VU9P: "vu9p", +} as const; + +const managedGpuManufacturerNames = { + "amazon-web-services": [ManagedGpuAcceleratorName.INFERENTIA], + amd: [ManagedGpuAcceleratorName.RADEON_PRO_V520], + nvidia: [ + ManagedGpuAcceleratorName.A100, + ManagedGpuAcceleratorName.A10G, + ManagedGpuAcceleratorName.H100, + ManagedGpuAcceleratorName.K520, + ManagedGpuAcceleratorName.K80, + ManagedGpuAcceleratorName.M60, + ManagedGpuAcceleratorName.T4, + ManagedGpuAcceleratorName.T4G, + ManagedGpuAcceleratorName.V100, + ], + xilinx: [ManagedGpuAcceleratorName.VU9P], + habana: [], +} as const; + +export type ManagedGpuAcceleratorName = + (typeof ManagedGpuAcceleratorName)[keyof typeof ManagedGpuAcceleratorName]; +export type ManagedGpu = + `${(typeof managedGpuManufacturers)[number]}/${ManagedGpuAcceleratorName}`; + +type ManagedContainers = ReturnType; +type ManagedServiceArgs = { + gpu: Input; + cpu?: Input<`${number} vCPU`>; + memory?: Input<`${number} GB`>; + storage?: Input<`${number} GB`>; + infrastructureRole?: Input; + instanceProfile?: Input; +}; + +type ManagedTaskDefinitionArgs = { + cluster: { + nodes: { + cluster: { + name: Output; + }; + }; + }; + link?: any; + transform?: { + image?: Transform; + taskDefinition?: Transform; + logGroup?: Transform; + }; +}; + +type ManagedCapacityProviderArgs = { + infrastructureRole?: Input; + instanceProfile?: Input; + transform?: { + infrastructureRole?: Transform; + capacityProvider?: Transform; + instanceProfile?: Transform; + }; +}; + +type ManagedVpcArgs = { + containerSubnets: Input[]>; + securityGroups: Input[]>; +}; + +type NormalizedManagedCapacity = { + taskCpu: string; + taskMemory: string; + hostCpu: { + min: number; + max?: number; + }; + hostMemory: { + min: number; + max?: number; + }; + hostStorage?: number; + gpu?: { + count: { + min: number; + max?: number; + }; + manufacturer: (typeof managedGpuManufacturers)[number]; + names?: ManagedGpuAcceleratorName[]; + }; +}; + +export function normalizeManagedCapacity( + name: string, + args: ManagedServiceArgs, +) { + return all([args.gpu, args.cpu, args.memory, args.storage]).apply( + ([gpu, cpu, memory, storage]) => { + const hostCpu = normalizeHostCpu(cpu); + const hostMemory = normalizeHostMemory(memory); + const hostStorage = normalizeStorage(storage); + + return { + taskCpu: cpu!, + taskMemory: memory!, + hostCpu, + hostMemory, + hostStorage, + gpu: normalizeGpu(gpu), + } satisfies NormalizedManagedCapacity; + }, + ); + + function normalizeHostCpu(cpu?: `${number} vCPU`) { + if (cpu) { + const min = parseFloat(cpu.split(" ")[0]); + return { min }; + } + throw new VisibleError( + `You must provide top-level \"cpu\" for the \"${name}\" Service when \"gpu\" is set.`, + ); + } + + function normalizeHostMemory(memory?: `${number} GB`) { + if (memory) { + const min = toMBs(memory); + return { min }; + } + throw new VisibleError( + `You must provide top-level \"memory\" for the \"${name}\" Service when \"gpu\" is set.`, + ); + } + + function normalizeGpu(gpu: ManagedGpu) { + const [manufacturer, name] = gpu.split("/") as [ + (typeof managedGpuManufacturers)[number], + ManagedGpuAcceleratorName, + ]; + if (!managedGpuManufacturers.includes(manufacturer)) { + throw new VisibleError( + `Unsupported GPU manufacturer \"${manufacturer}\". The supported values are ${managedGpuManufacturers.join( + ", ", + )}.`, + ); + } + + return { + count: { min: 1, max: 1 }, + manufacturer, + names: normalizeGpuNames(manufacturer, name), + }; + } + + function normalizeGpuNames( + manufacturer: (typeof managedGpuManufacturers)[number], + name: ManagedGpuAcceleratorName, + ) { + const names = [name]; + const supported = Object.values(ManagedGpuAcceleratorName); + const invalid = names.filter((name) => !supported.includes(name)); + if (invalid.length > 0) { + throw new VisibleError( + `Unsupported GPU accelerator name ${invalid + .map((name) => `"${name}"`) + .join(", ")}. The supported values are ${supported + .map((name) => `"${name}"`) + .join(", ")}.`, + ); + } + + const supportedForManufacturer = managedGpuManufacturerNames[ + manufacturer + ] as readonly ManagedGpuAcceleratorName[]; + if (!supportedForManufacturer.includes(name)) { + const validNames = supportedForManufacturer + .map((name) => `"${name}"`) + .join(", "); + throw new VisibleError( + supportedForManufacturer.length > 0 + ? `Unsupported GPU accelerator \"${manufacturer}/${name}\". The supported values for \"${manufacturer}\" are ${validNames}.` + : `Unsupported GPU accelerator \"${manufacturer}/${name}\". No accelerator names are currently supported for \"${manufacturer}\".`, + ); + } + return names; + } + + function normalizeStorage(storage?: `${number} GB`) { + if (!storage) return undefined; + const value = toGBs(storage); + if (value <= 0) { + throw new VisibleError( + `Invalid top-level \"storage\" value \"${storage}\" for the \"${name}\" Service. It must be greater than 0 GB.`, + ); + } + return value; + } +} + +export function createManagedCapacityProvider( + name: string, + args: ManagedCapacityProviderArgs, + opts: ComponentResourceOptions, + parent: Component, + clusterName: Output, + vpc: ManagedVpcArgs, + normalized: Output, +) { + const partition = getPartitionOutput({}, opts).partition; + + const infrastructureRoleArn = args.infrastructureRole + ? output(args.infrastructureRole) + : new iam.Role( + ...transform( + args.transform?.infrastructureRole, + `${name}ManagedInfrastructureRole`, + { + assumeRolePolicy: iam.assumeRolePolicyForPrincipal({ + Service: "ecs.amazonaws.com", + }), + managedPolicyArns: [ + interpolate`arn:${partition}:iam::aws:policy/AmazonECSInfrastructureRolePolicyForManagedInstances`, + ], + }, + { parent }, + ), + ).arn; + + const instanceProfileArn = args.instanceProfile + ? output(args.instanceProfile) + : getOrCreateManagedInstanceProfile( + name, + partition, + args.transform?.instanceProfile, + parent, + opts, + ).arn; + + return new ecs.CapacityProvider( + ...transform( + args.transform?.capacityProvider, + `${name}ManagedCapacityProvider`, + { + cluster: clusterName, + managedInstancesProvider: all([ + normalized, + infrastructureRoleArn, + instanceProfileArn, + vpc.containerSubnets, + vpc.securityGroups, + ]).apply( + ([ + normalized, + infrastructureRoleArn, + instanceProfileArn, + subnets, + securityGroups, + ]) => { + const managedInstancesProvider = { + infrastructureRoleArn, + propagateTags: "CAPACITY_PROVIDER" as const, + instanceLaunchTemplate: { + ec2InstanceProfileArn: instanceProfileArn, + networkConfiguration: { + subnets, + securityGroups, + }, + ...(normalized.hostStorage + ? { + storageConfiguration: { + storageSizeGib: normalized.hostStorage, + }, + } + : {}), + instanceRequirements: { + vcpuCount: { + min: normalized.hostCpu.min, + max: normalized.hostCpu.max, + }, + memoryMib: { + min: normalized.hostMemory.min, + max: normalized.hostMemory.max, + }, + instanceGenerations: ["current"], + ...(normalized.gpu + ? { + acceleratorTypes: ["gpu"], + acceleratorCount: { + min: normalized.gpu.count.min, + max: normalized.gpu.count.max, + }, + acceleratorManufacturers: [normalized.gpu.manufacturer], + ...(normalized.gpu.names + ? { + acceleratorNames: normalized.gpu.names, + } + : {}), + } + : {}), + }, + }, + }; + + return managedInstancesProvider; + }, + ), + }, + { parent }, + ), + ); +} + +const sharedManagedInstanceProfileByProvider = new WeakMap< + object, + iam.InstanceProfile +>(); +let defaultManagedInstanceProfile: iam.InstanceProfile | undefined; + +function getOrCreateManagedInstanceProfile( + name: string, + partition: Output, + profileTransform: Transform | undefined, + parent: Component, + opts: ComponentResourceOptions, +) { + const provider = opts.provider; + const existing = provider + ? sharedManagedInstanceProfileByProvider.get(provider) + : defaultManagedInstanceProfile; + if (existing) return existing; + + const role = new iam.Role( + ...transform( + undefined, + `${name}ManagedInstancesEcsInstanceRole`, + { + name: "ecsInstanceRole", + assumeRolePolicy: iam.assumeRolePolicyForPrincipal({ + Service: "ec2.amazonaws.com", + }), + managedPolicyArns: [ + interpolate`arn:${partition}:iam::aws:policy/AmazonECSInstanceRolePolicyForManagedInstances`, + ], + }, + { parent }, + ), + ); + + const profile = new iam.InstanceProfile( + ...transform( + profileTransform, + `${name}ManagedInstancesEcsInstanceProfile`, + { + name: "ecsInstanceRole", + role: role.name, + }, + { parent }, + ), + ); + + if (provider) sharedManagedInstanceProfileByProvider.set(provider, profile); + else defaultManagedInstanceProfile = profile; + + return profile; +} + +export function createManagedTaskDefinition( + name: string, + args: ManagedTaskDefinitionArgs, + opts: ComponentResourceOptions, + parent: Component, + containers: ManagedContainers, + architecture: Output<"x86_64" | "arm64">, + taskRole: iam.Role, + executionRole: iam.Role, + normalized: Output, +) { + const clusterName = args.cluster.nodes.cluster.name; + const region = getRegionOutput({}, opts).region; + const bootstrapData = region.apply((region) => bootstrap.forRegion(region)); + const linkEnvs = Link.propertiesToEnv(Link.getProperties(args.link)); + + const containerDefinitions = all([containers, normalized]).apply( + ([containers, normalized]) => { + if (normalized.gpu && containers.length > 1) { + throw new VisibleError( + `GPU support currently requires a single container when using managed instances.`, + ); + } + + return containers.map((container) => ({ + name: container.name, + image: (() => { + if (typeof container.image === "string") + return output(container.image); + + const containerImage = container.image; + const contextPath = path.join( + $cli.paths.root, + container.image.context, + ); + const dockerfile = container.image.dockerfile ?? "Dockerfile"; + const dockerfilePath = path.join(contextPath, dockerfile); + const dockerIgnorePath = fs.existsSync( + path.join(contextPath, `${dockerfile}.dockerignore`), + ) + ? path.join(contextPath, `${dockerfile}.dockerignore`) + : path.join(contextPath, ".dockerignore"); + + const lines = fs.existsSync(dockerIgnorePath) + ? fs.readFileSync(dockerIgnorePath).toString().split("\n") + : []; + if (!lines.find((line) => line === ".sst")) { + fs.writeFileSync( + dockerIgnorePath, + [...lines, "", "# sst", ".sst"].join("\n"), + ); + } + + const image = imageBuilder( + ...transform( + args.transform?.image, + `${name}Image${container.name}`, + { + context: { location: contextPath }, + dockerfile: { location: dockerfilePath }, + buildArgs: containerImage.args, + secrets: all([linkEnvs, containerImage.secrets ?? {}]).apply( + ([link, secrets]) => ({ ...link, ...secrets }), + ), + target: container.image.target, + platforms: [container.image.platform], + tags: [container.name, ...(container.image.tags ?? [])].map( + (tag) => interpolate`${bootstrapData.assetEcrUrl}:${tag}`, + ), + registries: [ + ecr + .getAuthorizationTokenOutput( + { + registryId: bootstrapData.assetEcrRegistryId, + }, + { parent }, + ) + .apply((authToken) => ({ + address: authToken.proxyEndpoint, + password: secret(authToken.password), + username: authToken.userName, + })), + ], + ...(container.image.cache !== false + ? { + cacheFrom: [ + { + registry: { + ref: interpolate`${bootstrapData.assetEcrUrl}:${container.name}-cache`, + }, + }, + ], + cacheTo: [ + { + registry: { + ref: interpolate`${bootstrapData.assetEcrUrl}:${container.name}-cache`, + imageManifest: true, + ociMediaTypes: true, + mode: "max", + }, + }, + ], + } + : {}), + push: true, + }, + { parent }, + ), + ); + + return interpolate`${bootstrapData.assetEcrUrl}@${image.digest}`; + })(), + cpu: container.cpu ? toNumber(container.cpu) : undefined, + memory: container.memory ? toMBs(container.memory) : undefined, + command: container.command, + entrypoint: container.entrypoint, + healthCheck: container.health && { + command: container.health.command, + startPeriod: toSeconds(container.health.startPeriod ?? "0 seconds"), + timeout: toSeconds(container.health.timeout ?? "5 seconds"), + interval: toSeconds(container.health.interval ?? "30 seconds"), + retries: container.health.retries ?? 3, + }, + portMappings: [{ containerPortRange: "1-65535" }], + logConfiguration: { + logDriver: "awslogs", + options: { + "awslogs-group": (() => { + return new cloudwatch.LogGroup( + ...transform( + args.transform?.logGroup, + `${name}LogGroup${container.name}`, + { + name: container.logging.name, + retentionInDays: RETENTION[container.logging.retention], + }, + { parent, ignoreChanges: ["name"] }, + ), + ); + })().name, + "awslogs-region": region, + "awslogs-stream-prefix": "/service", + }, + }, + environment: linkEnvs.apply((linkEnvs) => + Object.entries({ + ...container.environment, + ...linkEnvs, + }).map(([name, value]) => ({ name, value })), + ), + environmentFiles: container.environmentFiles?.map((file) => ({ + type: "s3", + value: file, + })), + linuxParameters: { + initProcessEnabled: true, + }, + mountPoints: container.volumes?.map((volume) => ({ + sourceVolume: volume.efs.accessPoint, + containerPath: volume.path, + })), + secrets: Object.entries(container.ssm ?? {}).map( + ([name, valueFrom]) => ({ + name, + valueFrom, + }), + ), + resourceRequirements: normalized.gpu + ? [{ type: "GPU", value: normalized.gpu.count.min.toString() }] + : undefined, + })); + }, + ); + + return output( + new ecs.TaskDefinition( + ...transform( + args.transform?.taskDefinition, + `${name}Task`, + { + family: interpolate`${clusterName}-${name}`, + trackLatest: true, + cpu: normalized.apply((v) => v.taskCpu), + memory: normalized.apply((v) => v.taskMemory), + networkMode: "awsvpc", + requiresCompatibilities: ["MANAGED_INSTANCES"], + runtimePlatform: { + cpuArchitecture: architecture.apply((v) => v.toUpperCase()), + operatingSystemFamily: "LINUX", + }, + executionRoleArn: executionRole.arn, + taskRoleArn: taskRole.arn, + volumes: output(containers).apply((containers) => { + const uniqueAccessPoints: Set = new Set(); + return containers.flatMap((container) => + (container.volumes ?? []).flatMap((volume) => { + if (uniqueAccessPoints.has(volume.efs.accessPoint)) return []; + uniqueAccessPoints.add(volume.efs.accessPoint); + return { + name: volume.efs.accessPoint, + efsVolumeConfiguration: { + fileSystemId: volume.efs.fileSystem, + transitEncryption: "ENABLED", + authorizationConfig: { + accessPointId: volume.efs.accessPoint, + }, + }, + }; + }), + ); + }), + containerDefinitions: $jsonStringify(containerDefinitions), + }, + { parent }, + ), + ), + ); +} diff --git a/platform/src/components/aws/service.ts b/platform/src/components/aws/service.ts index 3ea034162a..76fc1d7ac6 100644 --- a/platform/src/components/aws/service.ts +++ b/platform/src/components/aws/service.ts @@ -36,6 +36,12 @@ import { normalizeMemory, normalizeStorage, } from "./fargate.js"; +import { + createManagedCapacityProvider, + createManagedTaskDefinition, + ManagedGpu, + normalizeManagedCapacity, +} from "./managed-instances.js"; import { Dns } from "../dns.js"; import { hashStringToPrettyString } from "../naming.js"; import { Alb } from "./alb.js"; @@ -639,437 +645,437 @@ export interface ServiceArgs extends FargateBaseArgs { */ loadBalancer?: Input< | { - /** - * Configure if the load balancer should be public or private. - * - * When set to `false`, the load balancer endpoint will only be accessible within the - * VPC. - * - * @default `true` - */ - public?: Input; - /** - * Set a custom domain for your load balancer 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` - * } - * } - * ``` - * - * Wildcard domains are supported. - * - * ```js - * { - * domain: { - * name: "*.example.com" - * } - * } - * ``` - */ - name: Input; - /** - * Alias domains that should be used. - * - * @example - * ```js {4} - * { - * domain: { - * name: "app1.example.com", - * aliases: ["app2.example.com"] - * } - * } - * ``` - */ - aliases?: 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; - } - >; - /** @deprecated Use `rules` instead. */ - ports?: Input[]>; - /** - * Configure the mapping for the ports the load balancer listens to, forwards, or redirects 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 want to listen on `https` or `tls`, you need to specify a custom - * `loadBalancer.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 - * { - * rules: [ - * { 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 - * { - * rules: [ - * { listen: "80/http" } - * ] - * } - * ``` - * - * If multiple containers are configured via the `containers` argument, you need to - * specify which container the traffic should be forwarded to. - * - * ```js - * { - * rules: [ - * { listen: "80/http", container: "app" }, - * { listen: "8000/http", container: "admin" } - * ] - * } - * ``` - * - * You can also route the same port to multiple containers via path-based routing. - * - * ```js - * { - * rules: [ - * { - * listen: "80/http", - * container: "app", - * conditions: { path: "/api/*" } - * }, - * { - * listen: "80/http", - * container: "admin", - * conditions: { path: "/admin/*" } - * } - * ] - * } - * ``` - * - * Additionally, you can redirect traffic from one port to another. This is - * commonly used to redirect http to https. - * - * ```js - * { - * rules: [ - * { listen: "80/http", redirect: "443/https" }, - * { listen: "443/https", forward: "80/http" } - * ] - * } - * ``` - */ - rules?: Input[]>; - /** - * Configure the health check that the load balancer runs on your containers. - * - * :::tip - * This health check is different from the [`health`](#health) check. - * ::: - * - * This health check is run by the load balancer. While, `health` is run by ECS. This - * cannot be disabled if you are using a load balancer. While the other is off by default. - * - * Since this cannot be disabled, here are some tips on how to debug an unhealthy - * health check. - * - *
- * How to debug a load balancer health check - * - * If you notice a `Unhealthy: Health checks failed` error, it's because the health - * check has failed. When it fails, the load balancer will terminate the containers, - * causing any requests to fail. - * - * Here's how to debug it: - * - * 1. Verify the health check path. - * - * By default, the load balancer checks the `/` path. Ensure it's accessible in your - * containers. If your application runs on a different path, then update the path in - * the health check config accordingly. - * - * 2. Confirm the containers are operational. - * - * Navigate to **ECS console** > select the **cluster** > go to the **Tasks tab** > - * choose **Any desired status** under the **Filter desired status** dropdown > select - * a task and check for errors under the **Logs tab**. If it has error that means that - * the container failed to start. - * - * 3. If the container was terminated by the load balancer while still starting up, try - * increasing the health check interval and timeout. - *
- * - * For `http` and `https` the default is: - * - * ```js - * { - * path: "/", - * healthyThreshold: 5, - * successCodes: "200", - * timeout: "5 seconds", - * unhealthyThreshold: 2, - * interval: "30 seconds" - * } - * ``` - * - * For `tcp` and `udp` the default is: - * - * ```js - * { - * healthyThreshold: 5, - * timeout: "6 seconds", - * unhealthyThreshold: 2, - * interval: "30 seconds" - * } - * ``` - * - * @example - * - * To configure the health check, we use the _port/protocol_ format. Here we are - * configuring a health check that pings the `/health` path on port `8080` - * every 10 seconds. - * - * ```js - * { - * rules: [ - * { listen: "80/http", forward: "8080/http" } - * ], - * health: { - * "8080/http": { - * path: "/health", - * interval: "10 seconds" - * } - * } - * } - * ``` - * - */ - health?: Input< - Record< - Port, - Input<{ - /** - * The URL path to ping on the service for health checks. Only applicable to - * `http` and `https` protocols. - * @default `"/"` - */ - path?: Input; - /** - * The time period between each health check request. Must be between `5 seconds` - * and `300 seconds`. - * @default `"30 seconds"` - */ - interval?: Input; - /** - * The timeout for each health check request. If no response is received within this - * time, it is considered failed. Must be between `2 seconds` and `120 seconds`. - * @default `"5 seconds"` - */ - timeout?: Input; - /** - * The number of consecutive successful health check requests required to consider the - * target healthy. Must be between 2 and 10. - * @default `5` - */ - healthyThreshold?: Input; - /** - * The number of consecutive failed health check requests required to consider the - * target unhealthy. Must be between 2 and 10. - * @default `2` - */ - unhealthyThreshold?: Input; - /** - * One or more HTTP response codes the health check treats as successful. Only - * applicable to `http` and `https` protocols. - * - * @default `"200"` - * @example - * ```js - * { - * successCodes: "200-299" - * } - * ``` - */ - successCodes?: Input; - }> - > - >; - } + /** + * Configure if the load balancer should be public or private. + * + * When set to `false`, the load balancer endpoint will only be accessible within the + * VPC. + * + * @default `true` + */ + public?: Input; + /** + * Set a custom domain for your load balancer 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` + * } + * } + * ``` + * + * Wildcard domains are supported. + * + * ```js + * { + * domain: { + * name: "*.example.com" + * } + * } + * ``` + */ + name: Input; + /** + * Alias domains that should be used. + * + * @example + * ```js {4} + * { + * domain: { + * name: "app1.example.com", + * aliases: ["app2.example.com"] + * } + * } + * ``` + */ + aliases?: 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; + } + >; + /** @deprecated Use `rules` instead. */ + ports?: Input[]>; + /** + * Configure the mapping for the ports the load balancer listens to, forwards, or redirects 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 want to listen on `https` or `tls`, you need to specify a custom + * `loadBalancer.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 + * { + * rules: [ + * { 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 + * { + * rules: [ + * { listen: "80/http" } + * ] + * } + * ``` + * + * If multiple containers are configured via the `containers` argument, you need to + * specify which container the traffic should be forwarded to. + * + * ```js + * { + * rules: [ + * { listen: "80/http", container: "app" }, + * { listen: "8000/http", container: "admin" } + * ] + * } + * ``` + * + * You can also route the same port to multiple containers via path-based routing. + * + * ```js + * { + * rules: [ + * { + * listen: "80/http", + * container: "app", + * conditions: { path: "/api/*" } + * }, + * { + * listen: "80/http", + * container: "admin", + * conditions: { path: "/admin/*" } + * } + * ] + * } + * ``` + * + * Additionally, you can redirect traffic from one port to another. This is + * commonly used to redirect http to https. + * + * ```js + * { + * rules: [ + * { listen: "80/http", redirect: "443/https" }, + * { listen: "443/https", forward: "80/http" } + * ] + * } + * ``` + */ + rules?: Input[]>; + /** + * Configure the health check that the load balancer runs on your containers. + * + * :::tip + * This health check is different from the [`health`](#health) check. + * ::: + * + * This health check is run by the load balancer. While, `health` is run by ECS. This + * cannot be disabled if you are using a load balancer. While the other is off by default. + * + * Since this cannot be disabled, here are some tips on how to debug an unhealthy + * health check. + * + *
+ * How to debug a load balancer health check + * + * If you notice a `Unhealthy: Health checks failed` error, it's because the health + * check has failed. When it fails, the load balancer will terminate the containers, + * causing any requests to fail. + * + * Here's how to debug it: + * + * 1. Verify the health check path. + * + * By default, the load balancer checks the `/` path. Ensure it's accessible in your + * containers. If your application runs on a different path, then update the path in + * the health check config accordingly. + * + * 2. Confirm the containers are operational. + * + * Navigate to **ECS console** > select the **cluster** > go to the **Tasks tab** > + * choose **Any desired status** under the **Filter desired status** dropdown > select + * a task and check for errors under the **Logs tab**. If it has error that means that + * the container failed to start. + * + * 3. If the container was terminated by the load balancer while still starting up, try + * increasing the health check interval and timeout. + *
+ * + * For `http` and `https` the default is: + * + * ```js + * { + * path: "/", + * healthyThreshold: 5, + * successCodes: "200", + * timeout: "5 seconds", + * unhealthyThreshold: 2, + * interval: "30 seconds" + * } + * ``` + * + * For `tcp` and `udp` the default is: + * + * ```js + * { + * healthyThreshold: 5, + * timeout: "6 seconds", + * unhealthyThreshold: 2, + * interval: "30 seconds" + * } + * ``` + * + * @example + * + * To configure the health check, we use the _port/protocol_ format. Here we are + * configuring a health check that pings the `/health` path on port `8080` + * every 10 seconds. + * + * ```js + * { + * rules: [ + * { listen: "80/http", forward: "8080/http" } + * ], + * health: { + * "8080/http": { + * path: "/health", + * interval: "10 seconds" + * } + * } + * } + * ``` + * + */ + health?: Input< + Record< + Port, + Input<{ + /** + * The URL path to ping on the service for health checks. Only applicable to + * `http` and `https` protocols. + * @default `"/"` + */ + path?: Input; + /** + * The time period between each health check request. Must be between `5 seconds` + * and `300 seconds`. + * @default `"30 seconds"` + */ + interval?: Input; + /** + * The timeout for each health check request. If no response is received within this + * time, it is considered failed. Must be between `2 seconds` and `120 seconds`. + * @default `"5 seconds"` + */ + timeout?: Input; + /** + * The number of consecutive successful health check requests required to consider the + * target healthy. Must be between 2 and 10. + * @default `5` + */ + healthyThreshold?: Input; + /** + * The number of consecutive failed health check requests required to consider the + * target unhealthy. Must be between 2 and 10. + * @default `2` + */ + unhealthyThreshold?: Input; + /** + * One or more HTTP response codes the health check treats as successful. Only + * applicable to `http` and `https` protocols. + * + * @default `"200"` + * @example + * ```js + * { + * successCodes: "200-299" + * } + * ``` + */ + successCodes?: Input; + }> + > + >; + } | { - /** - * The `Alb` instance to attach this service to. When provided, the service creates - * target groups and listener rules on the shared ALB instead of creating its own - * load balancer. - * - * ECS tasks use the VPC's default security group, which allows all traffic within the - * VPC CIDR. For tighter security, add an explicit security group ingress rule from the - * ALB's security group using `transform`. - * - * @example - * ```js - * { - * loadBalancer: { - * instance: alb, - * rules: [ - * { listen: "443/https", forward: "8080/http", conditions: { path: "/api/*" }, priority: 100 } - * ] - * } - * } - * ``` - */ - instance: Alb; - /** - * The rules for routing traffic from the ALB to this service's containers. - * Each rule must have explicit conditions and priority. - */ - rules: Prettify[]; - /** - * Configure health checks for the target groups. Uses the same format as the inline - * health check config, keyed by `{port}/{protocol}`. - */ - health?: Record< - AlbPort, - Input<{ - path?: Input; - interval?: Input; - timeout?: Input; - healthyThreshold?: Input; - unhealthyThreshold?: Input; - successCodes?: Input; - }> - >; - } + /** + * The `Alb` instance to attach this service to. When provided, the service creates + * target groups and listener rules on the shared ALB instead of creating its own + * load balancer. + * + * ECS tasks use the VPC's default security group, which allows all traffic within the + * VPC CIDR. For tighter security, add an explicit security group ingress rule from the + * ALB's security group using `transform`. + * + * @example + * ```js + * { + * loadBalancer: { + * instance: alb, + * rules: [ + * { listen: "443/https", forward: "8080/http", conditions: { path: "/api/*" }, priority: 100 } + * ] + * } + * } + * ``` + */ + instance: Alb; + /** + * The rules for routing traffic from the ALB to this service's containers. + * Each rule must have explicit conditions and priority. + */ + rules: Prettify[]; + /** + * Configure health checks for the target groups. Uses the same format as the inline + * health check config, keyed by `{port}/{protocol}`. + */ + health?: Record< + AlbPort, + Input<{ + path?: Input; + interval?: Input; + timeout?: Input; + healthyThreshold?: Input; + unhealthyThreshold?: Input; + successCodes?: Input; + }> + >; + } >; /** * Configure the CloudMap service registry for the service. @@ -1209,6 +1215,37 @@ export interface ServiceArgs extends FargateBaseArgs { */ scaleOutCooldown?: Input; }>; + /** + * Run this service on ECS Managed Instances with a GPU-enabled host. + * + * This automatically switches the service from Fargate to ECS Managed Instances. + * Use the top-level `cpu`, `memory`, and `storage` props to size the workload. + * + * @example + * ```js + * { + * gpu: "nvidia/t4", + * cpu: "4 vCPU", + * memory: "10 GB" + * } + * ``` + * + * By default, SST creates the managed instances infrastructure role and instance profile for + * you. You can override them with `infrastructureRole`, `instanceProfile`, or the + * corresponding `transform` hooks. + * + * The GPU value must be in the form `/`. Valid manufacturers are + * `amazon-web-services`, `amd`, `nvidia`, `xilinx`, and `habana`. + */ + gpu?: Input; + /** + * The ARN of an existing ECS infrastructure role to use for managed instances. + */ + infrastructureRole?: Input; + /** + * The ARN of an existing EC2 instance profile to use for managed instances. + */ + instanceProfile?: Input; /** * Configure the capacity provider; regular Fargate or Fargate Spot, for this service. * @@ -1350,6 +1387,7 @@ export interface ServiceArgs extends FargateBaseArgs { */ weight: Input; }>; + managed?: never; } >; /** @@ -1525,6 +1563,18 @@ export interface ServiceArgs extends FargateBaseArgs { * attaching to an external ALB via the `loadBalancer.instance` prop. */ listenerRule?: Transform; + /** + * Transform the IAM infrastructure role resource created for managed instances. + */ + infrastructureRole?: Transform; + /** + * Transform the ECS managed instances capacity provider resource. + */ + capacityProvider?: Transform; + /** + * Transform the IAM instance profile resource created for managed instances. + */ + instanceProfile?: Transform; } >; } @@ -1789,6 +1839,7 @@ export class Service extends Component implements Link.Linkable { const scaling = normalizeScaling(); const capacity = normalizeCapacity(); const vpc = normalizeVpc(); + const managed = normalizeManaged(); const taskRole = createTaskRole(name, args, opts, self, !!dev); @@ -1797,28 +1848,68 @@ export class Service extends Component implements Link.Linkable { this.taskRole = taskRole; if (dev) { - this.devUrl = (!lbArgs && !args.loadBalancer) ? undefined : dev.url; + this.devUrl = !lbArgs && !args.loadBalancer ? undefined : dev.url; registerReceiver(); return; } const executionRole = createExecutionRole(name, args, opts, self); - const taskDefinition = createTaskDefinition( - name, - args, - opts, - self, - containers, - architecture, - cpu, - memory, - storage, - taskRole, - executionRole, - ); + const managedCapacityProvider = managed + ? createManagedCapacityProvider( + name, + { + infrastructureRole: args.infrastructureRole, + instanceProfile: args.instanceProfile, + transform: { + infrastructureRole: args.transform?.infrastructureRole, + capacityProvider: args.transform?.capacityProvider, + instanceProfile: args.transform?.instanceProfile, + }, + }, + opts, + self, + clusterName, + { + containerSubnets: vpc.containerSubnets, + securityGroups: vpc.securityGroups, + }, + managed.normalized, + ) + : undefined; + const taskDefinition = managed + ? createManagedTaskDefinition( + name, + args, + opts, + self, + containers, + architecture, + taskRole, + executionRole, + managed.normalized, + ) + : createTaskDefinition( + name, + args, + opts, + self, + containers, + architecture, + cpu, + memory, + storage, + taskRole, + executionRole, + ); let loadBalancer: lb.LoadBalancer | undefined; let targetGroups: ReturnType; - let targetEntries: Output<{ targetGroup: lb.TargetGroup; containerName: string; containerPort: number }[]>; + let targetEntries: Output< + { + targetGroup: lb.TargetGroup; + containerName: string; + containerPort: number; + }[] + >; let effectiveLbArn: Output | undefined; let effectiveDomain: Output; let effectiveDnsName: Output | undefined; @@ -1833,7 +1924,8 @@ export class Service extends Component implements Link.Linkable { } }, ); - const { targets: albTargets, entries: albEntries } = createAlbTargetsAndEntries(albAttachment); + const { targets: albTargets, entries: albEntries } = + createAlbTargetsAndEntries(albAttachment); targetGroups = output(albTargets); targetEntries = albEntries; createAlbListenerRules(albAttachment, albTargets); @@ -1850,7 +1942,8 @@ export class Service extends Component implements Link.Linkable { effectiveLbArn = loadBalancer.arn; effectiveDnsName = loadBalancer.dnsName; } - effectiveDomain = lbArgs?.domain?.apply((d) => d?.name) ?? output(undefined); + effectiveDomain = + lbArgs?.domain?.apply((d) => d?.name) ?? output(undefined); } const cloudmapService = createCloudmapService(); const service = createService(); @@ -1860,13 +1953,13 @@ export class Service extends Component implements Link.Linkable { this.cloudmapService = cloudmapService; this.executionRole = executionRole; this.taskDefinition = taskDefinition; - this.loadBalancer = loadBalancer ?? albAttachment?.instance.nodes.loadBalancer; + this.loadBalancer = + loadBalancer ?? albAttachment?.instance.nodes.loadBalancer; this.autoScalingTarget = autoScalingTarget; this.domain = effectiveDomain; this._url = effectiveDnsName - ? all([effectiveDomain, effectiveDnsName]).apply( - ([domain, dnsName]) => - domain ? `https://${domain}/` : `http://${dnsName}`, + ? all([effectiveDomain, effectiveDnsName]).apply(([domain, dnsName]) => + domain ? `https://${domain}/` : `http://${dnsName}`, ) : undefined; @@ -1908,7 +2001,9 @@ export class Service extends Component implements Link.Linkable { function normalizeScaling() { // External ALB is always "application" type - const lbType = albAttachment ? output("application" as const) : lbArgs?.type; + const lbType = albAttachment + ? output("application" as const) + : lbArgs?.type; return all([lbType, args.scaling]).apply(([type, v]) => { if (type !== "application" && v?.requestCount) throw new VisibleError( @@ -1921,20 +2016,64 @@ export class Service extends Component implements Link.Linkable { cpuUtilization: v?.cpuUtilization ?? 70, memoryUtilization: v?.memoryUtilization ?? 70, requestCount: v?.requestCount ?? false, - scaleInCooldown: v?.scaleInCooldown ? toSeconds(v.scaleInCooldown) : undefined, - scaleOutCooldown: v?.scaleOutCooldown ? toSeconds(v.scaleOutCooldown) : undefined, + scaleInCooldown: v?.scaleInCooldown + ? toSeconds(v.scaleInCooldown) + : undefined, + scaleOutCooldown: v?.scaleOutCooldown + ? toSeconds(v.scaleOutCooldown) + : undefined, }; }); } function normalizeCapacity() { + if (args.gpu && args.capacity) { + throw new VisibleError( + `Do not combine top-level "gpu" with "capacity" in the "${name}" Service. GPU services use ECS Managed Instances automatically.`, + ); + } if (!args.capacity) return; - return output(args.capacity).apply((v) => { - if (v === "spot") - return { spot: { weight: 1 }, fargate: { weight: 0 } }; - return v; + return output(args.capacity).apply( + ( + v, + ): + | { + fargate?: { base?: Input; weight: Input }; + spot?: { base?: Input; weight: Input }; + } + | undefined => { + if (v === "spot") + return { spot: { weight: 1 }, fargate: { weight: 0 } }; + const fargateCapacity = v as { + fargate?: { base?: Input; weight: Input }; + spot?: { base?: Input; weight: Input }; + }; + return { + fargate: fargateCapacity.fargate, + spot: fargateCapacity.spot, + }; + }, + ); + } + + function normalizeManaged() { + if (!args.gpu) return; + + const managedCapacity = output({ + gpu: args.gpu, + cpu: args.cpu, + memory: args.memory, + storage: args.storage, + infrastructureRole: args.infrastructureRole, + instanceProfile: args.instanceProfile, }); + + return { + normalized: managedCapacity.apply((managed) => + normalizeManagedCapacity(name, managed), + ), + }; } function normalizeLoadBalancer() { @@ -2216,7 +2355,11 @@ export class Service extends Component implements Link.Linkable { const seen = new Set(); for (const rule of rules) { if (rule.type !== "forward") continue; - const targetId = targetKey(rule.container!, rule.forwardProtocol, rule.forwardPort); + const targetId = targetKey( + rule.container!, + rule.forwardProtocol, + rule.forwardPort, + ); if (seen.has(targetId)) continue; seen.add(targetId); entries.push({ @@ -2386,82 +2529,108 @@ export class Service extends Component implements Link.Linkable { } function createService() { - return cloudmapService.apply( - (cloudmapService) => - new ecs.Service( - ...transform( - args.transform?.service, - `${name}Service`, - { - name, - cluster: clusterArn, - taskDefinition: taskDefinition.arn, - desiredCount: scaling.min, - ...(capacity - ? { - // setting `forceNewDeployment` ensures that the service is not recreated - // when the capacity provider config changes. - forceNewDeployment: true, - capacityProviderStrategies: capacity.apply((v) => [ - ...(v.fargate - ? [ - { - capacityProvider: "FARGATE", - base: v.fargate?.base, - weight: v.fargate?.weight, - }, - ] - : []), - ...(v.spot - ? [ - { - capacityProvider: "FARGATE_SPOT", - base: v.spot?.base, - weight: v.spot?.weight, - }, - ] - : []), - ]), - } - : // @deprecated do not use `launchType`, set `capacityProviderStrategies` - // to `[{ capacityProvider: "FARGATE", weight: 1 }]` instead - { - launchType: "FARGATE", - }), - networkConfiguration: { - // If the vpc is an SST vpc, services are automatically deployed to the public - // subnets. So we need to assign a public IP for the service to be accessible. - assignPublicIp: vpc.isSstVpc, - subnets: vpc.containerSubnets, - securityGroups: vpc.securityGroups, - }, - deploymentCircuitBreaker: { - enable: true, - rollback: true, - }, - loadBalancers: targetEntries.apply((entries) => - entries.map((e) => ({ - targetGroupArn: e.targetGroup.arn, - containerName: e.containerName, - containerPort: e.containerPort, - })), - ), - enableExecuteCommand: true, - serviceRegistries: cloudmapService && { - registryArn: cloudmapService.arn, - port: args.serviceRegistry - ? output(args.serviceRegistry).port - : undefined, + const create = (dependsOn?: ComponentResourceOptions["dependsOn"]) => + cloudmapService.apply( + (cloudmapService) => + new ecs.Service( + ...transform( + args.transform?.service, + `${name}Service`, + { + name, + cluster: clusterArn, + taskDefinition: taskDefinition.arn, + desiredCount: scaling.min, + ...(managed + ? { + forceNewDeployment: true, + capacityProviderStrategies: [ + { + capacityProvider: managedCapacityProvider!.name, + base: 1, + weight: 1, + }, + ], + } + : capacity + ? { + // setting `forceNewDeployment` ensures that the service is not recreated + // when the capacity provider config changes. + forceNewDeployment: true, + capacityProviderStrategies: capacity.apply((v) => { + if (!v) + throw new VisibleError( + `Invalid Fargate capacity configuration for the \"${name}\" Service.`, + ); + return [ + ...(v.fargate + ? [ + { + capacityProvider: "FARGATE", + base: v.fargate?.base, + weight: v.fargate?.weight, + }, + ] + : []), + ...(v.spot + ? [ + { + capacityProvider: "FARGATE_SPOT", + base: v.spot?.base, + weight: v.spot?.weight, + }, + ] + : []), + ]; + }), + } + : // @deprecated do not use `launchType`, set `capacityProviderStrategies` + // to `[{ capacityProvider: "FARGATE", weight: 1 }]` instead + { + launchType: "FARGATE", + }), + networkConfiguration: { + // If the vpc is an SST vpc, services are automatically deployed to the public + // subnets. So we need to assign a public IP for the service to be accessible. + ...(managed ? {} : { assignPublicIp: vpc.isSstVpc }), + subnets: vpc.containerSubnets, + securityGroups: vpc.securityGroups, + }, + deploymentCircuitBreaker: { + enable: true, + rollback: true, + }, + loadBalancers: targetEntries.apply((entries) => + entries.map((e) => ({ + targetGroupArn: e.targetGroup.arn, + containerName: e.containerName, + containerPort: e.containerPort, + })), + ), + enableExecuteCommand: true, + serviceRegistries: cloudmapService && { + registryArn: cloudmapService.arn, + port: args.serviceRegistry + ? output(args.serviceRegistry).port + : undefined, + }, + waitForSteadyState: wait, }, - waitForSteadyState: wait, - }, - { parent: self }, + { parent: self, ...(dependsOn ? { dependsOn } : {}) }, + ), ), - ), - ); + ); + + if (args.cluster.vpc instanceof Vpc) { + return create([args.cluster.vpc]); + } + + return create(); } function createAutoScaling() { + if (!args.scaling) return; + const target = new appautoscaling.Target( ...transform( args.transform?.autoScalingTarget, @@ -2477,55 +2646,64 @@ export class Service extends Component implements Link.Linkable { ), ); - all([scaling.cpuUtilization, scaling.scaleInCooldown, scaling.scaleOutCooldown]).apply( - ([cpuUtilization, scaleInCooldown, scaleOutCooldown]) => { - if (cpuUtilization === false) return; - new appautoscaling.Policy( - `${name}AutoScalingCpuPolicy`, - { - serviceNamespace: target.serviceNamespace, - scalableDimension: target.scalableDimension, - resourceId: target.resourceId, - policyType: "TargetTrackingScaling", - targetTrackingScalingPolicyConfiguration: { - predefinedMetricSpecification: { - predefinedMetricType: "ECSServiceAverageCPUUtilization", - }, - targetValue: cpuUtilization, - scaleInCooldown, - scaleOutCooldown, + all([ + scaling.cpuUtilization, + scaling.scaleInCooldown, + scaling.scaleOutCooldown, + ]).apply(([cpuUtilization, scaleInCooldown, scaleOutCooldown]) => { + if (cpuUtilization === false) return; + new appautoscaling.Policy( + `${name}AutoScalingCpuPolicy`, + { + serviceNamespace: target.serviceNamespace, + scalableDimension: target.scalableDimension, + resourceId: target.resourceId, + policyType: "TargetTrackingScaling", + targetTrackingScalingPolicyConfiguration: { + predefinedMetricSpecification: { + predefinedMetricType: "ECSServiceAverageCPUUtilization", }, + targetValue: cpuUtilization, + scaleInCooldown, + scaleOutCooldown, }, - { parent: self }, - ); - } - ); + }, + { parent: self }, + ); + }); - all([scaling.memoryUtilization, scaling.scaleInCooldown, scaling.scaleOutCooldown]).apply( - ([memoryUtilization, scaleInCooldown, scaleOutCooldown]) => { - if (memoryUtilization === false) return; - new appautoscaling.Policy( - `${name}AutoScalingMemoryPolicy`, - { - serviceNamespace: target.serviceNamespace, - scalableDimension: target.scalableDimension, - resourceId: target.resourceId, - policyType: "TargetTrackingScaling", - targetTrackingScalingPolicyConfiguration: { - predefinedMetricSpecification: { - predefinedMetricType: "ECSServiceAverageMemoryUtilization", - }, - targetValue: memoryUtilization, - scaleInCooldown, - scaleOutCooldown, + all([ + scaling.memoryUtilization, + scaling.scaleInCooldown, + scaling.scaleOutCooldown, + ]).apply(([memoryUtilization, scaleInCooldown, scaleOutCooldown]) => { + if (memoryUtilization === false) return; + new appautoscaling.Policy( + `${name}AutoScalingMemoryPolicy`, + { + serviceNamespace: target.serviceNamespace, + scalableDimension: target.scalableDimension, + resourceId: target.resourceId, + policyType: "TargetTrackingScaling", + targetTrackingScalingPolicyConfiguration: { + predefinedMetricSpecification: { + predefinedMetricType: "ECSServiceAverageMemoryUtilization", }, + targetValue: memoryUtilization, + scaleInCooldown, + scaleOutCooldown, }, - { parent: self }, - ); - } - ); + }, + { parent: self }, + ); + }); - all([scaling.requestCount, scaling.scaleInCooldown, scaling.scaleOutCooldown, targetGroups]).apply( + all([ + scaling.requestCount, + scaling.scaleInCooldown, + scaling.scaleOutCooldown, + targetGroups, + ]).apply( ([requestCount, scaleInCooldown, scaleOutCooldown, targetGroups]) => { if (requestCount === false) return; if (!targetGroups) return; @@ -2635,7 +2813,9 @@ export class Service extends Component implements Link.Linkable { const cn = rule.container ?? ctrs[0].name; if (!containerNames.has(cn)) { throw new VisibleError( - `Container "${cn}" in "loadBalancer.rules" does not match any container in Service "${name}". Available: ${[...containerNames].join(", ")}.`, + `Container "${cn}" in "loadBalancer.rules" does not match any container in Service "${name}". Available: ${[ + ...containerNames, + ].join(", ")}.`, ); } } @@ -2655,7 +2835,11 @@ export class Service extends Component implements Link.Linkable { const forwardProtocol = parts[1].toUpperCase(); // Use explicit container or component name for keying/naming const containerNameForKey = rule.container ?? name; - const tgtId = targetKey(containerNameForKey, forwardProtocol, forwardPort); + const tgtId = targetKey( + containerNameForKey, + forwardProtocol, + forwardPort, + ); if (!targets[tgtId]) { const healthKey = `${forwardPort}/${parts[1]}` as AlbPort; @@ -2728,8 +2912,7 @@ export class Service extends Component implements Link.Linkable { ); } - const seen = - prioritiesByListener.get(rule.listen) ?? new Set(); + const seen = prioritiesByListener.get(rule.listen) ?? new Set(); if (seen.has(rule.priority)) { throw new VisibleError( `Duplicate priority ${rule.priority} on listener "${rule.listen}" in Service "${name}".`, @@ -2756,7 +2939,11 @@ export class Service extends Component implements Link.Linkable { const forwardPort = parseInt(forwardParts[0]); const forwardProtocol = forwardParts[1].toUpperCase(); const containerNameForKey = rule.container ?? name; - const tgtId = targetKey(containerNameForKey, forwardProtocol, forwardPort); + const tgtId = targetKey( + containerNameForKey, + forwardProtocol, + forwardPort, + ); const targetGroup = albTargets[tgtId]; if (!targetGroup) { @@ -2765,13 +2952,17 @@ export class Service extends Component implements Link.Linkable { ); } - const listenerResource = - attachment.instance.getListener(listenerProtocol, listenerPort); + const listenerResource = attachment.instance.getListener( + listenerProtocol, + listenerPort, + ); new lb.ListenerRule( ...transform( args.transform?.listenerRule, - `${name}AlbRule${listenerProtocol.toUpperCase()}${listenerPort}P${rule.priority}`, + `${name}AlbRule${listenerProtocol.toUpperCase()}${listenerPort}P${ + rule.priority + }`, { listenerArn: listenerResource.arn, priority: rule.priority, @@ -2860,10 +3051,14 @@ export class Service extends Component implements Link.Linkable { throw new VisibleError( `Cannot access the AWS Cloud Map service name for the "${this._name}" Service. Cloud Map is not configured for the cluster.`, ); + if (!service) + throw new VisibleError( + `Cannot access the AWS Cloud Map service name for the "${this._name}" Service. The Cloud Map service is not available.`, + ); return this.dev ? interpolate`dev.${namespace}` - : interpolate`${service!.name}.${namespace}`; + : interpolate`${service.name}.${namespace}`; }, ); } @@ -2949,8 +3144,13 @@ export class Service extends Component implements Link.Linkable { return { properties: { url: this.dev ? this.devUrl : this._url, - service: output(this.cloudmapNamespace).apply((namespace) => - namespace ? this.service : undefined, + service: all([this.cloudmapNamespace, this.cloudmapService]).apply( + ([namespace, service]) => + namespace && service + ? this.dev + ? `dev.${namespace}` + : `${service.name}.${namespace}` + : undefined, ), }, }; diff --git a/platform/src/components/component.ts b/platform/src/components/component.ts index 1aa5fea935..fe5b68a233 100644 --- a/platform/src/components/component.ts +++ b/platform/src/components/component.ts @@ -238,6 +238,7 @@ export class Component extends ComponentResource { "aws:ec2/subnet:Subnet": ["tags", 255], "aws:ec2/vpc:Vpc": ["tags", 255], "aws:ec2/vpcEndpoint:VpcEndpoint": ["tags", 255], + "aws:ecs/capacityProvider:CapacityProvider": ["name", 255], "aws:ecs/cluster:Cluster": ["name", 255], "aws:elasticache/parameterGroup:ParameterGroup": [ "name",