diff --git a/README.md b/README.md index 308a9bf..f2a333e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,18 @@ See the examples/select/image for an example of how to do this ## Recent Changes -1. Rename images +1. IPv6 Only Support + To support load balanced IPv6 only instances, the primary interface needs to have the 'primary ipv6 enabled' flag set. + This flag is not currently available in the Terraform provider, but a PR exists: https://github.com/hashicorp/terraform-provider-aws/pull/36425 + Until the provider supports this flag we are using a workaround. + The workaround requires the AWS CLI to be installed on the server running Terraform. + The AWS CLI will use the same authentication mechanisms as Terraform, so there is no need to configure additional credentials. + WARNING! If deploying with `ip_family = "ipv6"` the server running Terraform must have the AWS CLI installed. +2. Rename images - Removed SUSE images that weren't BYOS (bring your own subscription) - Amazon subscriptions are harder to automate and don't provide direct service, it ends up being a hidden fee of using the image. Instead, users can use the BYOS image without a subscription until they need one, and then they can add a subscription separately bought from SUSE. - Started using SUSE cloud info API to get the latest image names -2. WARNING! Refactor! +3. WARNING! Refactor! A new Major version and a few new tricks. I don't like breaking the interface, but to enable new functionality it made the most sense to refactor. - set the private ip for your sever @@ -24,11 +31,6 @@ See the examples/select/image for an example of how to do this - look out for attributes like "server_use_strategy" to enable or disable features - indirect access! - now you can assign aws lb target group associations when you generate your server -3. New Images! - - Added SUSE Liberty 7.9 - - Added SLE Micro 5.5 (all subscription types) - - WARNING! we can't test llc (US and China) images due to our account geolocation (Germany) - ## AWS Access diff --git a/examples/basic/dualstack/README.md b/examples/basic/dualstack/README.md new file mode 100644 index 0000000..f81260d --- /dev/null +++ b/examples/basic/dualstack/README.md @@ -0,0 +1,7 @@ +# Basic + +This example shows the simpliest implementation of this module. +In this case "simple" means using the fewest features. + +This bare bones example will deploy a server into a VPC with no external access. +We will show how to enable features individually in futher examples. diff --git a/examples/basic/dualstack/main.tf b/examples/basic/dualstack/main.tf new file mode 100644 index 0000000..c5a3e42 --- /dev/null +++ b/examples/basic/dualstack/main.tf @@ -0,0 +1,85 @@ +provider "aws" { + default_tags { + tags = { + Id = local.identifier + Owner = local.email + } + } +} + +locals { + identifier = var.identifier # this is a random unique string that can be used to identify resources in the cloud provider + category = "basic" + example = "dualstack" + email = "terraform-ci@suse.com" + project_name = "tf-${substr(md5(join("-", [local.category, local.example, md5(local.identifier)])), 0, 5)}-${local.identifier}" + image = "sles-15" + username = lower(substr("tf-${local.identifier}", 0, 32)) + ip = chomp(data.http.myip.response_body) + ssh_key = var.key +} + +data "http" "myip" { + url = "https://ipinfo.io/ip" + retry { + attempts = 2 + min_delay_ms = 1000 + } +} + +data "aws_availability_zones" "available" { + state = "available" +} + +resource "random_pet" "server" { + keepers = { + # regenerate the pet name when the identifier changes + identifier = local.identifier + } + length = 1 +} + +module "access" { + source = "rancher/access/aws" + version = "v3.1.2" + vpc_name = "${local.project_name}-vpc" + vpc_type = "dualstack" + security_group_name = "${local.project_name}-sg" + security_group_type = "project" + load_balancer_name = "${local.project_name}-lb" + domain_use_strategy = "skip" +} + + +module "this" { + depends_on = [ + module.access, + ] + source = "../../../" # change this to "rancher/server/aws" per https://registry.terraform.io/modules/rancher/server/aws/latest + # version = "v1.1.1" # when using this example you will need to set the version + image_type = local.image + server_name = "${local.project_name}-${random_pet.server.id}" + server_type = "small" + subnet_name = keys(module.access.subnets)[0] + server_ip_family = "dualstack" + security_group_name = module.access.security_group.tags_all.Name + + # direct_access_use_strategy = "ssh" # either the subnet needs to be public or you must add an eip + # cloudinit_use_strategy = "default" # use the default cloudinit config + # server_access_addresses = { # you must include ssh access here to enable setup + # "runner" = { + # port = 22 + # protocol = "tcp" + # ip_family = "ipv4" + # cidrs = ["${local.ip}/32"] + # } + # } + # server_user = { + # user = local.username + # aws_keypair_use_strategy = "skip" # we will use cloud-init to add a keypair directly + # ssh_key_name = "" # not creating or selecting a key, but this field is still required + # public_ssh_key = local.ssh_key # ssh key to add via cloud-init + # user_workfolder = "/home/${local.username}" + # timeout = 5 + # } +} diff --git a/examples/basic/dualstack/outputs.tf b/examples/basic/dualstack/outputs.tf new file mode 100644 index 0000000..aace5e6 --- /dev/null +++ b/examples/basic/dualstack/outputs.tf @@ -0,0 +1,9 @@ +output "server" { + value = module.this.server +} +output "image" { + value = module.this.image +} +output "access" { + value = module.access +} diff --git a/examples/basic/dualstack/variables.tf b/examples/basic/dualstack/variables.tf new file mode 100644 index 0000000..bdc8af6 --- /dev/null +++ b/examples/basic/dualstack/variables.tf @@ -0,0 +1,6 @@ +variable "identifier" { + type = string +} +variable "key" { + type = string +} diff --git a/examples/basic/dualstack/versions.tf b/examples/basic/dualstack/versions.tf new file mode 100644 index 0000000..bdd5684 --- /dev/null +++ b/examples/basic/dualstack/versions.tf @@ -0,0 +1,24 @@ +terraform { + required_version = ">= 1.5.0, < 1.6" + required_providers { + local = { + source = "hashicorp/local" + version = ">= 2.4" + } + aws = { + source = "hashicorp/aws" + version = ">= 5.11" + } + random = { + source = "hashicorp/random" + version = ">= 3.1" + } + acme = { # used in the access module + source = "vancluever/acme" + version = ">= 2.0" + } + } +} +provider "acme" { + server_url = "https://acme-staging-v02.api.letsencrypt.org/directory" +} diff --git a/examples/os/sles15/main.tf b/examples/os/sles15/main.tf index 990e56d..d4b5f14 100644 --- a/examples/os/sles15/main.tf +++ b/examples/os/sles15/main.tf @@ -13,8 +13,8 @@ locals { example = "sles15" email = "terraform-ci@suse.com" project_name = "tf-${substr(md5(join("-", [local.category, local.example, md5(local.identifier)])), 0, 5)}-${local.identifier}" - username = lower(substr("tf-${local.identifier}", 0, 32)) image = "sles-15" + username = lower(substr("tf-${local.identifier}", 0, 32)) ip = chomp(data.http.myip.response_body) ssh_key = var.key } diff --git a/modules/server/main.tf b/modules/server/main.tf index 11da119..877f26f 100644 --- a/modules/server/main.tf +++ b/modules/server/main.tf @@ -60,6 +60,11 @@ data "aws_subnet" "general_info_create" { values = [local.subnet] } } +data "aws_availability_zone" "general_info_create" { + count = local.create + name = data.aws_subnet.general_info_create[0].availability_zone +} + data "aws_vpc" "general_info_create" { count = local.create id = data.aws_security_group.general_info_create[0].vpc_id @@ -123,3 +128,35 @@ resource "aws_instance" "created" { ] } } + +# WARNING! This forces a dependency on the AWS CLI, but only for "ipv6 only" servers. +# This is a workaround for the fact that the terraform AWS provider doesn't support the primary ipv6 flag yet. +# When the provider supports it, this can be removed and the attribute added to the instance resource. +resource "terraform_data" "set_primary_ipv6" { + count = (local.ip_family == "ipv6" ? local.create : 0) + depends_on = [ + data.aws_security_group.general_info_create, + data.aws_subnet.general_info_create, + aws_key_pair.created, + aws_instance.created, + ] + triggers_replace = { + "aws_instance" = "${aws_instance.created[0].id}" + } + provisioner "local-exec" { + command = <<-EOT + if ! aws ec2 describe-network-interfaces \ + --network-interface-ids ${aws_instance.created[0].primary_network_interface_id} \ + --region ${data.aws_availability_zone.general_info_create[0].region} \ + --query 'NetworkInterfaces[0].Ipv6Addresses[?IsPrimaryIpv6==`true`]' \ + --output text | grep -q .; then + aws ec2 modify-network-interface-attribute \ + --network-interface-id ${aws_instance.created[0].primary_network_interface_id} \ + --enable-primary-ipv6 \ + --region ${data.aws_availability_zone.general_info_create[0].region} + else + echo "Primary IPv6 is already enabled for this network interface" + fi + EOT + } +} diff --git a/tests/basic_test.go b/tests/basic_test.go index 81bce5f..bdc2a66 100644 --- a/tests/basic_test.go +++ b/tests/basic_test.go @@ -1,202 +1,219 @@ package test import ( - "fmt" - "os" - "testing" + "fmt" + "os" + "testing" - "github.com/gruntwork-io/terratest/modules/random" - "github.com/gruntwork-io/terratest/modules/ssh" - "github.com/gruntwork-io/terratest/modules/terraform" - "github.com/stretchr/testify/assert" + "github.com/gruntwork-io/terratest/modules/random" + "github.com/gruntwork-io/terratest/modules/ssh" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/stretchr/testify/assert" ) func TestBasicBasic(t *testing.T) { - t.Parallel() - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "basic" - region := "us-west-2" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - delete(terraformOptions.Vars, "key") - delete(terraformOptions.Vars, "key_name") - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - terraform.InitAndApply(t, terraformOptions) + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "basic" + region := "us-west-2" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + delete(terraformOptions.Vars, "key") + delete(terraformOptions.Vars, "key_name") + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + terraform.InitAndApply(t, terraformOptions) } +func TestBasicDualstack(t *testing.T) { + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "dualstack" + region := "us-west-2" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + delete(terraformOptions.Vars, "key_name") + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + terraform.InitAndApply(t, terraformOptions) +} func TestBasicPrivateIp(t *testing.T) { - t.Parallel() - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "privateip" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key") - delete(terraformOptions.Vars, "key_name") - terraform.InitAndApply(t, terraformOptions) + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "privateip" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key") + delete(terraformOptions.Vars, "key_name") + terraform.InitAndApply(t, terraformOptions) } func TestBasicIndirectOnly(t *testing.T) { - t.Parallel() - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "indirectonly" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key") - delete(terraformOptions.Vars, "key_name") - terraform.InitAndApply(t, terraformOptions) + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "indirectonly" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key") + delete(terraformOptions.Vars, "key_name") + terraform.InitAndApply(t, terraformOptions) } func TestBasicIndirectDomain(t *testing.T) { - t.Parallel() - zone := os.Getenv("ZONE") - acmeserver := os.Getenv("ACME_SERVER_URL") - if acmeserver == "" { - os.Setenv("ACME_SERVER_URL", "https://acme-staging-v02.api.letsencrypt.org/directory") - } - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "indirectdomain" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key") - delete(terraformOptions.Vars, "key_name") - terraformOptions.Vars["zone"] = zone - terraform.InitAndApply(t, terraformOptions) + t.Parallel() + zone := os.Getenv("ZONE") + acmeserver := os.Getenv("ACME_SERVER_URL") + if acmeserver == "" { + os.Setenv("ACME_SERVER_URL", "https://acme-staging-v02.api.letsencrypt.org/directory") + } + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "indirectdomain" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key") + delete(terraformOptions.Vars, "key_name") + terraformOptions.Vars["zone"] = zone + terraform.InitAndApply(t, terraformOptions) } func TestBasicDirectNetworkOnly(t *testing.T) { - t.Parallel() - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "directnetworkonly" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key") - delete(terraformOptions.Vars, "key_name") - terraform.InitAndApply(t, terraformOptions) + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "directnetworkonly" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key") + delete(terraformOptions.Vars, "key_name") + terraform.InitAndApply(t, terraformOptions) } func TestBasicDirectNetworkDomain(t *testing.T) { - t.Parallel() - zone := os.Getenv("ZONE") - acmeserver := os.Getenv("ACME_SERVER_URL") - if acmeserver == "" { - os.Setenv("ACME_SERVER_URL", "https://acme-staging-v02.api.letsencrypt.org/directory") - } - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "directnetworkdomain" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key") - delete(terraformOptions.Vars, "key_name") - terraformOptions.Vars["zone"] = zone - terraform.InitAndApply(t, terraformOptions) + t.Parallel() + zone := os.Getenv("ZONE") + acmeserver := os.Getenv("ACME_SERVER_URL") + if acmeserver == "" { + os.Setenv("ACME_SERVER_URL", "https://acme-staging-v02.api.letsencrypt.org/directory") + } + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "directnetworkdomain" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key") + delete(terraformOptions.Vars, "key_name") + terraformOptions.Vars["zone"] = zone + terraform.InitAndApply(t, terraformOptions) } func TestBasicDirectSshEip(t *testing.T) { - t.Parallel() - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "directssheip" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "directssheip" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) - defer sshAgent.Stop() - terraformOptions.SshAgent = sshAgent + sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) + defer sshAgent.Stop() + terraformOptions.SshAgent = sshAgent - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key_name") - terraform.InitAndPlan(t, terraformOptions) - terraform.InitAndApply(t, terraformOptions) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key_name") + terraform.InitAndPlan(t, terraformOptions) + terraform.InitAndApply(t, terraformOptions) - out := terraform.OutputAll(t,terraformOptions) - t.Logf("out: %v", out) - outputServer, ok := out["server"].(map[string]interface{}) - assert.True(t, ok, fmt.Sprintf("Wrong data type for 'server', expected map[string], got %T", out["server"])) - outputImage, ok := out["image"].(map[string]interface{}) - assert.True(t, ok, fmt.Sprintf("Wrong data type for 'image', expected map[string], got %T", out["image"])) - assert.NotEmpty(t, outputServer["public_ip"], "The 'server.public_ip' is empty") - assert.NotEmpty(t, outputImage["id"], "The 'image.id' is empty") + out := terraform.OutputAll(t, terraformOptions) + t.Logf("out: %v", out) + outputServer, ok := out["server"].(map[string]interface{}) + assert.True(t, ok, fmt.Sprintf("Wrong data type for 'server', expected map[string], got %T", out["server"])) + outputImage, ok := out["image"].(map[string]interface{}) + assert.True(t, ok, fmt.Sprintf("Wrong data type for 'image', expected map[string], got %T", out["image"])) + assert.NotEmpty(t, outputServer["public_ip"], "The 'server.public_ip' is empty") + assert.NotEmpty(t, outputImage["id"], "The 'image.id' is empty") } func TestBasicDirectSshSubnet(t *testing.T) { - t.Parallel() - id := os.Getenv("IDENTIFIER") - if id == "" { - id = random.UniqueId() - } - uniqueID := id + "-" + random.UniqueId() - category := "basic" - directory := "directsshsubnet" - region := "us-west-1" - owner := "terraform-ci@suse.com" - terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) + t.Parallel() + id := os.Getenv("IDENTIFIER") + if id == "" { + id = random.UniqueId() + } + uniqueID := id + "-" + random.UniqueId() + category := "basic" + directory := "directsshsubnet" + region := "us-west-1" + owner := "terraform-ci@suse.com" + terraformOptions, keyPair := setup(t, category, directory, region, owner, uniqueID) - sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) - defer sshAgent.Stop() - terraformOptions.SshAgent = sshAgent + sshAgent := ssh.SshAgentWithKeyPair(t, keyPair.KeyPair) + defer sshAgent.Stop() + terraformOptions.SshAgent = sshAgent - defer teardown(t, category, directory, keyPair) - defer terraform.Destroy(t, terraformOptions) - delete(terraformOptions.Vars, "key_name") - terraform.InitAndPlan(t, terraformOptions) - terraform.InitAndApply(t, terraformOptions) + defer teardown(t, category, directory, keyPair) + defer terraform.Destroy(t, terraformOptions) + delete(terraformOptions.Vars, "key_name") + terraform.InitAndPlan(t, terraformOptions) + terraform.InitAndApply(t, terraformOptions) - out := terraform.OutputAll(t,terraformOptions) - t.Logf("out: %v", out) - outputServer, ok := out["server"].(map[string]interface{}) - assert.True(t, ok, fmt.Sprintf("Wrong data type for 'server', expected map[string], got %T", out["server"])) - outputImage, ok := out["image"].(map[string]interface{}) - assert.True(t, ok, fmt.Sprintf("Wrong data type for 'image', expected map[string], got %T", out["image"])) - assert.NotEmpty(t, outputServer["public_ip"], "The 'server.public_ip' is empty") - assert.NotEmpty(t, outputImage["id"], "The 'image.id' is empty") + out := terraform.OutputAll(t, terraformOptions) + t.Logf("out: %v", out) + outputServer, ok := out["server"].(map[string]interface{}) + assert.True(t, ok, fmt.Sprintf("Wrong data type for 'server', expected map[string], got %T", out["server"])) + outputImage, ok := out["image"].(map[string]interface{}) + assert.True(t, ok, fmt.Sprintf("Wrong data type for 'image', expected map[string], got %T", out["image"])) + assert.NotEmpty(t, outputServer["public_ip"], "The 'server.public_ip' is empty") + assert.NotEmpty(t, outputImage["id"], "The 'image.id' is empty") } diff --git a/variables.tf b/variables.tf index 7277a9a..e7a75ea 100644 --- a/variables.tf +++ b/variables.tf @@ -157,6 +157,8 @@ variable "server_ip_family" { The ip family to use for the server, must be one of "ipv4", "dualstack", or "ipv6". This is mainly determined by the VPC and subnet that you are deploying to, attempting to deploy a dualstack or ipv6 server to a non-dualstack/ipv6 VPC/subnet will result in failed connections. + WARNING! When this is set to "ipv6" the server running Terraform must have the AWS CLI installed. + This is due to a missing feature in the AWS provider that allows ipv6 instances to be attached to load balancer target groups. EOT default = "ipv4" validation {