From d0e3f5b4ac3f23865f401036e4367210f8e3e4a8 Mon Sep 17 00:00:00 2001 From: Ismayil Mirzali Date: Mon, 5 May 2025 11:38:18 +0300 Subject: [PATCH 1/3] feat: Add AWS NLB load balancer type * Adds NLB variants of existing classic load balancers * Adds dualstack support on the NLBs * CF TCP LB is now limited to 50 ports, instead of 100 Co-authored-by: Alexander Lais --- cloudconfig/aws/ops_generator.go | 85 +++++--- cloudconfig/aws/ops_generator_test.go | 4 +- commands/commands_usage.go | 6 +- commands/commands_usage_test.go | 6 +- terraform/aws/template_generator.go | 14 +- terraform/aws/template_generator_test.go | 4 +- terraform/aws/templates/cf_dns.tf | 6 +- terraform/aws/templates/cf_lb.tf | 243 ---------------------- terraform/aws/templates/cf_lb_common.tf | 247 +++++++++++++++++++++++ terraform/aws/templates/cf_nlb.tf | 202 ++++++++++++++++++ terraform/aws/templates/iso_segments.tf | 92 ++++++++- 11 files changed, 623 insertions(+), 286 deletions(-) create mode 100644 terraform/aws/templates/cf_lb_common.tf create mode 100644 terraform/aws/templates/cf_nlb.tf diff --git a/cloudconfig/aws/ops_generator.go b/cloudconfig/aws/ops_generator.go index 0c32a1c11..89dfcaeea 100644 --- a/cloudconfig/aws/ops_generator.go +++ b/cloudconfig/aws/ops_generator.go @@ -3,6 +3,7 @@ package aws import ( "errors" "fmt" + "maps" "sort" "strings" @@ -92,6 +93,26 @@ func (o OpsGenerator) GenerateVars(state storage.State) (string, error) { "internal_az_subnet_id_mapping", "internal_az_subnet_cidr_mapping", } + cfRequiredOutputs := []string{ + "cf_router_lb_name", + "cf_router_lb_internal_security_group", + "cf_ssh_lb_name", + "cf_ssh_lb_internal_security_group", + "cf_tcp_lb_name", + "cf_tcp_lb_internal_security_group", + } + dualstackOutput, ok := terraformOutputs.Map["dualstack"] + if !ok { + return "", fmt.Errorf("missing dualstack terraform output") + } + dualstack := dualstackOutput.(bool) + if dualstack { + requiredOutputs = append(requiredOutputs, + "internal_cidr_ipv6", + "internal_az_subnet_ipv6_cidr_mapping", + ) + } + switch state.LB.Type { case "concourse": requiredOutputs = append( @@ -99,16 +120,10 @@ func (o OpsGenerator) GenerateVars(state storage.State) (string, error) { "concourse_lb_target_groups", "concourse_lb_internal_security_group", ) + case "nlb": + fallthrough case "cf": - requiredOutputs = append( - requiredOutputs, - "cf_router_lb_name", - "cf_router_lb_internal_security_group", - "cf_ssh_lb_name", - "cf_ssh_lb_internal_security_group", - "cf_tcp_lb_name", - "cf_tcp_lb_internal_security_group", - ) + requiredOutputs = append(requiredOutputs, cfRequiredOutputs...) } for _, output := range requiredOutputs { @@ -124,21 +139,34 @@ func (o OpsGenerator) GenerateVars(state storage.State) (string, error) { if err != nil { return "", err } + if dualstack { + internalAZSubnetIPv6CIDRMap := terraformOutputs.GetStringMap("internal_az_subnet_ipv6_cidr_mapping") + ipv6AvailabilityZones, err := generateAZs(3, internalAZSubnetIDMap, internalAZSubnetIPv6CIDRMap) + if err != nil { + return "", err + } + azs = append(azs, ipv6AvailabilityZones...) + } varsYAML := map[string]interface{}{} - for k, v := range terraformOutputs.Map { - varsYAML[k] = v - } + maps.Copy(varsYAML, terraformOutputs.Map) + for _, az := range azs { for key, value := range az { varsYAML[key] = value } } - + // TODO: Make the ISO Segments handle IPv6 isoSegAZSubnetIDMap := terraformOutputs.GetStringMap("iso_az_subnet_id_mapping") isoSegAZSubnetCIDRMap := terraformOutputs.GetStringMap("iso_az_subnet_cidr_mapping") if len(isoSegAZSubnetIDMap) > 0 && len(isoSegAZSubnetCIDRMap) > 0 { - isoSegAzs, err := generateAZs(len(azs), isoSegAZSubnetIDMap, isoSegAZSubnetCIDRMap) + // If not running IPv6, start the index after len(azs) many subnets + // If running IPv6, double we need to offset by another len(azs) to accommodate the IPv6 entries + offset := len(azs) + if dualstack { + offset = len(azs) * 2 + } + isoSegAzs, err := generateAZs(offset, isoSegAZSubnetIDMap, isoSegAZSubnetCIDRMap) if err == nil { for _, az := range isoSegAzs { for key, value := range az { @@ -219,7 +247,7 @@ func (o OpsGenerator) generateOps(state storage.State) ([]op, error) { if err != nil { return []op{}, fmt.Errorf("Retrieve availability zones: %s", err) //nolint:staticcheck } - + // This block doesn't seem to handle generating the OPs for isolation segments? for i := range azs { azOp := createOp("replace", "/azs/-", az{ Name: fmt.Sprintf("z%d", i+1), @@ -229,8 +257,15 @@ func (o OpsGenerator) generateOps(state storage.State) ([]op, error) { }) ops = append(ops, azOp) - subnet := generateNetworkSubnet(i) - subnets = append(subnets, subnet) + // IPv4 Subnets don't need offset + ipv4Subnet := generateNetworkSubnet(i, 0) + subnets = append(subnets, ipv4Subnet) + + if state.LB.Type == "nlb" { + // IPv6 subnets need to set the same values as IPv4 for + // AZ name (e.g z1, z2, z3) but require an offset value for templating reasons + subnets = append(subnets, generateNetworkSubnet(i, len(azs))) + } } ops = append(ops, createOp("replace", "/networks/-", network{ @@ -246,6 +281,8 @@ func (o OpsGenerator) generateOps(state storage.State) ([]op, error) { })) switch state.LB.Type { + case "nlb": + fallthrough case "cf": lbSecurityGroups := []map[string]string{ {"name": "cf-router-network-properties", "lb": "((cf_router_lb_name))", "group": "((cf_router_lb_internal_security_group))"}, @@ -307,21 +344,21 @@ func azify(az int, azName, cidr, subnet string) (map[string]string, error) { }, nil } -func generateNetworkSubnet(az int) networkSubnet { +func generateNetworkSubnet(az int, offset int) networkSubnet { az++ return networkSubnet{ AZ: fmt.Sprintf("z%d", az), - Gateway: fmt.Sprintf("((az%d_gateway))", az), - Range: fmt.Sprintf("((az%d_range))", az), + Gateway: fmt.Sprintf("((az%d_gateway))", az+offset), + Range: fmt.Sprintf("((az%d_range))", az+offset), Reserved: []string{ - fmt.Sprintf("((az%d_reserved_1))", az), - fmt.Sprintf("((az%d_reserved_2))", az), + fmt.Sprintf("((az%d_reserved_1))", az+offset), + fmt.Sprintf("((az%d_reserved_2))", az+offset), }, Static: []string{ - fmt.Sprintf("((az%d_static))", az), + fmt.Sprintf("((az%d_static))", az+offset), }, CloudProperties: networkSubnetCloudProperties{ - Subnet: fmt.Sprintf("((az%d_subnet))", az), + Subnet: fmt.Sprintf("((az%d_subnet))", az+offset), SecurityGroups: []string{"((internal_security_group))"}, }, } diff --git a/cloudconfig/aws/ops_generator_test.go b/cloudconfig/aws/ops_generator_test.go index d866273f2..5fc5a87be 100644 --- a/cloudconfig/aws/ops_generator_test.go +++ b/cloudconfig/aws/ops_generator_test.go @@ -47,6 +47,7 @@ var _ = Describe("OpsGenerator", func() { "cf_tcp_lb_internal_security_group": "some-cf-tcp-lb-internal-security-group", "concourse_lb_target_groups": []string{"some-concourse-lb-target-group", "some-other-concourse-lb-target-group"}, "concourse_lb_internal_security_group": "some-concourse-lb-internal-security-group", + "dualstack": false, "internal_az_subnet_id_mapping": map[string]interface{}{ "us-east-1c": "some-internal-subnet-ids-3", "us-east-1a": "some-internal-subnet-ids-1", @@ -136,6 +137,7 @@ cf_tcp_lb_internal_security_group: some-cf-tcp-lb-internal-security-group cf_iso_router_lb_name: some-cf-iso-seg-router-lb-name concourse_lb_target_groups: [some-concourse-lb-target-group, some-other-concourse-lb-target-group] concourse_lb_internal_security_group: some-concourse-lb-internal-security-group +dualstack: false internal_az_subnet_cidr_mapping: us-east-1a: 10.0.16.0/20 us-east-1b: 10.0.32.0/20 @@ -191,7 +193,7 @@ iso_az_subnet_id_mapping: Expect(err).To(MatchError(fmt.Sprintf("missing %s terraform output", outputKey))) }, Entry("when internal_security_group is missing", "internal_security_group", ""), - + Entry("when dualstack is missing", "dualstack", "nlb"), Entry("when internal_az_subnet_id_mapping is missing", "internal_az_subnet_id_mapping", "cf"), Entry("when internal_az_subnet_cidr_mapping is missing", "internal_az_subnet_cidr_mapping", "cf"), Entry("when cf_router_lb_name is missing", "cf_router_lb_name", "cf"), diff --git a/commands/commands_usage.go b/commands/commands_usage.go index 268621eac..7ec678f27 100644 --- a/commands/commands_usage.go +++ b/commands/commands_usage.go @@ -59,9 +59,9 @@ const ( LBUsage = ` Load Balancer options: - --lb-type Load balancer(s) type: "concourse" or "cf" - --lb-cert Path to SSL certificate (supported when type="cf") - --lb-key Path to SSL certificate key (supported when type="cf") + --lb-type Load balancer(s) type: "concourse", "cf", or "nlb" + --lb-cert Path to SSL certificate (supported when type="cf" or "nlb") + --lb-key Path to SSL certificate key (supported when type="cf" or "nlb") --lb-chain Path to SSL certificate chain (supported when iaas="aws") --lb-domain Creates a DNS zone and records for the given domain (supported when type="cf")` diff --git a/commands/commands_usage_test.go b/commands/commands_usage_test.go index 38970e6e8..748e00e24 100644 --- a/commands/commands_usage_test.go +++ b/commands/commands_usage_test.go @@ -69,9 +69,9 @@ var _ = Describe("Commands Usage", func() { --cloudstack-iso-segment CloudStack Activate iso segment env: $BBL_CLOUDSTACK_ISO_SEGMENT Load Balancer options: - --lb-type Load balancer(s) type: "concourse" or "cf" - --lb-cert Path to SSL certificate (supported when type="cf") - --lb-key Path to SSL certificate key (supported when type="cf") + --lb-type Load balancer(s) type: "concourse", "cf", or "nlb" + --lb-cert Path to SSL certificate (supported when type="cf" or "nlb") + --lb-key Path to SSL certificate key (supported when type="cf" or "nlb") --lb-chain Path to SSL certificate chain (supported when iaas="aws") --lb-domain Creates a DNS zone and records for the given domain (supported when type="cf")`)) }) diff --git a/terraform/aws/template_generator.go b/terraform/aws/template_generator.go index ca38c9634..9108ac9b9 100644 --- a/terraform/aws/template_generator.go +++ b/terraform/aws/template_generator.go @@ -13,8 +13,10 @@ type templates struct { iam string lbSubnet string cfLB string + cfNLB string cfDNS string concourseLB string + cfCommon string sslCertificate string isoSeg string vpc string @@ -43,7 +45,13 @@ func (tg TemplateGenerator) Generate(state storage.State) string { case "concourse": template = strings.Join([]string{template, tmpls.lbSubnet, tmpls.concourseLB}, "\n") case "cf": - template = strings.Join([]string{template, tmpls.lbSubnet, tmpls.cfLB, tmpls.sslCertificate, tmpls.isoSeg}, "\n") + template = strings.Join([]string{template, tmpls.lbSubnet, tmpls.cfLB, tmpls.cfCommon, tmpls.sslCertificate, tmpls.isoSeg}, "\n") + + if state.LB.Domain != "" { + template = strings.Join([]string{template, tmpls.cfDNS}, "\n") + } + case "nlb": + template = strings.Join([]string{template, tmpls.lbSubnet, tmpls.cfNLB, tmpls.cfCommon, tmpls.sslCertificate, tmpls.isoSeg}, "\n") if state.LB.Domain != "" { template = strings.Join([]string{template, tmpls.cfDNS}, "\n") @@ -60,6 +68,8 @@ func (t TemplateGenerator) readTemplates() templates { "lb_subnet.tf": "", "cf_lb.tf": "", "cf_dns.tf": "", + "cf_lb_common.tf": "", + "cf_nlb.tf": "", "concourse_lb.tf": "", "ssl_certificate.tf": "", "iso_segments.tf": "", @@ -94,8 +104,10 @@ func (t TemplateGenerator) readTemplates() templates { base: listings["base.tf"], iam: listings["iam.tf"], lbSubnet: listings["lb_subnet.tf"], + cfCommon: listings["cf_lb_common.tf"], cfLB: listings["cf_lb.tf"], cfDNS: listings["cf_dns.tf"], + cfNLB: listings["cf_nlb.tf"], concourseLB: listings["concourse_lb.tf"], sslCertificate: listings["ssl_certificate.tf"], isoSeg: listings["iso_segments.tf"], diff --git a/terraform/aws/template_generator_test.go b/terraform/aws/template_generator_test.go index e9285d1f0..a1cd60f6a 100644 --- a/terraform/aws/template_generator_test.go +++ b/terraform/aws/template_generator_test.go @@ -52,7 +52,7 @@ var _ = Describe("TemplateGenerator", func() { Context("when a CF lb type is provided with no system domain", func() { BeforeEach(func() { - expectedTemplate = expectTemplate("base", "iam", "vpc", "lb_subnet", "cf_lb", "ssl_certificate", "iso_segments") + expectedTemplate = expectTemplate("base", "iam", "vpc", "lb_subnet", "cf_lb", "cf_lb_common", "ssl_certificate", "iso_segments") lb = storage.LB{ Type: "cf", } @@ -65,7 +65,7 @@ var _ = Describe("TemplateGenerator", func() { Context("when a CF lb type is provided with a system domain", func() { BeforeEach(func() { - expectedTemplate = expectTemplate("base", "iam", "vpc", "lb_subnet", "cf_lb", "ssl_certificate", "iso_segments", "cf_dns") + expectedTemplate = expectTemplate("base", "iam", "vpc", "lb_subnet", "cf_lb", "cf_lb_common", "ssl_certificate", "iso_segments", "cf_dns") lb = storage.LB{ Type: "cf", Domain: "some-domain", diff --git a/terraform/aws/templates/cf_dns.tf b/terraform/aws/templates/cf_dns.tf index f2b37b8cb..15a882506 100644 --- a/terraform/aws/templates/cf_dns.tf +++ b/terraform/aws/templates/cf_dns.tf @@ -39,7 +39,7 @@ resource "aws_route53_record" "wildcard_dns" { type = "CNAME" ttl = 300 - records = ["${aws_elb.cf_router_lb.dns_name}"] + records = var.dualstack ? [aws_lb.cf_router_lb.dns_name] : ["${aws_elb.cf_router_lb.dns_name}"] } resource "aws_route53_record" "ssh" { @@ -48,7 +48,7 @@ resource "aws_route53_record" "ssh" { type = "CNAME" ttl = 300 - records = ["${aws_elb.cf_ssh_lb.dns_name}"] + records = var.dualstack ? [aws_lb.cf_ssh_lb.dns_name] : ["${aws_elb.cf_ssh_lb.dns_name}"] } resource "aws_route53_record" "bosh" { @@ -66,7 +66,7 @@ resource "aws_route53_record" "tcp" { type = "CNAME" ttl = 300 - records = ["${aws_elb.cf_tcp_lb.dns_name}"] + records = var.dualstack ? [aws_lb.cf_tcp_lb.dns_name] : ["${aws_elb.cf_tcp_lb.dns_name}"] } resource "aws_route53_record" "iso" { diff --git a/terraform/aws/templates/cf_lb.tf b/terraform/aws/templates/cf_lb.tf index 8521f8cb2..1c9a51303 100644 --- a/terraform/aws/templates/cf_lb.tf +++ b/terraform/aws/templates/cf_lb.tf @@ -1,75 +1,3 @@ -variable "elb_idle_timeout" { - type = number - default = 60 -} - -resource "aws_security_group" "cf_ssh_lb_security_group" { - name = "${var.env_id}-cf-ssh-lb-security-group" - description = "CF SSH" - vpc_id = local.vpc_id - - ingress { - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - protocol = "tcp" - from_port = 2222 - to_port = 2222 - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - } - - tags = { - Name = "${var.env_id}-cf-ssh-lb-security-group" - } - - lifecycle { - ignore_changes = [name] - } -} - -output "cf_ssh_lb_security_group" { - value = aws_security_group.cf_ssh_lb_security_group.id -} - -resource "aws_security_group" "cf_ssh_lb_internal_security_group" { - name = "${var.env_id}-cf-ssh-lb-internal-security-group" - description = "CF SSH Internal" - vpc_id = local.vpc_id - - ingress { - security_groups = ["${aws_security_group.cf_ssh_lb_security_group.id}"] - protocol = "tcp" - from_port = 2222 - to_port = 2222 - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - } - - tags = { - Name = "${var.env_id}-cf-ssh-lb-internal-security-group" - } - - lifecycle { - ignore_changes = [name] - } -} - -output "cf_ssh_lb_internal_security_group" { - value = aws_security_group.cf_ssh_lb_internal_security_group.id -} - resource "aws_elb" "cf_ssh_lb" { name = "${var.short_env_id}-cf-ssh-lb" cross_zone_load_balancing = true @@ -107,88 +35,6 @@ output "cf_ssh_lb_url" { value = aws_elb.cf_ssh_lb.dns_name } -resource "aws_security_group" "cf_router_lb_security_group" { - name = "${var.env_id}-cf-router-lb-security-group" - description = "CF Router" - vpc_id = local.vpc_id - - ingress { - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - protocol = "tcp" - from_port = 80 - to_port = 80 - } - - ingress { - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - protocol = "tcp" - from_port = 443 - to_port = 443 - } - - ingress { - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - protocol = "tcp" - from_port = 4443 - to_port = 4443 - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - } - - tags = { - Name = "${var.env_id}-cf-router-lb-security-group" - } - - lifecycle { - ignore_changes = [name] - } -} - -output "cf_router_lb_security_group" { - value = aws_security_group.cf_router_lb_security_group.id -} - -resource "aws_security_group" "cf_router_lb_internal_security_group" { - name = "${var.env_id}-cf-router-lb-internal-security-group" - description = "CF Router Internal" - vpc_id = local.vpc_id - - ingress { - security_groups = ["${aws_security_group.cf_router_lb_security_group.id}"] - protocol = "tcp" - from_port = 80 - to_port = 80 - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - } - - tags = { - Name = "${var.env_id}-cf-router-lb-internal-security-group" - } - - lifecycle { - ignore_changes = [name] - } -} - -output "cf_router_lb_internal_security_group" { - value = aws_security_group.cf_router_lb_internal_security_group.id -} resource "aws_elb" "cf_router_lb" { name = "${var.short_env_id}-cf-router-lb" @@ -235,21 +81,6 @@ resource "aws_elb" "cf_router_lb" { } } -resource "aws_lb_target_group" "cf_router_4443" { - name = "${var.short_env_id}-routertg-4443" - port = 4443 - protocol = "TCP" - vpc_id = local.vpc_id - - health_check { - protocol = "TCP" - } - - tags = { - Name = "${var.env_id}" - } -} - output "cf_router_lb_name" { value = aws_elb.cf_router_lb.name } @@ -258,80 +89,6 @@ output "cf_router_lb_url" { value = aws_elb.cf_router_lb.dns_name } -resource "aws_security_group" "cf_tcp_lb_security_group" { - name = "${var.env_id}-cf-tcp-lb-security-group" - description = "CF TCP" - vpc_id = local.vpc_id - - ingress { - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - protocol = "tcp" - from_port = 1024 - to_port = 1123 - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - } - - tags = { - Name = "${var.env_id}-cf-tcp-lb-security-group" - } - - lifecycle { - ignore_changes = [name] - } -} - -output "cf_tcp_lb_security_group" { - value = aws_security_group.cf_tcp_lb_security_group.id -} - -resource "aws_security_group" "cf_tcp_lb_internal_security_group" { - name = "${var.env_id}-cf-tcp-lb-internal-security-group" - description = "CF TCP Internal" - vpc_id = local.vpc_id - - ingress { - security_groups = ["${aws_security_group.cf_tcp_lb_security_group.id}"] - protocol = "tcp" - from_port = 1024 - to_port = 1123 - } - - ingress { - security_groups = ["${aws_security_group.cf_tcp_lb_security_group.id}"] - protocol = "tcp" - from_port = 80 - to_port = 80 - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null - } - - tags = { - Name = "${var.env_id}-cf-tcp-lb-security-group" - } - - lifecycle { - ignore_changes = [name] - } -} - -output "cf_tcp_lb_internal_security_group" { - value = aws_security_group.cf_tcp_lb_internal_security_group.id -} - resource "aws_elb" "cf_tcp_lb" { name = "${var.short_env_id}-cf-tcp-lb" cross_zone_load_balancing = true diff --git a/terraform/aws/templates/cf_lb_common.tf b/terraform/aws/templates/cf_lb_common.tf new file mode 100644 index 000000000..46e3dcdfb --- /dev/null +++ b/terraform/aws/templates/cf_lb_common.tf @@ -0,0 +1,247 @@ +variable "elb_idle_timeout" { + type = number + default = 60 +} + +resource "aws_lb_target_group" "cf_router_4443" { + name = "${var.short_env_id}-routertg-4443" + port = 4443 + protocol = "TCP" + vpc_id = local.vpc_id + + health_check { + protocol = "TCP" + } + + tags = { + Name = "${var.env_id}" + } +} + +resource "aws_security_group" "cf_ssh_lb_security_group" { + name = "${var.env_id}-cf-ssh-lb-security-group" + description = "CF SSH" + vpc_id = local.vpc_id + + ingress { + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + protocol = "tcp" + from_port = 2222 + to_port = 2222 + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + } + + tags = { + Name = "${var.env_id}-cf-ssh-lb-security-group" + } + + lifecycle { + ignore_changes = [name] + } +} + +resource "aws_security_group" "cf_ssh_lb_internal_security_group" { + name = "${var.env_id}-cf-ssh-lb-internal-security-group" + description = "CF SSH Internal" + vpc_id = local.vpc_id + + ingress { + security_groups = ["${aws_security_group.cf_ssh_lb_security_group.id}"] + protocol = "tcp" + from_port = 2222 + to_port = 2222 + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + } + + tags = { + Name = "${var.env_id}-cf-ssh-lb-internal-security-group" + } + + lifecycle { + ignore_changes = [name] + } +} + +resource "aws_security_group" "cf_router_lb_security_group" { + name = "${var.env_id}-cf-router-lb-security-group" + description = "CF Router" + vpc_id = local.vpc_id + + ingress { + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + protocol = "tcp" + from_port = 80 + to_port = 80 + } + + ingress { + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + protocol = "tcp" + from_port = 443 + to_port = 443 + } + + ingress { + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + protocol = "tcp" + from_port = 4443 + to_port = 4443 + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + } + + tags = { + Name = "${var.env_id}-cf-router-lb-security-group" + } + + lifecycle { + ignore_changes = [name] + } +} + +resource "aws_security_group" "cf_router_lb_internal_security_group" { + name = "${var.env_id}-cf-router-lb-internal-security-group" + description = "CF Router Internal" + vpc_id = local.vpc_id + + ingress { + security_groups = ["${aws_security_group.cf_router_lb_security_group.id}"] + protocol = "tcp" + from_port = 80 + to_port = 80 + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + } + + tags = { + Name = "${var.env_id}-cf-router-lb-internal-security-group" + } + + lifecycle { + ignore_changes = [name] + } +} + + +resource "aws_security_group" "cf_tcp_lb_security_group" { + name = "${var.env_id}-cf-tcp-lb-security-group" + description = "CF TCP" + vpc_id = local.vpc_id + + ingress { + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + protocol = "tcp" + from_port = 1024 + to_port = 1123 + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + } + + tags = { + Name = "${var.env_id}-cf-tcp-lb-security-group" + } + + lifecycle { + ignore_changes = [name] + } +} + + +resource "aws_security_group" "cf_tcp_lb_internal_security_group" { + name = "${var.env_id}-cf-tcp-lb-internal-security-group" + description = "CF TCP Internal" + vpc_id = local.vpc_id + + ingress { + security_groups = ["${aws_security_group.cf_tcp_lb_security_group.id}"] + protocol = "tcp" + from_port = 1024 + to_port = 1123 + } + + ingress { + security_groups = ["${aws_security_group.cf_tcp_lb_security_group.id}"] + protocol = "tcp" + from_port = 80 + to_port = 80 + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = var.dualstack ? ["::/0"] : null + } + + tags = { + Name = "${var.env_id}-cf-tcp-lb-security-group" + } + + lifecycle { + ignore_changes = [name] + } +} + +output "cf_tcp_lb_security_group" { + value = aws_security_group.cf_tcp_lb_security_group.id +} + +output "cf_tcp_lb_internal_security_group" { + value = aws_security_group.cf_tcp_lb_internal_security_group.id +} + +output "cf_router_lb_internal_security_group" { + value = aws_security_group.cf_router_lb_internal_security_group.id +} + +output "cf_router_lb_security_group" { + value = aws_security_group.cf_router_lb_security_group.id +} + +output "cf_ssh_lb_internal_security_group" { + value = aws_security_group.cf_ssh_lb_internal_security_group.id +} + + +output "cf_ssh_lb_security_group" { + value = aws_security_group.cf_ssh_lb_security_group.id +} + diff --git a/terraform/aws/templates/cf_nlb.tf b/terraform/aws/templates/cf_nlb.tf new file mode 100644 index 000000000..611628188 --- /dev/null +++ b/terraform/aws/templates/cf_nlb.tf @@ -0,0 +1,202 @@ +resource "aws_lb" "cf_ssh_lb" { + name = "${var.short_env_id}-cf-ssh-lb" + internal = false + load_balancer_type = "network" + security_groups = [aws_security_group.cf_ssh_lb_security_group.id] + subnets = [for subnet in aws_subnet.lb_subnets : subnet.id] + + enable_deletion_protection = false + enable_cross_zone_load_balancing = true + + # idle_timeout = var.elb_idle_timeout + ip_address_type = "dualstack" + + tags = { + Name = var.env_id + } +} + +resource "aws_lb" "cf_router_lb" { + name = "${var.short_env_id}-cf-router-lb" + internal = false + load_balancer_type = "network" + security_groups = [aws_security_group.cf_router_lb_security_group.id] + subnets = [for subnet in aws_subnet.lb_subnets : subnet.id] + + enable_deletion_protection = false + enable_cross_zone_load_balancing = true + + # idle_timeout = var.elb_idle_timeout + ip_address_type = "dualstack" + + tags = { + Name = var.env_id + } +} + +resource "aws_lb" "cf_tcp_lb" { + name = "${var.short_env_id}-cf-tcp-lb" + internal = false + load_balancer_type = "network" + security_groups = [aws_security_group.cf_tcp_lb_security_group.id] + subnets = [for subnet in aws_subnet.lb_subnets : subnet.id] + + enable_deletion_protection = false + enable_cross_zone_load_balancing = true + + # idle_timeout = var.elb_idle_timeout + ip_address_type = "dualstack" + + tags = { + Name = var.env_id + } +} + +resource "aws_lb_listener" "cf_tcp_lb" { + for_each = toset([for x in range(1024, 1074, 1) : tostring(x)]) + + load_balancer_arn = aws_lb.cf_tcp_lb.arn + port = each.value + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.cf_tcp_nlb[each.value].arn + } + + depends_on = [ + aws_lb_target_group.cf_tcp_nlb + ] +} + +resource "aws_lb_target_group" "cf_tcp_nlb" { + for_each = toset([for x in range(1024, 1074, 1) : tostring(x)]) + + name = "${var.short_env_id}-cf-tcp-nlb-${each.value}" + port = each.value + protocol = "TCP" + vpc_id = local.vpc_id + + health_check { + healthy_threshold = 6 + unhealthy_threshold = 3 + interval = 15 + protocol = "TCP" + port = 80 + } + + tags = { + Name = "${var.env_id}-${each.value}" + } +} + +resource "aws_lb_target_group" "cf_ssh_nlb" { + name = "${var.short_env_id}-cf-ssh-nlb" + port = 2222 + protocol = "TCP" + vpc_id = local.vpc_id + + health_check { + healthy_threshold = 5 + unhealthy_threshold = 2 + interval = 12 + protocol = "TCP" + port = 2222 + } + + tags = { + Name = "${var.env_id}" + } +} + + +resource "aws_lb_target_group" "cf_router_nlb" { + name = "${var.short_env_id}-cf-router-nlb" + port = 80 + protocol = "TCP" + vpc_id = local.vpc_id + + health_check { + healthy_threshold = 5 + unhealthy_threshold = 2 + interval = 15 + protocol = "TCP" + port = 80 + } + + tags = { + Name = "${var.env_id}" + } +} + +resource "aws_lb_listener" "cf_ssh" { + load_balancer_arn = aws_lb.cf_ssh_lb.arn + port = "2222" + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.cf_ssh_nlb.arn + } +} + +resource "aws_lb_listener" "cf_router_http" { + load_balancer_arn = aws_lb.cf_router_lb.arn + port = "80" + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.cf_router_nlb.arn + } +} + +resource "aws_lb_listener" "cf_router_https" { + load_balancer_arn = aws_lb.cf_router_lb.arn + port = "443" + protocol = "TLS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = aws_iam_server_certificate.lb_cert.arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.cf_router_nlb.arn + } +} + +resource "aws_lb_listener" "cf_router_4443" { + load_balancer_arn = aws_lb.cf_router_lb.arn + port = "4443" + protocol = "TLS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = aws_iam_server_certificate.lb_cert.arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.cf_router_nlb.arn + } +} + +output "cf_ssh_lb_name" { + value = aws_lb.cf_ssh_lb.name +} + +output "cf_ssh_lb_url" { + value = aws_lb.cf_ssh_lb.dns_name +} + +output "cf_router_lb_name" { + value = aws_lb.cf_router_lb.name +} + +output "cf_router_lb_url" { + value = aws_lb.cf_router_lb.dns_name +} + +output "cf_tcp_lb_name" { + value = aws_lb.cf_tcp_lb.name +} + +output "cf_tcp_lb_url" { + value = aws_lb.cf_tcp_lb.dns_name +} diff --git a/terraform/aws/templates/iso_segments.tf b/terraform/aws/templates/iso_segments.tf index fc9945dfa..c06a429a4 100644 --- a/terraform/aws/templates/iso_segments.tf +++ b/terraform/aws/templates/iso_segments.tf @@ -5,17 +5,17 @@ variable "isolation_segments" { } variable "iso_to_bosh_ports" { - type = list(any) + type = list(number) default = [22, 6868, 2555, 4222, 25250] } variable "iso_to_shared_tcp_ports" { - type = list(any) + type = list(number) default = [9090, 9091, 8082, 8300, 8301, 8889, 8443, 3000, 4443, 8080, 3457, 9023, 9022, 4222] } variable "iso_to_shared_udp_ports" { - type = list(any) + type = list(number) default = [8301, 8302, 8600] } @@ -39,12 +39,13 @@ resource "aws_subnet" "iso_subnets" { resource "aws_route_table_association" "route_iso_subnets" { count = local.iso_az_count - subnet_id = element(aws_subnet.iso_subnets.*.id, count.index) + subnet_id = aws_subnet.iso_subnets[count.index].id route_table_id = aws_route_table.nated_route_table.id } + resource "aws_elb" "iso_router_lb" { - count = var.isolation_segments + count = var.isolation_segments == "1" && var.dualstack == false ? 1 : 0 name = "${var.short_env_id}-iso-router-lb" cross_zone_load_balancing = true @@ -88,6 +89,85 @@ resource "aws_elb" "iso_router_lb" { } } +resource "aws_lb" "iso_router_nlb" { + count = var.isolation_segments == "1" && var.dualstack ? 1 : 0 + name = "${var.short_env_id}-iso-router-lb" + internal = false + load_balancer_type = "network" + security_groups = [aws_security_group.cf_router_lb_security_group.id] + subnets = [for subnet in aws_subnet.lb_subnets : subnet.id] + + enable_deletion_protection = false + enable_cross_zone_load_balancing = true + + # idle_timeout = var.elb_idle_timeout + ip_address_type = "dualstack" + + tags = { + Name = var.env_id + } +} + +resource "aws_lb_target_group" "iso_router_nlb_http" { + count = var.isolation_segments == "1" && var.dualstack ? 1 : 0 + name = "${var.short_env_id}-iso-router-nlb-http" + port = 80 + protocol = "HTTP" + vpc_id = local.vpc_id + + health_check { + healthy_threshold = 5 + unhealthy_threshold = 2 + interval = 15 + protocol = "TCP" + port = 80 + } + + tags = { + Name = "${var.env_id}" + } +} + +resource "aws_lb_listener" "iso_router_nlb_http" { + count = var.isolation_segments == "1" && var.dualstack ? 1 : 0 + load_balancer_arn = aws_lb.iso_router_nlb[0].arn + port = "80" + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.iso_router_nlb_http[0].arn + } +} + +resource "aws_lb_listener" "iso_router_nlb_https" { + count = var.isolation_segments == "1" && var.dualstack ? 1 : 0 + load_balancer_arn = aws_lb.iso_router_nlb[0].arn + port = "443" + protocol = "TLS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = aws_iam_server_certificate.lb_cert.arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.iso_router_nlb_http[0].arn + } +} + +resource "aws_lb_listener" "iso_router_nlb_4443" { + count = var.isolation_segments == "1" && var.dualstack ? 1 : 0 + load_balancer_arn = aws_lb.iso_router_nlb[0].arn + port = "4443" + protocol = "TLS" + ssl_policy = "ELBSecurityPolicy-2016-08" + certificate_arn = aws_iam_server_certificate.lb_cert.arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.iso_router_nlb_http[0].arn + } +} + resource "aws_lb_target_group" "iso_router_lb_4443" { count = var.isolation_segments name = "${var.short_env_id}-isotg-4443" @@ -211,7 +291,7 @@ resource "aws_security_group_rule" "nat_to_isolated_cells_rule" { } output "cf_iso_router_lb_name" { - value = one(aws_elb.iso_router_lb[*].name) + value = var.dualstack ? one(aws_lb.iso_router_nlb[*].name) : one(aws_elb.iso_router_lb[*].name) } output "iso_security_group_id" { From d5b0092e49f3c51203b46344685f7586d8cb7e06 Mon Sep 17 00:00:00 2001 From: Alexander Lais Date: Mon, 23 Mar 2026 12:15:43 +0100 Subject: [PATCH 2/3] feat: Add --dual-stack flag for IPv6 dual-stack networking Enable IPv6 dual-stack on AWS via --dual-stack flag on bbl plan/up. Requires --lb-type=nlb, as classic ELBs do not support IPv6. Configures IPv6 CIDRs on all VPCs and subnets, conditional dualstack on NLBs, and separate private_v6/default_v6 BOSH cloud-config networks. BOSH manages IPv6 addressing explicitly. --- .gitignore | 1 + cloudconfig/aws/ops_generator.go | 108 ++++++++++++++++++----- cloudconfig/aws/ops_generator_test.go | 56 ++++++++++++ commands/commands_usage.go | 3 +- commands/commands_usage_test.go | 3 +- commands/lb_args_handler.go | 20 +++-- commands/lb_args_handler_test.go | 79 ++++++++++++++--- commands/plan.go | 1 + commands/plan_test.go | 46 ++++++++++ storage/lb.go | 11 +-- terraform/aws/input_generator.go | 4 +- terraform/aws/input_generator_test.go | 63 +++++++++++++ terraform/aws/template_generator_test.go | 27 ++++++ terraform/aws/templates/base.tf | 4 +- terraform/aws/templates/cf_nlb.tf | 6 +- terraform/aws/templates/iso_segments.tf | 2 +- terraform/aws/templates/lb_subnet.tf | 2 +- 17 files changed, 379 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 3380d0f8f..fc83868fa 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ bbl/debug # packrd **/*-packr.go bbl/packrd +bbl-env diff --git a/cloudconfig/aws/ops_generator.go b/cloudconfig/aws/ops_generator.go index 89dfcaeea..0a0cc0ded 100644 --- a/cloudconfig/aws/ops_generator.go +++ b/cloudconfig/aws/ops_generator.go @@ -141,11 +141,22 @@ func (o OpsGenerator) GenerateVars(state storage.State) (string, error) { } if dualstack { internalAZSubnetIPv6CIDRMap := terraformOutputs.GetStringMap("internal_az_subnet_ipv6_cidr_mapping") - ipv6AvailabilityZones, err := generateAZs(3, internalAZSubnetIDMap, internalAZSubnetIPv6CIDRMap) - if err != nil { - return "", err + var azNames []string + for azName := range internalAZSubnetIDMap { + azNames = append(azNames, azName) + } + sort.Strings(azNames) + for azIndex, azName := range azNames { + cidr, ok := internalAZSubnetIPv6CIDRMap[azName] + if !ok { + return "", errors.New("missing AZ in terraform output: internal_az_subnet_ipv6_cidr_mapping") + } + v6Vars, err := azifyV6(azIndex, cidr) + if err != nil { + return "", err + } + azs = append(azs, v6Vars) } - azs = append(azs, ipv6AvailabilityZones...) } varsYAML := map[string]interface{}{} @@ -160,12 +171,8 @@ func (o OpsGenerator) GenerateVars(state storage.State) (string, error) { isoSegAZSubnetIDMap := terraformOutputs.GetStringMap("iso_az_subnet_id_mapping") isoSegAZSubnetCIDRMap := terraformOutputs.GetStringMap("iso_az_subnet_cidr_mapping") if len(isoSegAZSubnetIDMap) > 0 && len(isoSegAZSubnetCIDRMap) > 0 { - // If not running IPv6, start the index after len(azs) many subnets - // If running IPv6, double we need to offset by another len(azs) to accommodate the IPv6 entries - offset := len(azs) - if dualstack { - offset = len(azs) * 2 - } + // Iso segment AZ indices start after the internal AZs + offset := len(internalAZSubnetIDMap) isoSegAzs, err := generateAZs(offset, isoSegAZSubnetIDMap, isoSegAZSubnetCIDRMap) if err == nil { for _, az := range isoSegAzs { @@ -242,6 +249,7 @@ func createOp(opType, opPath string, value interface{}) op { func (o OpsGenerator) generateOps(state storage.State) ([]op, error) { ops := []op{} subnets := []networkSubnet{} + subnetsV6 := []networkSubnet{} azs, err := o.availabilityZones.RetrieveAZs(state.AWS.Region) if err != nil { @@ -257,14 +265,10 @@ func (o OpsGenerator) generateOps(state storage.State) ([]op, error) { }) ops = append(ops, azOp) - // IPv4 Subnets don't need offset - ipv4Subnet := generateNetworkSubnet(i, 0) - subnets = append(subnets, ipv4Subnet) + subnets = append(subnets, generateNetworkSubnet(i)) - if state.LB.Type == "nlb" { - // IPv6 subnets need to set the same values as IPv4 for - // AZ name (e.g z1, z2, z3) but require an offset value for templating reasons - subnets = append(subnets, generateNetworkSubnet(i, len(azs))) + if state.LB.DualStack { + subnetsV6 = append(subnetsV6, generateNetworkSubnetV6(i)) } } @@ -280,6 +284,20 @@ func (o OpsGenerator) generateOps(state storage.State) ([]op, error) { Type: "manual", })) + if state.LB.DualStack { + ops = append(ops, createOp("replace", "/networks/-", network{ + Name: "private_v6", + Subnets: subnetsV6, + Type: "manual", + })) + + ops = append(ops, createOp("replace", "/networks/-", network{ + Name: "default_v6", + Subnets: subnetsV6, + Type: "manual", + })) + } + switch state.LB.Type { case "nlb": fallthrough @@ -344,21 +362,63 @@ func azify(az int, azName, cidr, subnet string) (map[string]string, error) { }, nil } -func generateNetworkSubnet(az int, offset int) networkSubnet { +func azifyV6(az int, cidr string) (map[string]string, error) { + parsedCidr, err := bosh.ParseCIDRBlock(cidr) + if err != nil { + return map[string]string{}, err + } + + gateway := parsedCidr.GetNthIP(1).String() + firstReserved := parsedCidr.GetNthIP(2).String() + secondReserved := parsedCidr.GetNthIP(3).String() + lastReserved := parsedCidr.GetLastIP().String() + lastStatic := parsedCidr.GetLastIP().Subtract(1).String() + firstStatic := parsedCidr.GetLastIP().Subtract(65).String() + + return map[string]string{ + fmt.Sprintf("az%d_gateway_v6", az+1): gateway, + fmt.Sprintf("az%d_range_v6", az+1): cidr, + fmt.Sprintf("az%d_reserved_1_v6", az+1): fmt.Sprintf("%s-%s", firstReserved, secondReserved), + fmt.Sprintf("az%d_reserved_2_v6", az+1): lastReserved, + fmt.Sprintf("az%d_static_v6", az+1): fmt.Sprintf("%s-%s", firstStatic, lastStatic), + }, nil +} + +func generateNetworkSubnet(az int) networkSubnet { + az++ + return networkSubnet{ + AZ: fmt.Sprintf("z%d", az), + Gateway: fmt.Sprintf("((az%d_gateway))", az), + Range: fmt.Sprintf("((az%d_range))", az), + Reserved: []string{ + fmt.Sprintf("((az%d_reserved_1))", az), + fmt.Sprintf("((az%d_reserved_2))", az), + }, + Static: []string{ + fmt.Sprintf("((az%d_static))", az), + }, + CloudProperties: networkSubnetCloudProperties{ + Subnet: fmt.Sprintf("((az%d_subnet))", az), + SecurityGroups: []string{"((internal_security_group))"}, + }, + } +} + +func generateNetworkSubnetV6(az int) networkSubnet { az++ return networkSubnet{ AZ: fmt.Sprintf("z%d", az), - Gateway: fmt.Sprintf("((az%d_gateway))", az+offset), - Range: fmt.Sprintf("((az%d_range))", az+offset), + Gateway: fmt.Sprintf("((az%d_gateway_v6))", az), + Range: fmt.Sprintf("((az%d_range_v6))", az), Reserved: []string{ - fmt.Sprintf("((az%d_reserved_1))", az+offset), - fmt.Sprintf("((az%d_reserved_2))", az+offset), + fmt.Sprintf("((az%d_reserved_1_v6))", az), + fmt.Sprintf("((az%d_reserved_2_v6))", az), }, Static: []string{ - fmt.Sprintf("((az%d_static))", az+offset), + fmt.Sprintf("((az%d_static_v6))", az), }, CloudProperties: networkSubnetCloudProperties{ - Subnet: fmt.Sprintf("((az%d_subnet))", az+offset), + Subnet: fmt.Sprintf("((az%d_subnet))", az), SecurityGroups: []string{"((internal_security_group))"}, }, } diff --git a/cloudconfig/aws/ops_generator_test.go b/cloudconfig/aws/ops_generator_test.go index 5fc5a87be..c410793c1 100644 --- a/cloudconfig/aws/ops_generator_test.go +++ b/cloudconfig/aws/ops_generator_test.go @@ -158,6 +158,36 @@ iso_az_subnet_id_mapping: `)) }) + Context("when dualstack is enabled", func() { + BeforeEach(func() { + terraformManager.GetOutputsCall.Returns.Outputs.Map["dualstack"] = true + terraformManager.GetOutputsCall.Returns.Outputs.Map["internal_cidr_ipv6"] = "2600:1f18::/56" + terraformManager.GetOutputsCall.Returns.Outputs.Map["internal_az_subnet_ipv6_cidr_mapping"] = map[string]interface{}{ + "us-east-1a": "2600:1f18:0:1::/64", + "us-east-1b": "2600:1f18:0:2::/64", + "us-east-1c": "2600:1f18:0:3::/64", + } + }) + + It("includes _v6 suffixed variables for each AZ", func() { + varsYAML, err := opsGenerator.GenerateVars(incomingState) + Expect(err).NotTo(HaveOccurred()) + + // IPv4 vars still present + Expect(varsYAML).To(ContainSubstring("az1_gateway: 10.0.16.1")) + Expect(varsYAML).To(ContainSubstring("az1_subnet: some-internal-subnet-ids-1")) + + // IPv6 _v6 vars present + Expect(varsYAML).To(ContainSubstring("az1_gateway_v6: 2600:1f18:0:1::1")) + Expect(varsYAML).To(ContainSubstring("az1_range_v6: 2600:1f18:0:1::/64")) + Expect(varsYAML).To(ContainSubstring("az2_gateway_v6: 2600:1f18:0:2::1")) + Expect(varsYAML).To(ContainSubstring("az3_gateway_v6: 2600:1f18:0:3::1")) + + // No offset-based az4+ IPv6 entries + Expect(varsYAML).NotTo(ContainSubstring("az4_gateway_v6")) + }) + }) + Context("failure cases", func() { Context("when the az subnet id map has a key not in the cidr map", func() { BeforeEach(func() { @@ -249,6 +279,32 @@ iso_az_subnet_id_mapping: }) }) + Context("when there are cf lbs with dualstack", func() { + It("generates ops with separate IPv6 networks", func() { + incomingState.LB.Type = "cf" + incomingState.LB.DualStack = true + opsYAML, err := opsGenerator.Generate(incomingState) + Expect(err).NotTo(HaveOccurred()) + + // IPv6 subnets are in separate networks, not mixed into private/default + Expect(opsYAML).To(ContainSubstring("name: private_v6")) + Expect(opsYAML).To(ContainSubstring("name: default_v6")) + + // IPv6 networks use _v6 variable references + numAZs := len(availabilityZones.RetrieveAZsCall.Returns.AZs) + for i := range numAZs { + az := fmt.Sprintf("az%d", i+1) + Expect(opsYAML).To(ContainSubstring(fmt.Sprintf("((%s_gateway_v6))", az))) + Expect(opsYAML).To(ContainSubstring(fmt.Sprintf("((%s_range_v6))", az))) + Expect(opsYAML).To(ContainSubstring(fmt.Sprintf("((%s_reserved_1_v6))", az))) + Expect(opsYAML).To(ContainSubstring(fmt.Sprintf("((%s_static_v6))", az))) + } + + // IPv6 subnets share the same AWS subnet as IPv4 + Expect(opsYAML).To(ContainSubstring("((az1_subnet))")) + }) + }) + Context("when there is a concourse lb", func() { BeforeEach(func() { baseOpsYAMLContents, err := os.ReadFile(filepath.Join("fixtures", "aws-ops.yml")) diff --git a/commands/commands_usage.go b/commands/commands_usage.go index 7ec678f27..156803e3a 100644 --- a/commands/commands_usage.go +++ b/commands/commands_usage.go @@ -63,7 +63,8 @@ const ( --lb-cert Path to SSL certificate (supported when type="cf" or "nlb") --lb-key Path to SSL certificate key (supported when type="cf" or "nlb") --lb-chain Path to SSL certificate chain (supported when iaas="aws") - --lb-domain Creates a DNS zone and records for the given domain (supported when type="cf")` + --lb-domain Creates a DNS zone and records for the given domain (supported when type="cf") + --dual-stack Enable dual-stack (IPv4+IPv6) networking (currently AWS only, requires --lb-type=nlb)` PlanCommandUsage = `Populates a state directory with the latest config without applying it diff --git a/commands/commands_usage_test.go b/commands/commands_usage_test.go index 748e00e24..b01788937 100644 --- a/commands/commands_usage_test.go +++ b/commands/commands_usage_test.go @@ -73,7 +73,8 @@ var _ = Describe("Commands Usage", func() { --lb-cert Path to SSL certificate (supported when type="cf" or "nlb") --lb-key Path to SSL certificate key (supported when type="cf" or "nlb") --lb-chain Path to SSL certificate chain (supported when iaas="aws") - --lb-domain Creates a DNS zone and records for the given domain (supported when type="cf")`)) + --lb-domain Creates a DNS zone and records for the given domain (supported when type="cf") + --dual-stack Enable dual-stack (IPv4+IPv6) networking (currently AWS only, requires --lb-type=nlb)`)) }) }) }) diff --git a/commands/lb_args_handler.go b/commands/lb_args_handler.go index fc6bdb88e..db949d867 100644 --- a/commands/lb_args_handler.go +++ b/commands/lb_args_handler.go @@ -19,6 +19,7 @@ type LBArgs struct { KeyPath string ChainPath string Domain string + DualStack bool } func NewLBArgsHandler(certificateValidator certificateValidator) LBArgsHandler { @@ -28,6 +29,10 @@ func NewLBArgsHandler(certificateValidator certificateValidator) LBArgsHandler { } func (l LBArgsHandler) GetLBState(iaas string, args LBArgs) (storage.LB, error) { + if args.DualStack && !(iaas == "aws" && args.LBType == "nlb") { + return storage.LB{}, errors.New("dual stack networking requires AWS with the 'nlb' load balancer type. Set --lb-type=nlb on AWS, or remove the --dual-stack flag.") //nolint:staticcheck + } + if args.LBType == "" { return storage.LB{}, nil } @@ -61,11 +66,12 @@ func (l LBArgsHandler) GetLBState(iaas string, args LBArgs) (storage.LB, error) } return storage.LB{ - Type: args.LBType, - Cert: string(certData.Cert), - Key: string(certData.Key), - Chain: string(certData.Chain), - Domain: args.Domain, + Type: args.LBType, + Cert: string(certData.Cert), + Key: string(certData.Key), + Chain: string(certData.Chain), + Domain: args.Domain, + DualStack: args.DualStack, }, nil } @@ -78,6 +84,10 @@ func (l LBArgsHandler) Merge(new storage.LB, old storage.LB) storage.LB { if new.Type == "" { new.Type = old.Type } + + if !new.DualStack { + new.DualStack = old.DualStack + } } return new diff --git a/commands/lb_args_handler_test.go b/commands/lb_args_handler_test.go index 78623b3ce..82faaf295 100644 --- a/commands/lb_args_handler_test.go +++ b/commands/lb_args_handler_test.go @@ -66,6 +66,7 @@ var _ = Describe("LBArgsHandler", func() { Expect(lbState.Key).To(Equal("")) Expect(lbState.Chain).To(Equal("")) Expect(lbState.Domain).To(Equal("")) + Expect(lbState.DualStack).To(BeFalse()) Expect(certificateValidator.ReadAndValidateCall.CallCount).To(Equal(0)) }) }) @@ -80,6 +81,24 @@ var _ = Describe("LBArgsHandler", func() { }) }) + Context("when lb type is nlb with dualstack", func() { + It("returns a storage.LB object with DualStack set", func() { + lbState, err := handler.GetLBState("aws", commands.LBArgs{ + LBType: "nlb", + CertPath: "/path/to/cert", + KeyPath: "/path/to/key", + ChainPath: "/path/to/chain", + DualStack: true, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(lbState.Type).To(Equal("nlb")) + Expect(lbState.Cert).To(Equal("some-cert")) + Expect(lbState.Key).To(Equal("some-key")) + Expect(lbState.Chain).To(Equal("some-chain")) + Expect(lbState.DualStack).To(BeTrue()) + }) + }) + Context("when iaas is azure and lb type is cf", func() { BeforeEach(func() { certDataPKCS12 := certs.CertData{ @@ -137,6 +156,37 @@ var _ = Describe("LBArgsHandler", func() { Expect(err).To(MatchError("domain is not implemented for concourse load balancers. Remove the --lb-domain flag and try again.")) }) }) + + Context("when dualstack is set on non-AWS IaaS", func() { + It("returns an error", func() { + _, err := handler.GetLBState("gcp", commands.LBArgs{ + LBType: "nlb", + DualStack: true, + }) + Expect(err).To(MatchError("dual stack networking requires AWS with the 'nlb' load balancer type. Set --lb-type=nlb on AWS, or remove the --dual-stack flag.")) + }) + }) + + Context("when dualstack is set without nlb type", func() { + It("returns an error", func() { + _, err := handler.GetLBState("aws", commands.LBArgs{ + LBType: "cf", + CertPath: "/path/to/cert", + KeyPath: "/path/to/key", + DualStack: true, + }) + Expect(err).To(MatchError("dual stack networking requires AWS with the 'nlb' load balancer type. Set --lb-type=nlb on AWS, or remove the --dual-stack flag.")) + }) + }) + + Context("when dualstack is set with empty lb type", func() { + It("returns an error", func() { + _, err := handler.GetLBState("aws", commands.LBArgs{ + DualStack: true, + }) + Expect(err).To(MatchError("dual stack networking requires AWS with the 'nlb' load balancer type. Set --lb-type=nlb on AWS, or remove the --dual-stack flag.")) + }) + }) }) }) @@ -146,18 +196,20 @@ var _ = Describe("LBArgsHandler", func() { BeforeEach(func() { new = storage.LB{ - Type: "new-type", - Cert: "new-cert", - Key: "new-key", - Chain: "new-chain", - Domain: "new-domain", + Type: "new-type", + Cert: "new-cert", + Key: "new-key", + Chain: "new-chain", + Domain: "new-domain", + DualStack: true, } old = storage.LB{ - Type: "old-type", - Cert: "old-cert", - Key: "old-key", - Chain: "old-chain", - Domain: "old-domain", + Type: "old-type", + Cert: "old-cert", + Key: "old-key", + Chain: "old-chain", + Domain: "old-domain", + DualStack: true, } }) @@ -176,11 +228,12 @@ var _ = Describe("LBArgsHandler", func() { }) Context("when the new state is empty", func() { - It("keeps the old domain and type", func() { + It("keeps the old domain, type, and dualstack", func() { merged := handler.Merge(storage.LB{}, old) Expect(merged).To(Equal(storage.LB{ - Type: "old-type", - Domain: "old-domain", + Type: "old-type", + Domain: "old-domain", + DualStack: true, })) }) }) diff --git a/commands/plan.go b/commands/plan.go index 7a06fba21..9981c89b0 100644 --- a/commands/plan.go +++ b/commands/plan.go @@ -88,6 +88,7 @@ func (p Plan) ParseArgs(args []string, state storage.State) (PlanConfig, error) planFlags.String(&lbArgs.CertPath, "lb-cert", "") planFlags.String(&lbArgs.KeyPath, "lb-key", "") planFlags.String(&lbArgs.Domain, "lb-domain", "") + planFlags.Bool(&lbArgs.DualStack, "dual-stack") if state.IAAS == "aws" { planFlags.String(&lbArgs.ChainPath, "lb-chain", "") } diff --git a/commands/plan_test.go b/commands/plan_test.go index 2202e9679..db0ebeaf3 100644 --- a/commands/plan_test.go +++ b/commands/plan_test.go @@ -136,6 +136,28 @@ var _ = Describe("Plan", func() { Expect(envIDManager.SyncCall.Receives.State.LB).To(Equal(lb)) }) }) + + Context("aws with dual-stack", func() { + It("sets LB args with DualStack on the state", func() { + err := command.Execute( + []string{ + "--lb-type", "nlb", + "--lb-cert", "cert", + "--lb-key", "key", + "--lb-chain", "chain", + "--dual-stack", + }, storage.State{IAAS: "aws"}) + Expect(err).NotTo(HaveOccurred()) + Expect(lbArgsHandler.GetLBStateCall.CallCount).To(Equal(1)) + Expect(lbArgsHandler.GetLBStateCall.Receives.Args).To(Equal(commands.LBArgs{ + LBType: "nlb", + CertPath: "cert", + KeyPath: "key", + ChainPath: "chain", + DualStack: true, + })) + }) + }) }) Describe("failure cases", func() { @@ -322,6 +344,30 @@ var _ = Describe("Plan", func() { }) }) + Context("aws with dual-stack", func() { + It("sets LB args with DualStack on the state", func() { + config, err := command.ParseArgs( + []string{ + "--lb-type", "nlb", + "--lb-cert", "cert", + "--lb-key", "key", + "--lb-chain", "chain", + "--dual-stack", + }, storage.State{IAAS: "aws"}) + Expect(err).NotTo(HaveOccurred()) + Expect(lbArgsHandler.GetLBStateCall.CallCount).To(Equal(1)) + Expect(lbArgsHandler.GetLBStateCall.Receives.Args).To(Equal(commands.LBArgs{ + LBType: "nlb", + CertPath: "cert", + KeyPath: "key", + ChainPath: "chain", + DualStack: true, + })) + + Expect(config.LB).To(Equal(lb)) + }) + }) + Context("gcp", func() { It("doesn't use --lb-chain", func() { _, err := command.ParseArgs( diff --git a/storage/lb.go b/storage/lb.go index 70fae2162..d636fb996 100644 --- a/storage/lb.go +++ b/storage/lb.go @@ -1,9 +1,10 @@ package storage type LB struct { - Type string `json:"type"` - Cert string `json:"cert"` - Key string `json:"key"` - Chain string `json:"chain"` - Domain string `json:"domain,omitempty"` + Type string `json:"type"` + Cert string `json:"cert"` + Key string `json:"key"` + Chain string `json:"chain"` + Domain string `json:"domain,omitempty"` + DualStack bool `json:"dual_stack,omitempty"` } diff --git a/terraform/aws/input_generator.go b/terraform/aws/input_generator.go index db9a30b1e..694dce8d6 100644 --- a/terraform/aws/input_generator.go +++ b/terraform/aws/input_generator.go @@ -44,7 +44,7 @@ func (i InputGenerator) Generate(state storage.State) (map[string]interface{}, e "availability_zones": azs, } - if state.LB.Type == "cf" { + if state.LB.Type == "cf" || state.LB.Type == "nlb" { inputs["ssl_certificate"] = state.LB.Cert inputs["ssl_certificate_private_key"] = state.LB.Key inputs["ssl_certificate_chain"] = state.LB.Chain @@ -59,6 +59,8 @@ func (i InputGenerator) Generate(state storage.State) (map[string]interface{}, e } } + inputs["dualstack"] = state.LB.DualStack + return inputs, nil } diff --git a/terraform/aws/input_generator_test.go b/terraform/aws/input_generator_test.go index dcdc459eb..3355a236c 100644 --- a/terraform/aws/input_generator_test.go +++ b/terraform/aws/input_generator_test.go @@ -64,6 +64,7 @@ var _ = Describe("InputGenerator", func() { "short_env_id": "some-env-id", "region": "some-region", "availability_zones": []string{"z1", "z2", "z3"}, + "dualstack": false, })) }) @@ -102,6 +103,7 @@ var _ = Describe("InputGenerator", func() { "ssl_certificate": "some-cert", "ssl_certificate_chain": "some-chain", "ssl_certificate_private_key": "some-key", + "dualstack": false, })) }) @@ -128,6 +130,67 @@ var _ = Describe("InputGenerator", func() { "ssl_certificate_private_key": "some-key", "system_domain": "some-domain", "parent_zone": "zone-id", + "dualstack": false, + })) + }) + }) + }) + + Context("when an nlb lb exists", func() { + var state storage.State + + BeforeEach(func() { + state = storage.State{ + IAAS: "aws", + EnvID: "some-env-id", + AWS: storage.AWS{ + AccessKeyID: "some-access-key-id", + SecretAccessKey: "some-secret-access-key", + Region: "some-region", + }, + LB: storage.LB{ + Type: "nlb", + Cert: "some-cert", + Chain: "some-chain", + Key: "some-key", + }, + } + }) + + It("returns a map with ssl certificate inputs and dualstack false", func() { + inputs, err := inputGenerator.Generate(state) + Expect(err).NotTo(HaveOccurred()) + + Expect(inputs).To(Equal(map[string]interface{}{ + "env_id": "some-env-id", + "short_env_id": "some-env-id", + "region": "some-region", + "availability_zones": []string{"z1", "z2", "z3"}, + "ssl_certificate": "some-cert", + "ssl_certificate_chain": "some-chain", + "ssl_certificate_private_key": "some-key", + "dualstack": false, + })) + }) + + Context("when dualstack is enabled", func() { + BeforeEach(func() { + state.LB.DualStack = true + }) + + It("returns a map with dualstack true", func() { + inputs, err := inputGenerator.Generate(state) + Expect(err).NotTo(HaveOccurred()) + + Expect(inputs).To(Equal(map[string]interface{}{ + "env_id": "some-env-id", + "short_env_id": "some-env-id", + "region": "some-region", + "availability_zones": []string{"z1", "z2", "z3"}, + "ssl_certificate": "some-cert", + "ssl_certificate_chain": "some-chain", + "ssl_certificate_private_key": "some-key", + "dualstack": true, })) }) }) diff --git a/terraform/aws/template_generator_test.go b/terraform/aws/template_generator_test.go index a1cd60f6a..e27265453 100644 --- a/terraform/aws/template_generator_test.go +++ b/terraform/aws/template_generator_test.go @@ -76,6 +76,33 @@ var _ = Describe("TemplateGenerator", func() { checkTemplate(template, expectedTemplate) }) }) + + Context("when an NLB lb type is provided with no system domain", func() { + BeforeEach(func() { + expectedTemplate = expectTemplate("base", "iam", "vpc", "lb_subnet", "cf_nlb", "cf_lb_common", "ssl_certificate", "iso_segments") + lb = storage.LB{ + Type: "nlb", + } + }) + It("adds the lb subnet, cf nlb, ssl cert and iso seg to the base template", func() { + template := templateGenerator.Generate(storage.State{LB: lb}) + checkTemplate(template, expectedTemplate) + }) + }) + + Context("when an NLB lb type is provided with a system domain", func() { + BeforeEach(func() { + expectedTemplate = expectTemplate("base", "iam", "vpc", "lb_subnet", "cf_nlb", "cf_lb_common", "ssl_certificate", "iso_segments", "cf_dns") + lb = storage.LB{ + Type: "nlb", + Domain: "some-domain", + } + }) + It("adds the domain", func() { + template := templateGenerator.Generate(storage.State{LB: lb}) + checkTemplate(template, expectedTemplate) + }) + }) }) }) diff --git a/terraform/aws/templates/base.tf b/terraform/aws/templates/base.tf index 7fc6ca066..4b5dd4f1c 100644 --- a/terraform/aws/templates/base.tf +++ b/terraform/aws/templates/base.tf @@ -392,7 +392,7 @@ resource "aws_subnet" "bosh_subnet" { cidr_block = cidrsubnet(var.vpc_cidr, 8, 0) ipv6_cidr_block = var.dualstack ? "${cidrsubnet(aws_vpc.vpc[0].ipv6_cidr_block, 8, 0)}" : null - assign_ipv6_address_on_creation = var.dualstack + assign_ipv6_address_on_creation = false enable_dns64 = var.dualstack tags = { @@ -429,7 +429,7 @@ resource "aws_subnet" "internal_subnets" { availability_zone = element(var.availability_zones, count.index) ipv6_cidr_block = var.dualstack ? "${cidrsubnet(aws_vpc.vpc[0].ipv6_cidr_block, 8, count.index + 1)}" : null - assign_ipv6_address_on_creation = var.dualstack + assign_ipv6_address_on_creation = false enable_dns64 = var.dualstack tags = { diff --git a/terraform/aws/templates/cf_nlb.tf b/terraform/aws/templates/cf_nlb.tf index 611628188..6c3d69481 100644 --- a/terraform/aws/templates/cf_nlb.tf +++ b/terraform/aws/templates/cf_nlb.tf @@ -9,7 +9,7 @@ resource "aws_lb" "cf_ssh_lb" { enable_cross_zone_load_balancing = true # idle_timeout = var.elb_idle_timeout - ip_address_type = "dualstack" + ip_address_type = var.dualstack ? "dualstack" : "ipv4" tags = { Name = var.env_id @@ -27,7 +27,7 @@ resource "aws_lb" "cf_router_lb" { enable_cross_zone_load_balancing = true # idle_timeout = var.elb_idle_timeout - ip_address_type = "dualstack" + ip_address_type = var.dualstack ? "dualstack" : "ipv4" tags = { Name = var.env_id @@ -45,7 +45,7 @@ resource "aws_lb" "cf_tcp_lb" { enable_cross_zone_load_balancing = true # idle_timeout = var.elb_idle_timeout - ip_address_type = "dualstack" + ip_address_type = var.dualstack ? "dualstack" : "ipv4" tags = { Name = var.env_id diff --git a/terraform/aws/templates/iso_segments.tf b/terraform/aws/templates/iso_segments.tf index c06a429a4..3c34c0e97 100644 --- a/terraform/aws/templates/iso_segments.tf +++ b/terraform/aws/templates/iso_segments.tf @@ -29,7 +29,7 @@ resource "aws_subnet" "iso_subnets" { cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + length(var.availability_zones) + 1) ipv6_cidr_block = var.dualstack ? "${cidrsubnet(aws_vpc.vpc[0].ipv6_cidr_block, 8, count.index + 2 + length(var.availability_zones))}" : null availability_zone = element(var.availability_zones, count.index) - assign_ipv6_address_on_creation = var.dualstack + assign_ipv6_address_on_creation = false enable_dns64 = var.dualstack tags = { diff --git a/terraform/aws/templates/lb_subnet.tf b/terraform/aws/templates/lb_subnet.tf index a20f9bf65..965e06f09 100644 --- a/terraform/aws/templates/lb_subnet.tf +++ b/terraform/aws/templates/lb_subnet.tf @@ -4,7 +4,7 @@ resource "aws_subnet" "lb_subnets" { cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 2) ipv6_cidr_block = var.dualstack ? "${cidrsubnet(aws_vpc.vpc[0].ipv6_cidr_block, 8, count.index + 1 + length(var.availability_zones))}" : null availability_zone = element(var.availability_zones, count.index) - assign_ipv6_address_on_creation = var.dualstack + assign_ipv6_address_on_creation = false enable_dns64 = var.dualstack tags = { From e00612574be60e6c49b86fbf459e80950b2555bc Mon Sep 17 00:00:00 2001 From: Alexander Lais Date: Mon, 23 Mar 2026 16:29:45 +0100 Subject: [PATCH 3/3] docs: Add IPv6 dual-stack networking documentation Document the --dual-stack flag, NLB requirement, cloud-config network layout (default_v6/private_v6), and deployment usage guidance. --- docs/advanced-configuration.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/advanced-configuration.md b/docs/advanced-configuration.md index 2b9e3f96a..53bf26ac3 100644 --- a/docs/advanced-configuration.md +++ b/docs/advanced-configuration.md @@ -4,6 +4,7 @@ * Using a BOSH ops-file with bbl * Customizing IaaS Paving with Terraform * Using VM Extensions for Cost Optimization +* IPv6 Dual-Stack Networking (AWS) * Applying and authoring plan patches, bundled modifications to default bbl configurations. ## Using a BOSH ops-file with bbl @@ -103,6 +104,33 @@ instance_groups: - Not recommended for singleton instances or databases - For legacy compatibility, the `preemptible` vm_extension is also available (uses the older GCP API) +## IPv6 Dual-Stack Networking (AWS) + +On AWS, you can enable IPv6 dual-stack networking for your BOSH environment. This assigns IPv6 CIDRs to all VPCs and subnets, and configures Network Load Balancers (NLBs) for dual-stack operation. + +Dual-stack requires the `nlb` load balancer type, as classic AWS Elastic Load Balancers do not support IPv6: + +```sh +bbl plan \ + --lb-type nlb \ + --lb-cert path/to/cert.pem \ + --lb-key path/to/key.pem \ + --lb-chain path/to/chain.pem \ + --dual-stack + +bbl up +``` + +This configures: +- IPv6 CIDR blocks on the VPC and all subnets (internal, LB, isolation segments) +- NLB `ip_address_type` set to `dualstack` (IPv4 when `--dual-stack` is not used) +- IPv6 ingress/egress rules on all security groups +- Separate `default_v6` and `private_v6` BOSH cloud-config networks for IPv6 subnets, alongside the existing `default` and `private` IPv4 networks + +Deployments that want to use IPv6 should reference the `default_v6` network as a secondary network in their manifest. The IPv4 network should remain the primary (with `default: [dns, gateway]`) for control plane communication. + +Dual-stack is currently only supported on AWS. Using `--dual-stack` on other IaaS providers will result in an error. + ## [Plan Patches](https://github.com/cloudfoundry/bosh-bootloader/tree/master/plan-patches) Through operations files and terraform overrides, all sorts of wild modifications can be done to the vanilla bosh environments that bbl creates. The basic principle of a plan patch is to make several modifications to a `bbl plan` in override files that bbl finds under `terraform/`, `cloud-config/`, and `{create,delete}-{jumpbox,director}.sh` . BBL will read and merge those into it's plan when you run `bbl up`.