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",