Skip to content
Open
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
100 changes: 82 additions & 18 deletions deploy/aws/cloudformation/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ Metadata:
- SubnetId
- AllowedApiCidr
- ApiPort
- EnableHttpIngress
- EnableHttpsIngress
- AllowedIngressCidr
- Label:
default: Instance
Parameters:
- InstanceType
- RootVolumeSize
- DataVolumeSize
- DataVolumeIops
- DataVolumeThroughput
- AmiSsmParameter
- Label:
default: Access
Expand All @@ -41,6 +46,12 @@ Metadata:
default: Hypeman API access CIDR
ApiPort:
default: Hypeman API port
EnableHttpIngress:
default: Enable HTTP ingress
EnableHttpsIngress:
default: Enable HTTPS ingress
AllowedIngressCidr:
default: Hypeman ingress access CIDR
EnableSSH:
default: Enable SSH
AllowedSshCidr:
Expand All @@ -51,6 +62,10 @@ Metadata:
default: Root volume size
DataVolumeSize:
default: Hypeman data volume size
DataVolumeIops:
default: Hypeman data volume IOPS
DataVolumeThroughput:
default: Hypeman data volume throughput
HypemanVersion:
default: Hypeman release
HypemanBranch:
Expand Down Expand Up @@ -83,6 +98,21 @@ Parameters:
MinValue: 1
MaxValue: 65535
Description: Hypeman API port exposed to AllowedApiCidr.
EnableHttpIngress:
Type: String
Default: "false"
AllowedValues: ["true", "false"]
Description: Open port 80 from AllowedIngressCidr for Hypeman HTTP ingress traffic.
EnableHttpsIngress:
Type: String
Default: "false"
AllowedValues: ["true", "false"]
Description: Open port 443 from AllowedIngressCidr for Hypeman HTTPS ingress traffic.
AllowedIngressCidr:
Type: String
Default: 127.0.0.1/32
Description: Client CIDR allowed to reach Hypeman ingress ports when enabled. Use your current public IP /32 or a trusted VPN CIDR; avoid 0.0.0.0/0.
AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$"
EnableSSH:
Type: String
Default: "false"
Expand All @@ -109,6 +139,18 @@ Parameters:
MinValue: 50
MaxValue: 16384
Description: Hypeman data EBS volume size in GiB. This volume is formatted as XFS and mounted at /var/lib/hypeman.
DataVolumeIops:
Type: Number
Default: 3000
MinValue: 3000
MaxValue: 80000
Description: Provisioned IOPS for the Hypeman data gp3 EBS volume. High values may require a larger DataVolumeSize.
DataVolumeThroughput:
Type: Number
Default: 125
MinValue: 125
MaxValue: 2000
Description: Provisioned throughput in MiB/s for the Hypeman data gp3 EBS volume. High values may require higher DataVolumeIops.
HypemanVersion:
Type: String
Default: latest
Expand All @@ -128,6 +170,8 @@ Parameters:

Conditions:
UseSSH: !Equals [!Ref EnableSSH, "true"]
UseHttpIngress: !Equals [!Ref EnableHttpIngress, "true"]
UseHttpsIngress: !Equals [!Ref EnableHttpsIngress, "true"]

Resources:
HypemanSecurityGroup:
Expand All @@ -141,6 +185,22 @@ Resources:
ToPort: !Ref ApiPort
CidrIp: !Ref AllowedApiCidr
Description: Hypeman API
- !If
- UseHttpIngress
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: !Ref AllowedIngressCidr
Description: Hypeman HTTP ingress
- !Ref AWS::NoValue
- !If
- UseHttpsIngress
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: !Ref AllowedIngressCidr
Description: Hypeman HTTPS ingress
- !Ref AWS::NoValue
- !If
- UseSSH
- IpProtocol: tcp
Expand Down Expand Up @@ -186,9 +246,10 @@ Resources:
Roles:
- !Ref HypemanInstanceRole

# CloudFormation's typed EC2 resources do not expose CpuOptions.NestedVirtualization yet.
# This helper creates only the launch template that carries that EC2 API option;
# the Hypeman EC2 instance itself remains a normal stack-managed resource.
# CloudFormation's typed EC2 instance block device mapping does not expose gp3
# throughput, and EC2 resources do not expose CpuOptions.NestedVirtualization
# yet. This helper creates a minimal launch template for those fields; the
# Hypeman EC2 instance itself remains a normal stack-managed resource.
NestedVirtualizationLaunchTemplateRole:
Type: AWS::IAM::Role
Properties:
Expand Down Expand Up @@ -285,12 +346,24 @@ Resources:
stack_uuid = event["StackId"].rsplit("/", 1)[-1]
return f"{event['ResourceProperties']['NamePrefix']}-{stack_uuid}"

