Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/providers/aws/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -89,6 +90,7 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw
region: cliArgs.region,
zone: cliArgs.zone,
useSpot: cliArgs.spot,
dedicatedVpc: cliArgs.dedicatedVpc !== undefined ? { enabled: cliArgs.dedicatedVpc } : undefined,
costAlert: costAlertCliArgsIntoConfig(cliArgs),
deleteInstanceServerOnStop: cliArgs.deleteInstanceServerOnStop,
dataDiskSnapshot: cliArgs.dataDiskSnapshot ? {
Expand All @@ -108,6 +110,7 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw
await this.informCloudProviderQuotaWarning(CLOUDYPAD_PROVIDER_AWS, "https://docs.cloudypad.gg/cloud-provider-setup/aws.html")
}

const dedicatedVpcEnabled = await this.promptDedicatedVpc(partialInput.provision?.dedicatedVpc?.enabled)
const region = await this.region(partialInput.provision?.region)
const zone = await this.zone(region, partialInput.provision?.zone)
const useSpot = await this.useSpotInstance(partialInput.provision?.useSpot)
Expand All @@ -129,6 +132,7 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw
region: region,
zone: zone,
useSpot: useSpot,
dedicatedVpc: { enabled: dedicatedVpcEnabled },
costAlert: costAlert,
deleteInstanceServerOnStop: partialInput.provision?.deleteInstanceServerOnStop,
dataDiskSnapshot: partialInput.provision?.dataDiskSnapshot?.enable ? {
Expand All @@ -145,6 +149,17 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw

}

