diff --git a/src/providers/aws/cli.ts b/src/providers/aws/cli.ts index be2cbb5d..2b9d398b 100644 --- a/src/providers/aws/cli.ts +++ b/src/providers/aws/cli.ts @@ -30,7 +30,8 @@ export const AwsCreateCliArgsSchema = CreateCliArgsSchema.extend({ costAlert: z.boolean().optional(), costLimit: z.number().optional(), costNotificationEmail: z.string().optional(), - imageId: z.string().optional(), + imageId: z.string().optional(), + dedicatedVpc: z.boolean().optional(), baseImageSnapshot: z.boolean().optional(), baseImageKeepOnDeletion: z.boolean().optional(), dataDiskSnapshot: z.boolean().optional(), @@ -89,6 +90,7 @@ export class AwsInputPrompter extends AbstractInputPrompter { + if (enabled !== undefined) { + return enabled + } + + return await confirm({ + message: 'Create a dedicated VPC for this instance? (required if your AWS account has no default VPC)', + default: false, + }) + } + private async instanceType(region: string, useSpot: boolean, instanceType?: string): Promise { if (instanceType) { @@ -325,6 +340,7 @@ export class AwsCliCommandGenerator extends CliCommandGenerator { .option('--region ', 'Region in which to deploy instance') .option('--zone ', 'Availability zone in which to deploy instance') .option('--image-id ', 'Existing AMI ID for instance server. Disk size must be equal or greater than image size.') + .option('--dedicated-vpc', 'Create a dedicated VPC for this instance') .action(async (rawCliArgs: unknown) => { // Parse raw CLI args using Zod schema early to ensure type safety const cliArgs = AwsCreateCliArgsSchema.parse(rawCliArgs) diff --git a/src/providers/aws/provisioner.ts b/src/providers/aws/provisioner.ts index bcf7a3ee..5c5d9c6b 100644 --- a/src/providers/aws/provisioner.ts +++ b/src/providers/aws/provisioner.ts @@ -168,6 +168,7 @@ export class AwsProvisioner extends AbstractInstanceProvisioner { + args.additionalVolumes?.forEach(v => { const vol = new aws.ebs.Volume(`${name}-volume-${v.deviceName}`, { encrypted: v.encrypted || true, availabilityZone: v.availabilityZone || this.ec2Instance!.availabilityZone, @@ -245,7 +245,7 @@ class CloudyPadEC2Instance extends pulumi.ComponentResource { throughput: v.throughput, tags: globalTags }, commonPulumiOpts); - + new aws.ec2.VolumeAttachment(`${name}-volume-attach-${v.deviceName}`, { deviceName: v.deviceName, volumeId: vol.id, @@ -270,7 +270,7 @@ class CloudyPadEC2Instance extends pulumi.ComponentResource { this.eip = new aws.ec2.Eip(`${name}-eip`, { tags: globalTags }, commonPulumiOpts); - + // Attach IP to instance if it exists // otherwise keep IP if(this.ec2Instance){ @@ -288,6 +288,74 @@ class CloudyPadEC2Instance extends pulumi.ComponentResource { } } +interface CloudyPadVpcArgs { + instanceName: string + zone?: string + region: string +} + +class CloudyPadVpc extends pulumi.ComponentResource { + readonly vpcId: pulumi.Output + readonly subnetId: pulumi.Output + + constructor(name: string, args: CloudyPadVpcArgs, opts?: pulumi.ComponentResourceOptions) { + super("crafteo:cloudypad:aws:vpc", name, args, opts) + + const { instanceName, region } = args + // Use specified zone, or default to {region}a for deterministic AZ assignment. + // This ensures repeated Pulumi runs always target the same AZ, keeping EBS volumes and instances co-located. + const targetAz = args.zone ?? `${region}a` + + const vpc = new aws.ec2.Vpc(`${instanceName}-vpc`, { + cidrBlock: "10.0.0.0/16", + enableDnsHostnames: true, + enableDnsSupport: true, + assignGeneratedIpv6CidrBlock: true, + tags: { Name: `CloudyPad-${instanceName}` }, + }, { parent: this }) + + const igw = new aws.ec2.InternetGateway(`${instanceName}-igw`, { + vpcId: vpc.id, + tags: { Name: `CloudyPad-${instanceName}` }, + }, { parent: this }) + + const routeTable = new aws.ec2.RouteTable(`${instanceName}-rt`, { + vpcId: vpc.id, + routes: [ + { cidrBlock: "0.0.0.0/0", gatewayId: igw.id }, + { ipv6CidrBlock: "::/0", gatewayId: igw.id }, + ], + tags: { Name: `CloudyPad-${instanceName}` }, + }, { parent: this }) + + const ipv6CidrBlock = vpc.ipv6CidrBlock.apply(cidr => { + if (!cidr.endsWith("::/56")) throw new Error(`Expected VPC IPv6 CIDR to be a /56, got: ${cidr}`) + const base = cidr.replace("::/56", "").slice(0, -2) + return `${base}00::/64` + }) + + const subnet = new aws.ec2.Subnet(`${instanceName}-subnet`, { + vpcId: vpc.id, + cidrBlock: "10.0.0.0/24", + ipv6CidrBlock: ipv6CidrBlock, + availabilityZone: targetAz, + mapPublicIpOnLaunch: true, + assignIpv6AddressOnCreation: true, + tags: { Name: `CloudyPad-${instanceName}-${targetAz}` }, + }, { parent: this }) + + new aws.ec2.RouteTableAssociation(`${instanceName}-rta`, { + subnetId: subnet.id, + routeTableId: routeTable.id, + }, { parent: this }) + + this.vpcId = vpc.id + this.subnetId = subnet.id + + this.registerOutputs({ vpcId: this.vpcId, subnetId: this.subnetId }) + } +} + /* eslint-disable @typescript-eslint/no-explicit-any */ // Interface is set by Pulumi async function awsPulumiProgram(): Promise | void> { @@ -308,8 +376,21 @@ async function awsPulumiProgram(): Promise | void> { const billingAlertLimit = config.get("billingAlertLimit"); const billingAlertNotificationEmail = config.get("billingAlertNotificationEmail"); + const dedicatedVpc = config.getObject<{ enabled: boolean }>("dedicatedVpc") + const instanceName = pulumi.getStack() + // Create a dedicated VPC with public subnet if requested (e.g. when account has no default VPC) + let vpcId: pulumi.Output | undefined = undefined + let subnetId: pulumi.Output | undefined = undefined + + if (dedicatedVpc?.enabled) { + const region = new pulumi.Config("aws").require("region") + const cloudypadVpc = new CloudyPadVpc(`${instanceName}-cloudypad-vpc`, { instanceName, zone, region }) + vpcId = cloudypadVpc.vpcId + subnetId = cloudypadVpc.subnetId + } + // Use provided imageId if available, otherwise use default Ubuntu AMI const amiId = imageId ? pulumi.output(imageId) : aws.ec2.getAmiOutput({ mostRecent: true, @@ -318,7 +399,7 @@ async function awsPulumiProgram(): Promise | void> { { name: "name", // Use a specific version as much as possible to avoid reproducibility issues - // Can't use AMI ID as it's region dependent + // Can't use AMI ID as it's region dependent // and specifying AMI for all regions may not yield expected results and would be hard to maintain values: ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"], }, @@ -329,7 +410,7 @@ async function awsPulumiProgram(): Promise | void> { ], owners: ["099720109477"], }).imageId - + let billingAlert: { limit: pulumi.Input notificationEmail: pulumi.Input @@ -353,6 +434,8 @@ async function awsPulumiProgram(): Promise | void> { ami: amiId, type: instanceType, availabilityZone: zone, + vpcId: vpcId, + subnetId: subnetId, publicKeyContent: publicKeyContent, rootVolume: { type: "gp3", @@ -368,9 +451,9 @@ async function awsPulumiProgram(): Promise | void> { dataDisk: dataDisk, instanceServerState: instanceServerState, ingressPorts: ingressPorts.map(p => ({ - fromPort: p.port, - toPort: p.port, - protocol: p.protocol, + fromPort: p.port, + toPort: p.port, + protocol: p.protocol, cidrBlocks: ["0.0.0.0/0"], ipv6CidrBlocks: ["::/0"] })) @@ -405,6 +488,7 @@ export interface PulumiStackConfigAws { notificationEmail: string }, ingressPorts: SimplePortDefinition[] + dedicatedVpc?: { enabled: boolean } } export interface AwsPulumiOutput { @@ -423,7 +507,7 @@ export interface AwsPulumiOutput { * ID of the root disk volume on AWS. */ rootDiskId?: string - + /** * ID of the data disk volume on AWS. */ @@ -434,13 +518,13 @@ export interface AwsPulumiClientArgs { stackName: string workspaceOptions?: LocalWorkspaceOptions } - + export class AwsPulumiClient extends InstancePulumiClient { constructor(args: AwsPulumiClientArgs){ - super({ - program: awsPulumiProgram, - projectName: "CloudyPad-AWS", + super({ + program: awsPulumiProgram, + projectName: "CloudyPad-AWS", stackName: args.stackName, workspaceOptions: args.workspaceOptions }) @@ -459,6 +543,7 @@ export class AwsPulumiClient extends InstancePulumiClient