diff --git a/platform/src/components/aws/function.ts b/platform/src/components/aws/function.ts index e8ea08cd4..bdb40a5ed 100644 --- a/platform/src/components/aws/function.ts +++ b/platform/src/components/aws/function.ts @@ -493,53 +493,53 @@ export interface FunctionArgs { logging?: Input< | false | { - /** - * The duration the function logs are kept in CloudWatch. - * - * Not application when an existing log group is provided. - * - * @default `forever` - * @example - * ```js - * { - * logging: { - * retention: "1 week" - * } - * } - * ``` - */ - retention?: Input; - /** - * Assigns the given CloudWatch log group name to the function. This allows you to pass in a previously created log group. - * - * By default, the function creates a new log group when it's created. - * - * @default Creates a log group - * @example - * ```js - * { - * logging: { - * logGroup: "/existing/log-group" - * } - * } - * ``` - */ - logGroup?: Input; - /** - * The [log format](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-advanced.html) - * of the Lambda function. - * @default `"text"` - * @example - * ```js - * { - * logging: { - * format: "json" - * } - * } - * ``` - */ - format?: Input<"text" | "json">; - } + /** + * The duration the function logs are kept in CloudWatch. + * + * Not application when an existing log group is provided. + * + * @default `forever` + * @example + * ```js + * { + * logging: { + * retention: "1 week" + * } + * } + * ``` + */ + retention?: Input; + /** + * Assigns the given CloudWatch log group name to the function. This allows you to pass in a previously created log group. + * + * By default, the function creates a new log group when it's created. + * + * @default Creates a log group + * @example + * ```js + * { + * logging: { + * logGroup: "/existing/log-group" + * } + * } + * ``` + */ + logGroup?: Input; + /** + * The [log format](https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-advanced.html) + * of the Lambda function. + * @default `"text"` + * @example + * ```js + * { + * logging: { + * format: "json" + * } + * } + * ``` + */ + format?: Input<"text" | "json">; + } >; /** * The [architecture](https://docs.aws.amazon.com/lambda/latest/dg/foundation-arch.html) @@ -601,45 +601,45 @@ export interface FunctionArgs { url?: Input< | boolean | { - /** - * The authorization used for the function URL. Supports [IAM authorization](https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html). - * @default `"none"` - * @example - * ```js - * { - * url: { - * authorization: "iam" - * } - * } - * ``` - */ - authorization?: Input<"none" | "iam">; - /** - * Customize the CORS (Cross-origin resource sharing) settings for the function URL. - * @default `true` - * @example - * Disable CORS. - * ```js - * { - * url: { - * cors: false - * } - * } - * ``` - * Only enable the `GET` and `POST` methods for `https://example.com`. - * ```js - * { - * url: { - * cors: { - * allowMethods: ["GET", "POST"], - * allowOrigins: ["https://example.com"] - * } - * } - * } - * ``` - */ - cors?: Input>; - } + /** + * The authorization used for the function URL. Supports [IAM authorization](https://docs.aws.amazon.com/lambda/latest/dg/urls-auth.html). + * @default `"none"` + * @example + * ```js + * { + * url: { + * authorization: "iam" + * } + * } + * ``` + */ + authorization?: Input<"none" | "iam">; + /** + * Customize the CORS (Cross-origin resource sharing) settings for the function URL. + * @default `true` + * @example + * Disable CORS. + * ```js + * { + * url: { + * cors: false + * } + * } + * ``` + * Only enable the `GET` and `POST` methods for `https://example.com`. + * ```js + * { + * url: { + * cors: { + * allowMethods: ["GET", "POST"], + * allowOrigins: ["https://example.com"] + * } + * } + * } + * ``` + */ + cors?: Input>; + } >; /** * Configure how your function is bundled. @@ -1350,10 +1350,10 @@ export class Function extends Component implements Link.Linkable { : url.cors === true || url.cors === undefined ? defaultCors : { - ...defaultCors, - ...url.cors, - maxAge: url.cors.maxAge && toSeconds(url.cors.maxAge), - }; + ...defaultCors, + ...url.cors, + maxAge: url.cors.maxAge && toSeconds(url.cors.maxAge), + }; return { authorization, cors }; }); @@ -1389,8 +1389,11 @@ export class Function extends Component implements Link.Linkable { privateSubnets: args.vpc.privateSubnets, securityGroups: args.vpc.securityGroups, }; - return args.vpc.nodes.natGateways.apply((natGateways) => { - if (natGateways.length === 0) + return all([ + args.vpc.nodes.natGateways, + args.vpc.nodes.natInstances, + ]).apply(([natGateways, natInstances]) => { + if (natGateways.length === 0 && natInstances.length === 0) throw new VisibleError( `The VPC configured for the function does not have NAT enabled. Enable NAT by configuring "nat" on the "sst.aws.Vpc" component.`, ); @@ -1435,9 +1438,9 @@ export class Function extends Component implements Link.Linkable { links: linkData, }); if (result.type === "error") { - throw new Error( + throw new VisibleError( `Failed to build function "${args.handler}": ` + - result.errors.join("\n").trim(), + result.errors.join("\n").trim(), ); } return result; @@ -1447,9 +1450,9 @@ export class Function extends Component implements Link.Linkable { links: linkData, }); if (result.type === "error") { - throw new Error( + throw new VisibleError( `Failed to build function "${args.handler}": ` + - result.errors.join("\n").trim(), + result.errors.join("\n").trim(), ); } return result; @@ -1476,9 +1479,9 @@ export class Function extends Component implements Link.Linkable { links: linkData, }); if (result.type === "error") { - throw new Error( + throw new VisibleError( `Failed to build function "${args.handler}": ` + - result.errors.join("\n").trim(), + result.errors.join("\n").trim(), ); } return result; @@ -1523,12 +1526,12 @@ export class Function extends Component implements Link.Linkable { const linkInjection = hasLinkInjections ? linkData - .map((item) => [ - `process.env.SST_RESOURCE_${item.name} = ${JSON.stringify( - JSON.stringify(item.properties), - )};\n`, - ]) - .join("") + .map((item) => [ + `process.env.SST_RESOURCE_${item.name} = ${JSON.stringify( + JSON.stringify(item.properties), + )};\n`, + ]) + .join("") : ""; const parsed = path.posix.parse(handler); @@ -1558,21 +1561,21 @@ export class Function extends Component implements Link.Linkable { name: path.posix.join(handlerDir, `${newHandlerFileName}.mjs`), content: streaming ? [ - linkInjection, - `export const ${newHandlerFunction} = awslambda.streamifyResponse(async (event, responseStream, context) => {`, - ...injections, - ` const { ${oldHandlerFunction}: rawHandler} = await import("./${oldHandlerFileName}${newHandlerFileExt}");`, - ` return rawHandler(event, responseStream, context);`, - `});`, - ].join("\n") + linkInjection, + `export const ${newHandlerFunction} = awslambda.streamifyResponse(async (event, responseStream, context) => {`, + ...injections, + ` const { ${oldHandlerFunction}: rawHandler} = await import("./${oldHandlerFileName}${newHandlerFileExt}");`, + ` return rawHandler(event, responseStream, context);`, + `});`, + ].join("\n") : [ - linkInjection, - `export const ${newHandlerFunction} = async (event, context) => {`, - ...injections, - ` const { ${oldHandlerFunction}: rawHandler} = await import("./${oldHandlerFileName}${newHandlerFileExt}");`, - ` return rawHandler(event, context);`, - `};`, - ].join("\n"), + linkInjection, + `export const ${newHandlerFunction} = async (event, context) => {`, + ...injections, + ` const { ${oldHandlerFunction}: rawHandler} = await import("./${oldHandlerFileName}${newHandlerFileExt}");`, + ` return rawHandler(event, context);`, + `};`, + ].join("\n"), }, }; }, @@ -1597,18 +1600,18 @@ export class Function extends Component implements Link.Linkable { })), ...(dev ? [ - { - actions: ["iot:*"], - resources: ["*"], - }, - { - actions: ["s3:*"], - resources: [ - interpolate`arn:aws:s3:::${bootstrapData.asset}`, - interpolate`arn:aws:s3:::${bootstrapData.asset}/*`, - ], - }, - ] + { + actions: ["iot:*"], + resources: ["*"], + }, + { + actions: ["s3:*"], + resources: [ + interpolate`arn:aws:s3:::${bootstrapData.asset}`, + interpolate`arn:aws:s3:::${bootstrapData.asset}/*`, + ], + }, + ] : []), ], }), @@ -1621,28 +1624,29 @@ export class Function extends Component implements Link.Linkable { { assumeRolePolicy: !$dev ? iam.assumeRolePolicyForPrincipal({ - Service: "lambda.amazonaws.com", - }) + Service: "lambda.amazonaws.com", + }) : iam.getPolicyDocumentOutput({ - statements: [ - { - actions: ["sts:AssumeRole"], - principals: [ - { - type: "Service", - identifiers: ["lambda.amazonaws.com"], - }, - { - type: "AWS", - identifiers: [ - interpolate`arn:aws:iam::${getCallerIdentityOutput().accountId + statements: [ + { + actions: ["sts:AssumeRole"], + principals: [ + { + type: "Service", + identifiers: ["lambda.amazonaws.com"], + }, + { + type: "AWS", + identifiers: [ + interpolate`arn:aws:iam::${ + getCallerIdentityOutput().accountId }:root`, - ], - }, - ], - }, - ], - }).json, + ], + }, + ], + }, + ], + }).json, // if there are no statements, do not add an inline policy. // adding an inline policy with no statements will cause an error. inlinePolicies: policy.apply(({ statements }) => @@ -1651,13 +1655,13 @@ export class Function extends Component implements Link.Linkable { managedPolicyArns: logging.apply((logging) => [ ...(logging ? [ - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ] + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ] : []), ...(vpc ? [ - "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", - ] + "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", + ] : []), ]), }, @@ -1785,9 +1789,9 @@ export class Function extends Component implements Link.Linkable { entry.isDir ? archive.directory(entry.from, entry.to, { date: new Date(0) }) : archive.file(entry.from, { - name: entry.to, - date: new Date(0), - }); + name: entry.to, + date: new Date(0), + }); }); await archive.finalize(); }); @@ -1822,8 +1826,9 @@ export class Function extends Component implements Link.Linkable { args.transform?.logGroup, `${name}LogGroup`, { - name: interpolate`/aws/lambda/${args.name ?? physicalName(64, `${name}Function`) - }`, + name: interpolate`/aws/lambda/${ + args.name ?? physicalName(64, `${name}Function`) + }`, retentionInDays: RETENTION[logging.retention], }, { parent }, @@ -1868,21 +1873,21 @@ export class Function extends Component implements Link.Linkable { publish: output(args.versioning).apply((v) => v ?? false), ...(isContainer ? { - packageType: "Image", - imageUri: imageAsset!.ref.apply( - (ref) => ref?.replace(":latest", ""), - ), - imageConfig: { - commands: [handler], - }, - } + packageType: "Image", + imageUri: imageAsset!.ref.apply( + (ref) => ref?.replace(":latest", ""), + ), + imageConfig: { + commands: [handler], + }, + } : { - packageType: "Zip", - s3Bucket: zipAsset!.bucket, - s3Key: zipAsset!.key, - handler: unsecret(handler), - runtime, - }), + packageType: "Zip", + s3Bucket: zipAsset!.bucket, + s3Key: zipAsset!.key, + handler: unsecret(handler), + runtime, + }), }, { parent }, ); @@ -1892,14 +1897,14 @@ export class Function extends Component implements Link.Linkable { ...transformed[1], ...(dev ? { - description: transformed[1].description - ? output(transformed[1].description).apply( - (v) => `${v.substring(0, 240)} (live)`, - ) - : "live", - runtime: "provided.al2023", - architectures: ["x86_64"], - } + description: transformed[1].description + ? output(transformed[1].description).apply( + (v) => `${v.substring(0, 240)} (live)`, + ) + : "live", + runtime: "provided.al2023", + architectures: ["x86_64"], + } : {}), }, transformed[2], @@ -1963,7 +1968,7 @@ export class Function extends Component implements Link.Linkable { */ get role() { if (!self.role) - throw new Error( + throw new VisibleError( `"nodes.role" is not available when a pre-existing role is used.`, ); return self.role; @@ -1985,7 +1990,7 @@ export class Function extends Component implements Link.Linkable { public get url() { return this.fnUrl.apply((url) => { if (!url) - throw new Error( + throw new VisibleError( `Function URL is not enabled. Enable it with "url: true".`, ); return url.functionUrl; diff --git a/platform/src/components/aws/vpc.ts b/platform/src/components/aws/vpc.ts index 05b3f76d8..602fd8e2f 100644 --- a/platform/src/components/aws/vpc.ts +++ b/platform/src/components/aws/vpc.ts @@ -1,13 +1,8 @@ -import { - ComponentResourceOptions, - Output, - all, - interpolate, - output, -} from "@pulumi/pulumi"; +import { ComponentResourceOptions, Output, all, output } from "@pulumi/pulumi"; import { Component, Transform, transform } from "../component"; import { Input } from "../input"; import { + autoscaling, ec2, getAvailabilityZonesOutput, iam, @@ -15,6 +10,7 @@ import { } from "@pulumi/aws"; import { Vpc as VpcV1 } from "./vpc-v1"; import { Link } from "../link"; +import { VisibleError } from "../error"; export type { VpcArgs as VpcV1Args } from "./vpc-v1"; export interface VpcArgs { @@ -32,6 +28,20 @@ export interface VpcArgs { az?: Input; /** * Configures NAT. Enabling NAT allows resources in private subnets to connect to the internet. + * + * If `"managed"` is specified, a NAT Gateway is created in each AZ. All the traffic from + * the private subnets are routed to the NAT Gateway in the same AZ. + * + * NAT Gateways are billed per hour and per gigabyte of data processed. Each NAT Gateway + * roughly costs $33 per month. Make sure to [review the pricing](https://aws.amazon.com/vpc/pricing/). + * + * If `"ec2"` is specified, an EC2 instance of type `t4g.nano` will be launched in each AZ + * with the [fck-nat](https://github.com/AndrewGuenther/fck-nat) AMI. All the traffic from + * the private subnets are routed to the Elastic Network Interface (ENI) of the EC2 instance + * in the same AZ. + * + * NAT instances are much cheaper than NAT Gateways, but they need to be managed manually. + * * @default NAT is disabled * @example * ```ts @@ -40,13 +50,16 @@ export interface VpcArgs { * } * ``` */ - nat?: Input<"managed">; + nat?: Input<"ec2" | "managed">; /** * Configures a bastion host that can be used to connect to resources in the VPC. * - * When enabled, an EC2 instance with the bastion AMI will be launched in a public subnet. - * The instance will have AWS SSM (AWS Session Manager) enabled for secure access without - * the need for SSH key management. + * When enabled, an EC2 instance of type `t4g.nano` with the bastion AMI will be launched + * in a public subnet. The instance will have AWS SSM (AWS Session Manager) enabled for + * secure access without the need for SSH key management. + * + * However if `nat` is enabled and `"ec2"` is specified, a NAT instance will be used + * as the bastion host. No additional bastion instance will be created. * * @default Bastion is not created * @example @@ -74,6 +87,10 @@ export interface VpcArgs { * Transform the EC2 NAT Gateway resource. */ natGateway?: Transform; + /** + * Transform the EC2 NAT instance resource. + */ + natInstance?: Transform; /** * Transform the EC2 Elastic IP resource. */ @@ -115,6 +132,7 @@ interface VpcRef { publicSubnets: Output; publicRouteTables: Output; natGateways: Output; + natInstances: Output; elasticIps: Output; bastionInstance: Output; cloudmapNamespace: servicediscovery.PrivateDnsNamespace; @@ -131,16 +149,13 @@ interface VpcRef { * 2. A public subnet in each AZ. * 3. A private subnet in each AZ. * 4. An Internet Gateway. All the traffic from the public subnets are routed through it. - * 5. If `nat` is enabled, a NAT Gateway in each AZ. All the traffic from the private subnets - * are routed to the NAT Gateway in the same AZ. + * 5. If `nat` is enabled, a NAT Gateway or NAT instance in each AZ. All the traffic from + * the private subnets are routed to the NAT in the same AZ. * * :::note - * By default, this does not create NAT Gateways. + * By default, this does not create NAT Gateways or NAT instances. * ::: * - * NAT Gateways are billed per hour and per gigabyte of data processed. Each NAT Gateway - * roughly costs $33 per month. Make sure to [review the pricing](https://aws.amazon.com/vpc/pricing/). - * * @example * * #### Create a VPC @@ -170,6 +185,7 @@ export class Vpc extends Component implements Link.Linkable { private internetGateway: ec2.InternetGateway; private securityGroup: ec2.SecurityGroup; private natGateways: Output; + private natInstances: Output; private elasticIps: Output; private _publicSubnets: Output; private _privateSubnets: Output; @@ -196,6 +212,7 @@ export class Vpc extends Component implements Link.Linkable { this.publicRouteTables = output(ref.publicRouteTables); this.privateRouteTables = output(ref.privateRouteTables); this.natGateways = output(ref.natGateways); + this.natInstances = output(ref.natInstances); this.elasticIps = ref.elasticIps; this.bastionInstance = ref.bastionInstance; this.cloudmapNamespace = ref.cloudmapNamespace; @@ -211,6 +228,7 @@ export class Vpc extends Component implements Link.Linkable { const securityGroup = createSecurityGroup(); const { publicSubnets, publicRouteTables } = createPublicSubnets(); const { elasticIps, natGateways } = createNatGateways(); + const natInstances = createNatInstances(); const { privateSubnets, privateRouteTables } = createPrivateSubnets(); const bastionInstance = createBastion(); const cloudmapNamespace = createCloudmapNamespace(); @@ -219,6 +237,7 @@ export class Vpc extends Component implements Link.Linkable { this.internetGateway = internetGateway; this.securityGroup = securityGroup; this.natGateways = natGateways; + this.natInstances = natInstances; this.elasticIps = elasticIps; this._publicSubnets = publicSubnets; this._privateSubnets = privateSubnets; @@ -242,8 +261,7 @@ export class Vpc extends Component implements Link.Linkable { } function normalizeNat() { - if (!args?.nat) return; - return output(args?.nat); + return output(args?.nat).apply((nat) => nat); } function createVpc() { @@ -306,7 +324,7 @@ export class Vpc extends Component implements Link.Linkable { function createNatGateways() { const ret = all([nat, publicSubnets]).apply(([nat, subnets]) => { - if (!nat) return []; + if (nat !== "managed") return []; return subnets.map((subnet, i) => { const elasticIp = new ec2.Eip( @@ -341,6 +359,105 @@ export class Vpc extends Component implements Link.Linkable { }; } + function createNatInstances() { + return nat.apply((nat) => { + if (nat !== "ec2") return output([]); + + const sg = new ec2.SecurityGroup( + `${name}NatInstanceSecurityGroup`, + { + vpcId: vpc.id, + ingress: [ + { + protocol: "-1", + fromPort: 0, + toPort: 0, + cidrBlocks: ["0.0.0.0/0"], + }, + ], + egress: [ + { + protocol: "-1", + fromPort: 0, + toPort: 0, + cidrBlocks: ["0.0.0.0/0"], + }, + ], + }, + { parent }, + ); + + const role = new iam.Role( + `${name}NatInstanceRole`, + { + assumeRolePolicy: iam.getPolicyDocumentOutput({ + statements: [ + { + actions: ["sts:AssumeRole"], + principals: [ + { + type: "Service", + identifiers: ["ec2.amazonaws.com"], + }, + ], + }, + ], + }).json, + managedPolicyArns: [ + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + ], + }, + { parent }, + ); + + const instanceProfile = new iam.InstanceProfile( + `${name}NatInstanceProfile`, + { role: role.name }, + { parent }, + ); + + const ami = ec2.getAmiOutput( + { + owners: ["568608671756"], // AWS account ID for fck-nat AMI + filters: [ + { + name: "name", + // The AMI has the SSM agent pre-installed + values: ["fck-nat-al2023-*"], + }, + { + name: "architecture", + values: ["arm64"], + }, + ], + mostRecent: true, + }, + { parent }, + ); + + return all([zones, publicSubnets]).apply(([zones, publicSubnets]) => + zones.map((_, i) => { + return new ec2.Instance( + `${name}NatInstance${i + 1}`, + { + instanceType: "t4g.nano", + ami: ami.id, + subnetId: publicSubnets[i].id, + vpcSecurityGroupIds: [sg.id], + iamInstanceProfile: instanceProfile.name, + sourceDestCheck: false, + tags: { + Name: `${name} NAT Instance`, + "sst:lookup-type": "nat", + }, + }, + { parent }, + ); + }), + ); + }); + } + function createPublicSubnets() { const ret = zones.apply((zones) => zones.map((zone, i) => { @@ -416,15 +533,26 @@ export class Vpc extends Component implements Link.Linkable { `${name}PrivateRouteTable${i + 1}`, { vpcId: vpc.id, - routes: natGateways.apply((natGateways) => - natGateways[i] - ? [ - { - cidrBlock: "0.0.0.0/0", - natGatewayId: natGateways[i].id, - }, - ] - : [], + routes: all([natGateways, natInstances]).apply( + ([natGateways, natInstances]) => [ + ...(natGateways[i] + ? [ + { + cidrBlock: "0.0.0.0/0", + natGatewayId: natGateways[i].id, + }, + ] + : []), + ...(natInstances[i] + ? [ + { + cidrBlock: "0.0.0.0/0", + networkInterfaceId: + natInstances[i].primaryNetworkInterfaceId, + }, + ] + : []), + ], ), }, { parent }, @@ -453,87 +581,96 @@ export class Vpc extends Component implements Link.Linkable { function createBastion() { if (!args?.bastion) return output(undefined); - const sg = new ec2.SecurityGroup( - `${name}BastionSecurityGroup`, - { - vpcId: vpc.id, - ingress: [ - { - protocol: "tcp", - fromPort: 22, - toPort: 22, - cidrBlocks: ["0.0.0.0/0"], - }, - ], - egress: [ - { - protocol: "-1", - fromPort: 0, - toPort: 0, - cidrBlocks: ["0.0.0.0/0"], - }, - ], - }, - { parent }, - ); + return natInstances.apply((natInstances) => { + if (natInstances.length) return natInstances[0]; - const role = new iam.Role( - `${name}BastionRole`, - { - assumeRolePolicy: iam.getPolicyDocumentOutput({ - statements: [ + const sg = new ec2.SecurityGroup( + `${name}BastionSecurityGroup`, + { + vpcId: vpc.id, + ingress: [ { - actions: ["sts:AssumeRole"], - principals: [ - { - type: "Service", - identifiers: ["ec2.amazonaws.com"], - }, - ], + protocol: "tcp", + fromPort: 22, + toPort: 22, + cidrBlocks: ["0.0.0.0/0"], }, ], - }).json, - managedPolicyArns: [ - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", - ], - }, - { parent }, - ); - const instanceProfile = new iam.InstanceProfile( - `${name}BastionProfile`, - { role: role.name }, - { parent }, - ); - const amiIds = ec2.getAmiIdsOutput( - { - owners: ["amazon"], - filters: [ - { - name: "name", - // The AMI has the SSM agent pre-installed - values: ["al2023-ami-2023.5.20240916.0-kernel-6.1-x86_64"], - }, - ], - }, - { parent }, - ).ids; - return new ec2.Instance( - ...transform( - args?.transform?.bastionInstance, - `${name}BastionInstance`, + egress: [ + { + protocol: "-1", + fromPort: 0, + toPort: 0, + cidrBlocks: ["0.0.0.0/0"], + }, + ], + }, + { parent }, + ); + + const role = new iam.Role( + `${name}BastionRole`, { - instanceType: "t2.micro", - ami: amiIds.apply((ids) => ids[0]), - subnetId: publicSubnets.apply((v) => v[0].id), - vpcSecurityGroupIds: [sg.id], - iamInstanceProfile: instanceProfile.name, - tags: { - "sst:lookup-type": "bastion", - }, + assumeRolePolicy: iam.getPolicyDocumentOutput({ + statements: [ + { + actions: ["sts:AssumeRole"], + principals: [ + { + type: "Service", + identifiers: ["ec2.amazonaws.com"], + }, + ], + }, + ], + }).json, + managedPolicyArns: [ + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + ], }, { parent }, - ), - ); + ); + const instanceProfile = new iam.InstanceProfile( + `${name}BastionProfile`, + { role: role.name }, + { parent }, + ); + const ami = ec2.getAmiOutput( + { + owners: ["amazon"], + filters: [ + { + name: "name", + // The AMI has the SSM agent pre-installed + values: ["al2023-ami-2023.5.*"], + }, + { + name: "architecture", + values: ["arm64"], + }, + ], + mostRecent: true, + }, + { parent }, + ); + return new ec2.Instance( + ...transform( + args?.transform?.bastionInstance, + `${name}BastionInstance`, + { + instanceType: "t4g.nano", + ami: ami.id, + subnetId: publicSubnets.apply((v) => v[0].id), + vpcSecurityGroupIds: [sg.id], + iamInstanceProfile: instanceProfile.name, + tags: { + "sst:lookup-type": "bastion", + }, + }, + { parent }, + ), + ); + }); } function createCloudmapNamespace() { @@ -585,7 +722,10 @@ export class Vpc extends Component implements Link.Linkable { */ public get bastion() { return this.bastionInstance.apply((v) => { - if (!v) throw new Error("Bastion instance not created"); + if (!v) + throw new VisibleError( + `VPC bastion is not enabled. Enable it with "bastion: true".`, + ); return v.id; }); } @@ -611,6 +751,10 @@ export class Vpc extends Component implements Link.Linkable { * The Amazon EC2 NAT Gateway. */ natGateways: this.natGateways, + /** + * The Amazon EC2 NAT instances. + */ + natInstances: this.natInstances, /** * The Amazon EC2 Elastic IP. */ @@ -692,7 +836,7 @@ export class Vpc extends Component implements Link.Linkable { }) .ids.apply((ids) => { if (!ids.length) - throw new Error(`Security group not found in VPC ${vpcID}`); + throw new VisibleError(`Security group not found in VPC ${vpcID}`); return ids[0]; }), ); @@ -757,6 +901,16 @@ export class Vpc extends Component implements Link.Linkable { ), ), ); + const natInstances = ec2 + .getInstancesOutput({ + filters: [ + { name: "tag:sst:lookup-type", values: ["nat"] }, + { name: "vpc-id", values: [vpc.id] }, + ], + }) + .ids.apply((ids) => + ids.map((id, i) => ec2.Instance.get(`${name}NatInstance${i + 1}`, id)), + ); const bastionInstance = ec2 .getInstancesOutput({ filters: [ @@ -790,6 +944,7 @@ export class Vpc extends Component implements Link.Linkable { publicSubnets, publicRouteTables, natGateways, + natInstances, elasticIps, bastionInstance, cloudmapNamespace,