private async promptDedicatedVpc(enabled?: boolean): Promise<boolean> {
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<string> {

if (instanceType) {
Expand Down Expand Up @@ -325,6 +340,7 @@ export class AwsCliCommandGenerator extends CliCommandGenerator {
.option('--region <region>', 'Region in which to deploy instance')
.option('--zone <zone>', 'Availability zone in which to deploy instance')
.option('--image-id <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)
Expand Down
1 change: 1 addition & 0 deletions src/providers/aws/provisioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export class AwsProvisioner extends AbstractInstanceProvisioner<AwsProvisionInpu
} : undefined,
// use base image ID from input if available, otherwise use output base image ID (created during deploy)
imageId: this.args.provisionInput?.imageId ?? this.args.provisionOutput?.baseImageId,
dedicatedVpc: this.args.provisionInput.dedicatedVpc,
}
}

Expand Down
123 changes: 104 additions & 19 deletions src/providers/aws/pulumi/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ interface CloudyPadEC2instanceArgs {
* Multiple replicas of CompositeEC2Instance
*/
class CloudyPadEC2Instance extends pulumi.ComponentResource {

private readonly ec2Instance?: aws.ec2.Instance
private readonly volumes: aws.ebs.Volume[]
private readonly dataDisk?: aws.ebs.Volume
Expand Down Expand Up @@ -197,7 +197,7 @@ class CloudyPadEC2Instance extends pulumi.ComponentResource {
"associatePublicIpAddress",
// Don't update AMI as it will replace instance, destroying disk and user's data
// TODO support such change while keeping user's data
"ami"
"ami"
]
})

Expand All @@ -219,7 +219,7 @@ class CloudyPadEC2Instance extends pulumi.ComponentResource {
...commonPulumiOpts,
dependsOn: [this.ec2Instance]
})

new aws.ec2.VolumeAttachment(`${name}-data-disk-attach`, {
deviceName: "/dev/sdf",
volumeId: this.dataDisk.id,
Expand All @@ -228,14 +228,14 @@ class CloudyPadEC2Instance extends pulumi.ComponentResource {
...commonPulumiOpts,
dependsOn: [this.ec2Instance, this.dataDisk]
})

this.dataDiskId = this.dataDisk.id
} else {
this.dataDiskId = undefined
}

this.volumes = []
args.additionalVolumes?.forEach(v => {
args.additionalVolumes?.forEach(v => {
const vol = new aws.ebs.Volume(`${name}-volume-${v.deviceName}`, {
encrypted: v.encrypted || true,
availabilityZone: v.availabilityZone || this.ec2Instance!.availabilityZone,
Expand All @@ -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,
Expand All @@ -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){
Expand All @@ -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<string>
readonly subnetId: pulumi.Output<string>

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<Record<string, any> | void> {
Expand All @@ -308,8 +376,21 @@ async function awsPulumiProgram(): Promise<Record<string, any> | 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<string> | undefined = undefined
let subnetId: pulumi.Output<string> | 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,
Expand All @@ -318,7 +399,7 @@ async function awsPulumiProgram(): Promise<Record<string, any> | 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-*"],
},
Expand All @@ -329,7 +410,7 @@ async function awsPulumiProgram(): Promise<Record<string, any> | void> {
],
owners: ["099720109477"],
}).imageId

let billingAlert: {
limit: pulumi.Input<string>
notificationEmail: pulumi.Input<string>
Expand All @@ -353,6 +434,8 @@ async function awsPulumiProgram(): Promise<Record<string, any> | void> {
ami: amiId,
type: instanceType,
availabilityZone: zone,
vpcId: vpcId,
subnetId: subnetId,
publicKeyContent: publicKeyContent,
rootVolume: {
type: "gp3",
Expand All @@ -368,9 +451,9 @@ async function awsPulumiProgram(): Promise<Record<string, any> | 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"]
}))
Expand Down Expand Up @@ -405,6 +488,7 @@ export interface PulumiStackConfigAws {
notificationEmail: string
},
ingressPorts: SimplePortDefinition[]
dedicatedVpc?: { enabled: boolean }
}

export interface AwsPulumiOutput {
Expand All @@ -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.
*/
Expand All @@ -434,13 +518,13 @@ export interface AwsPulumiClientArgs {
stackName: string
workspaceOptions?: LocalWorkspaceOptions
}

export class AwsPulumiClient extends InstancePulumiClient<PulumiStackConfigAws, AwsPulumiOutput> {

constructor(args: AwsPulumiClientArgs){
super({
program: awsPulumiProgram,
projectName: "CloudyPad-AWS",
super({
program: awsPulumiProgram,
projectName: "CloudyPad-AWS",
stackName: args.stackName,
workspaceOptions: args.workspaceOptions
})
Expand All @@ -459,6 +543,7 @@ export class AwsPulumiClient extends InstancePulumiClient<PulumiStackConfigAws,
await stack.setConfig("ingressPorts", { value: JSON.stringify(config.ingressPorts)})

if(config.zone) await stack.setConfig("zone", { value: config.zone})
if(config.dedicatedVpc !== undefined) await stack.setConfig("dedicatedVpc", { value: JSON.stringify(config.dedicatedVpc) })
if(config.imageId) await stack.setConfig("imageId", { value: config.imageId})
if(config.instanceServerState) await stack.setConfig("instanceServerState", { value: config.instanceServerState})
if(config.dataDisk) await stack.setConfig("dataDisk", { value: JSON.stringify(config.dataDisk)})
Expand All @@ -482,7 +567,7 @@ export class AwsPulumiClient extends InstancePulumiClient<PulumiStackConfigAws,
publicIp: outputs["publicIp"]?.value || "" as string,
rootDiskId: outputs["rootDiskId"]?.value as string | undefined,
dataDiskId: outputs["dataDiskId"]?.value as string | undefined
}
}
}

}
}
1 change: 1 addition & 0 deletions src/providers/aws/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const AwsProvisionInputV1Schema = CommonProvisionInputV1Schema.extend({
region: z.string().describe("AWS region"),
zone: z.string().optional().describe("AWS availability zone"),
useSpot: z.boolean().describe("Whether to use spot instances"),
dedicatedVpc: z.object({ enabled: z.boolean() }).optional().describe("Dedicated VPC configuration (required when no default VPC exists)"),
costAlert: z.object({
limit: z.number().describe("Cost alert limit (USD)"),
notificationEmail: z.string().describe("Cost alert notification email"),
Expand Down