def create_launch_template(name):
def create_launch_template(name, props):
payload = {
"Action": "CreateLaunchTemplate",
"Version": "2016-11-15",
"LaunchTemplateName": name,
"LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled",
"LaunchTemplateData.BlockDeviceMapping.1.DeviceName": "/dev/sda1",
"LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeSize": props["RootVolumeSize"],
"LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeType": "gp3",
"LaunchTemplateData.BlockDeviceMapping.1.Ebs.Encrypted": "true",
"LaunchTemplateData.BlockDeviceMapping.1.Ebs.DeleteOnTermination": "true",
"LaunchTemplateData.BlockDeviceMapping.2.DeviceName": "/dev/sdf",
"LaunchTemplateData.BlockDeviceMapping.2.Ebs.VolumeSize": props["DataVolumeSize"],
"LaunchTemplateData.BlockDeviceMapping.2.Ebs.VolumeType": "gp3",
"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Iops": props["DataVolumeIops"],
"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Throughput": props["DataVolumeThroughput"],
"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Encrypted": "true",
"LaunchTemplateData.BlockDeviceMapping.2.Ebs.DeleteOnTermination": "true",
"TagSpecification.1.ResourceType": "launch-template",
"TagSpecification.1.Tag.1.Key": "Name",
"TagSpecification.1.Tag.1.Value": name,
Expand Down Expand Up @@ -326,7 +399,7 @@ Resources:
return
if request_type == "Update":
delete_launch_template(physical_id)
data = create_launch_template(launch_template_name(event))
data = create_launch_template(launch_template_name(event), event["ResourceProperties"])
send(event, context, "SUCCESS", data, physical_id=data["LaunchTemplateId"])
except Exception as exc:
traceback.print_exc()
Expand All @@ -337,6 +410,10 @@ Resources:
Properties:
ServiceToken: !GetAtt NestedVirtualizationLaunchTemplateFunction.Arn
NamePrefix: hypeman
RootVolumeSize: !Ref RootVolumeSize
DataVolumeSize: !Ref DataVolumeSize
DataVolumeIops: !Ref DataVolumeIops
DataVolumeThroughput: !Ref DataVolumeThroughput

HypemanHost:
Type: AWS::EC2::Instance
Expand All @@ -351,19 +428,6 @@ Resources:
- !Ref HypemanSecurityGroup
IamInstanceProfile: !Ref HypemanInstanceProfile
KeyName: !If [UseSSH, !Ref KeyName, !Ref AWS::NoValue]
BlockDeviceMappings:
- DeviceName: /dev/sda1
Ebs:
VolumeSize: !Ref RootVolumeSize
VolumeType: gp3
Encrypted: true
DeleteOnTermination: true
- DeviceName: /dev/sdf
Ebs:
VolumeSize: !Ref DataVolumeSize
VolumeType: gp3
Encrypted: true
DeleteOnTermination: true
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-hypeman
Expand Down
57 changes: 43 additions & 14 deletions deploy/aws/cloudformation/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ func TestQuickstartParameters(t *testing.T) {
assertDefault(t, parameters, "InstanceType", "c8i.2xlarge")
assertDefault(t, parameters, "AllowedApiCidr", "127.0.0.1/32")
assertDefault(t, parameters, "ApiPort", "8080")
assertDefault(t, parameters, "EnableHttpIngress", "false")
assertDefault(t, parameters, "EnableHttpsIngress", "false")
assertDefault(t, parameters, "AllowedIngressCidr", "127.0.0.1/32")
assertDefault(t, parameters, "EnableSSH", "false")
assertDefault(t, parameters, "AllowedSshCidr", "127.0.0.1/32")
assertDefault(t, parameters, "RootVolumeSize", "30")
assertDefault(t, parameters, "DataVolumeSize", "100")
assertDefault(t, parameters, "DataVolumeIops", "3000")
assertDefault(t, parameters, "DataVolumeThroughput", "125")
assertDefault(t, parameters, "HypemanVersion", "latest")
assertDefault(t, parameters, "HypemanCliVersion", "latest")

Expand All @@ -31,6 +36,10 @@ func TestQuickstartParameters(t *testing.T) {
assertContains(t, scalar(t, apiCidr["Description"]), "current public IP /32")
assertContains(t, scalar(t, apiCidr["Description"]), "avoid 0.0.0.0/0")

ingressCidr := requireMapping(t, parameters["AllowedIngressCidr"])
assertContains(t, scalar(t, ingressCidr["Description"]), "current public IP /32")
assertContains(t, scalar(t, ingressCidr["Description"]), "avoid 0.0.0.0/0")

metadata := requireMapping(t, requireField(t, root, "Metadata"))
cfnInterface := requireMapping(t, requireField(t, metadata, "AWS::CloudFormation::Interface"))
groups := requireSequence(t, requireField(t, cfnInterface, "ParameterGroups"))
Expand All @@ -54,16 +63,19 @@ func TestCloudFormationLaunchContract(t *testing.T) {
securityGroup := requireMapping(t, requireField(t, resources, "HypemanSecurityGroup"))
sgProperties := requireMapping(t, requireField(t, securityGroup, "Properties"))
ingress := requireSequence(t, requireField(t, sgProperties, "SecurityGroupIngress"))
if len(ingress.Content) != 2 {
t.Fatalf("expected API ingress and conditional SSH ingress, got %d entries", len(ingress.Content))
if len(ingress.Content) != 4 {
t.Fatalf("expected API ingress, HTTP ingress, HTTPS ingress, and SSH ingress, got %d entries", len(ingress.Content))
}

apiIngress := requireMapping(t, ingress.Content[0])
assertRef(t, requireField(t, apiIngress, "FromPort"), "ApiPort")
assertRef(t, requireField(t, apiIngress, "ToPort"), "ApiPort")
assertRef(t, requireField(t, apiIngress, "CidrIp"), "AllowedApiCidr")

sshIngress := ingress.Content[1]
assertConditionalIngress(t, ingress.Content[1], "UseHttpIngress", "80", "AllowedIngressCidr")
assertConditionalIngress(t, ingress.Content[2], "UseHttpsIngress", "443", "AllowedIngressCidr")

sshIngress := ingress.Content[3]
if sshIngress.Tag != "!If" {
t.Fatalf("expected SSH ingress to be conditional !If, got %s", sshIngress.Tag)
}
Expand All @@ -87,6 +99,14 @@ func TestCloudFormationLaunchContract(t *testing.T) {
zipFile := scalar(t, requireField(t, code, "ZipFile"))
assertContains(t, zipFile, `"Action": "CreateLaunchTemplate"`)
assertContains(t, zipFile, `"LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled"`)
assertContains(t, zipFile, `"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Iops": props["DataVolumeIops"]`)
assertContains(t, zipFile, `"LaunchTemplateData.BlockDeviceMapping.2.Ebs.Throughput": props["DataVolumeThroughput"]`)

launchTemplateProperties := requireMapping(t, requireField(t, launchTemplate, "Properties"))
assertRef(t, requireField(t, launchTemplateProperties, "RootVolumeSize"), "RootVolumeSize")
assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeSize"), "DataVolumeSize")
assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeIops"), "DataVolumeIops")
assertRef(t, requireField(t, launchTemplateProperties, "DataVolumeThroughput"), "DataVolumeThroughput")

host := requireMapping(t, requireField(t, resources, "HypemanHost"))
if got := scalar(t, requireField(t, host, "Type")); got != "AWS::EC2::Instance" {
Expand All @@ -97,17 +117,6 @@ func TestCloudFormationLaunchContract(t *testing.T) {
assertGetAtt(t, requireField(t, hostLaunchTemplate, "LaunchTemplateId"), "NestedVirtualizationLaunchTemplate.LaunchTemplateId")
assertGetAtt(t, requireField(t, hostLaunchTemplate, "Version"), "NestedVirtualizationLaunchTemplate.VersionNumber")

blockDeviceMappings := requireSequence(t, requireField(t, hostProperties, "BlockDeviceMappings"))
if len(blockDeviceMappings.Content) != 2 {
t.Fatalf("expected root and Hypeman data block device mappings, got %d", len(blockDeviceMappings.Content))
}
dataDevice := requireMapping(t, blockDeviceMappings.Content[1])
if got := scalar(t, requireField(t, dataDevice, "DeviceName")); got != "/dev/sdf" {
t.Fatalf("data device name = %q, want /dev/sdf", got)
}
dataEBS := requireMapping(t, requireField(t, dataDevice, "Ebs"))
assertRef(t, requireField(t, dataEBS, "VolumeSize"), "DataVolumeSize")

userData := nodeText(requireField(t, hostProperties, "UserData"))
assertContains(t, userData, "curl -fsSL https://raw.githubusercontent.com/kernel/hypeman/main/scripts/install.sh | bash")
assertContains(t, userData, "xfsprogs")
Expand Down Expand Up @@ -144,6 +153,26 @@ func TestQuickstartOutputs(t *testing.T) {
assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["CreateTokenCommand"]), "Value")), "hypeman-create-token")
}

func assertConditionalIngress(t *testing.T, node *yaml.Node, condition, port, cidrRef string) {
t.Helper()

if node.Tag != "!If" {
t.Fatalf("expected ingress to be conditional !If, got %s", node.Tag)
}
parts := requireSequence(t, node)
if got := scalar(t, parts.Content[0]); got != condition {
t.Fatalf("expected condition %q, got %q", condition, got)
}
rule := requireMapping(t, parts.Content[1])
if got := scalar(t, requireField(t, rule, "FromPort")); got != port {
t.Fatalf("expected FromPort %s, got %q", port, got)
}
if got := scalar(t, requireField(t, rule, "ToPort")); got != port {
t.Fatalf("expected ToPort %s, got %q", port, got)
}
assertRef(t, requireField(t, rule, "CidrIp"), cidrRef)
}

func loadTemplate(t *testing.T) *yaml.Node {
t.Helper()

Expand Down
Loading