diff --git a/internal/service/ec2/find.go b/internal/service/ec2/find.go index 15abb78a0ab9..05357c8fc7aa 100644 --- a/internal/service/ec2/find.go +++ b/internal/service/ec2/find.go @@ -4416,6 +4416,28 @@ func findIPAMResourceDiscoveryAssociationByID(ctx context.Context, conn *ec2.Cli return output, nil } +func findIPAMResourceCIDRs(ctx context.Context, conn *ec2.Client, input *ec2.GetIpamResourceCidrsInput) ([]awstypes.IpamResourceCidr, error) { + var output []awstypes.IpamResourceCidr + + pages := ec2.NewGetIpamResourceCidrsPaginator(conn, input) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + if err != nil { + if tfawserr.ErrCodeEquals(err, errCodeInvalidIPAMPoolIdNotFound) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + return nil, err + } + + output = append(output, page.IpamResourceCidrs...) + } + + return output, nil +} + func findIPAMScope(ctx context.Context, conn *ec2.Client, input *ec2.DescribeIpamScopesInput) (*awstypes.IpamScope, error) { output, err := findIPAMScopes(ctx, conn, input) diff --git a/internal/service/ec2/vpc_default_subnet.go b/internal/service/ec2/vpc_default_subnet.go index 202b372404ac..76db687bb555 100644 --- a/internal/service/ec2/vpc_default_subnet.go +++ b/internal/service/ec2/vpc_default_subnet.go @@ -50,7 +50,11 @@ func resourceDefaultSubnet() *schema.Resource { // - availability_zone_id is Computed-only // - cidr_block is Computed-only // - enable_lni_at_device_index is Computed-only + // - ipv4_ipam_pool_id is omitted as it's not set in resourceSubnetRead + // - ipv4_netmask_length is omitted as it's not set in resourceSubnetRead // - ipv6_cidr_block is Optional/Computed as it's automatically assigned if ipv6_native = true + // - ipv6_ipam_pool_id is omitted as it's not set in resourceSubnetRead + // - ipv6_netmask_length is omitted as it's not set in resourceSubnetRead // - map_public_ip_on_launch has a Default of true // - outpost_arn is Computed-only // - vpc_id is Computed-only diff --git a/internal/service/ec2/vpc_ipam_pool.go b/internal/service/ec2/vpc_ipam_pool.go index 73524969ef04..d51fb8536c61 100644 --- a/internal/service/ec2/vpc_ipam_pool.go +++ b/internal/service/ec2/vpc_ipam_pool.go @@ -5,6 +5,7 @@ package ec2 import ( "context" + "fmt" "log" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/hashicorp/aws-sdk-go-base/v2/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/id" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/hashicorp/terraform-provider-aws/internal/conns" @@ -41,7 +43,8 @@ func resourceIPAMPool() *schema.Resource { }, Timeouts: &schema.ResourceTimeout{ - Create: schema.DefaultTimeout(3 * time.Minute), + // Cross-region resources take 20+ minutes to be monitored by IPAM + Create: schema.DefaultTimeout(35 * time.Minute), Update: schema.DefaultTimeout(3 * time.Minute), Delete: schema.DefaultTimeout(3 * time.Minute), }, @@ -138,6 +141,37 @@ func resourceIPAMPool() *schema.Resource { Optional: true, ForceNew: true, }, + "source_resource": { + Type: schema.TypeList, + Optional: true, + ForceNew: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + names.AttrResourceID: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + names.AttrResourceOwner: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "resource_region": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + names.AttrResourceType: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: enum.Validate[awstypes.IpamPoolSourceResourceType](), + }, + }, + }, + }, names.AttrState: { Type: schema.TypeString, Computed: true, @@ -213,6 +247,76 @@ func resourceIPAMPoolCreate(ctx context.Context, d *schema.ResourceData, meta an input.SourceIpamPoolId = aws.String(v.(string)) } + var sourceResourceData map[string]any + if v, ok := d.GetOk("source_resource"); ok && len(v.([]any)) > 0 { + sourceResourceData = v.([]any)[0].(map[string]any) + } + + if sourceResourceData != nil { + resourceID := sourceResourceData[names.AttrResourceID].(string) + resourceRegion := sourceResourceData["resource_region"].(string) + resourceOwner := aws.String(sourceResourceData[names.AttrResourceOwner].(string)) + resourceType := awstypes.IpamPoolSourceResourceType(sourceResourceData[names.AttrResourceType].(string)) + + log.Printf("[DEBUG] Checking if resource %s exists in region %s before waiting for IPAM discovery", resourceID, resourceRegion) + + resourceConn := conn + if resourceRegion != meta.(*conns.AWSClient).Region(ctx) { + resourceCtx := conns.NewResourceContext(ctx, names.EC2ServiceID, "aws_vpc_ipam_pool", resourceRegion) + resourceConn = meta.(*conns.AWSClient).EC2Client(resourceCtx) + } + + if resourceType == awstypes.IpamPoolSourceResourceTypeVpc { + if _, err := findVPCByID(ctx, resourceConn, resourceID); err != nil { + return sdkdiag.AppendErrorf(diags, "source_resource VPC (%s) does not exist: %s", resourceID, err) + } + } + + log.Printf("[DEBUG] Resource %s exists, waiting for IPAM to manage the resource", resourceID) + + // Wait for the resource to be managed by IPAM - can take 20+ minutes + _, err = tfresource.RetryWhenNotFound(ctx, d.Timeout(schema.TimeoutCreate), func(ctx context.Context) (any, error) { + input := &ec2.GetIpamResourceCidrsInput{ + IpamScopeId: aws.String(scopeID), + Filters: []awstypes.Filter{ + { + Name: aws.String("resource-id"), + Values: []string{resourceID}, + }, + { + Name: aws.String("management-state"), + Values: []string{"managed"}, + }, + }, + } + + resources, err := findIPAMResourceCIDRs(ctx, conn, input) + if err != nil { + return nil, err + } + + if len(resources) == 0 { + return nil, &retry.NotFoundError{ + Message: fmt.Sprintf("resource %s not yet managed by IPAM", resourceID), + } + } + + log.Printf("[DEBUG] Resource %s is now managed by IPAM", resourceID) + return resources, nil + }) + + if err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for resource %s to be managed by IPAM: %s", resourceID, err) + } + + input.SourceResource = &awstypes.IpamPoolSourceResourceRequest{ + ResourceId: aws.String(resourceID), + ResourceOwner: resourceOwner, + ResourceRegion: aws.String(resourceRegion), + ResourceType: resourceType, + } + } + output, err := conn.CreateIpamPool(ctx, input) if err != nil { @@ -258,6 +362,17 @@ func resourceIPAMPoolRead(ctx context.Context, d *schema.ResourceData, meta any) d.Set("publicly_advertisable", pool.PubliclyAdvertisable) d.Set("public_ip_source", pool.PublicIpSource) d.Set("source_ipam_pool_id", pool.SourceIpamPoolId) + if pool.SourceResource != nil { + tfMap := map[string]any{ + names.AttrResourceID: aws.ToString(pool.SourceResource.ResourceId), + names.AttrResourceOwner: aws.ToString(pool.SourceResource.ResourceOwner), + "resource_region": aws.ToString(pool.SourceResource.ResourceRegion), + names.AttrResourceType: string(pool.SourceResource.ResourceType), + } + d.Set("source_resource", []any{tfMap}) + } else { + d.Set("source_resource", nil) + } d.Set(names.AttrState, pool.State) setTagsOut(ctx, pool.Tags) diff --git a/internal/service/ec2/vpc_ipam_pool_cidr.go b/internal/service/ec2/vpc_ipam_pool_cidr.go index 1883fa77000d..68230e9d9ca5 100644 --- a/internal/service/ec2/vpc_ipam_pool_cidr.go +++ b/internal/service/ec2/vpc_ipam_pool_cidr.go @@ -15,11 +15,14 @@ import ( awstypes "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/hashicorp/aws-sdk-go-base/v2/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/hashicorp/terraform-provider-aws/internal/conns" "github.com/hashicorp/terraform-provider-aws/internal/errs/sdkdiag" + intretry "github.com/hashicorp/terraform-provider-aws/internal/retry" "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/internal/types" "github.com/hashicorp/terraform-provider-aws/internal/verify" "github.com/hashicorp/terraform-provider-aws/names" ) @@ -179,6 +182,107 @@ func resourceIPAMPoolCIDRDelete(ctx context.Context, d *schema.ResourceData, met return sdkdiag.AppendFromErr(diags, err) } + ipamPool, err := findIPAMPoolByID(ctx, conn, poolID) + if intretry.NotFound(err) { + log.Printf("[DEBUG] IPAM Pool (%s) not found, skipping IPAM Pool CIDR (%s) delete", poolID, d.Id()) + return diags + } else if err != nil { + return sdkdiag.AppendErrorf(diags, "reading IPAM Pool (%s): %s", poolID, err) + } + + // VPC / Subnet allocations take upto 20m to be released after resource deletion. + log.Printf("[DEBUG] Checking for allocations from CIDR %s in IPAM Pool (%s) that need to be released", cidrBlock, poolID) + + // Create a region-specific client for the pool's locale + poolRegion := aws.ToString(ipamPool.Locale) + allocationCtx := conns.NewResourceContext(ctx, names.EC2ServiceID, "aws_vpc_ipam_pool_cidr", poolRegion) + allocationConn := meta.(*conns.AWSClient).EC2Client(allocationCtx) + + ipamPoolAllocationsInput := &ec2.GetIpamPoolAllocationsInput{ + IpamPoolId: aws.String(poolID), + Filters: []awstypes.Filter{ + { + Name: aws.String("resource-type"), + Values: []string{string(awstypes.IpamPoolAllocationResourceTypeVpc), string(awstypes.IpamPoolAllocationResourceTypeSubnet)}, + }, + }, + } + allocations, err := findIPAMPoolAllocations(allocationCtx, allocationConn, ipamPoolAllocationsInput) + if intretry.NotFound(err) { + allocations = nil + } else if err != nil { + return sdkdiag.AppendErrorf(diags, "listing IPAM Pool (%s) allocations: %s", poolID, err) + } + + var allocationsToTrack []awstypes.IpamPoolAllocation + for _, allocation := range allocations { + allocationCIDR := aws.ToString(allocation.Cidr) + + if !types.CIDRBlocksOverlap(cidrBlock, allocationCIDR) { + continue + } + allocationsToTrack = append(allocationsToTrack, allocation) + } + + if len(allocationsToTrack) > 0 { + log.Printf("[DEBUG] Found %d VPC/Subnet allocation(s) from CIDR %s that need to be released", len(allocationsToTrack), cidrBlock) + + for _, allocation := range allocationsToTrack { + resourceID := aws.ToString(allocation.ResourceId) + allocationCIDR := aws.ToString(allocation.Cidr) + resourceType := allocation.ResourceType + + switch resourceType { + case awstypes.IpamPoolAllocationResourceTypeVpc: + _, err := findVPCByID(allocationCtx, allocationConn, resourceID) + if err == nil { + return sdkdiag.AppendErrorf(diags, "VPC %s (CIDR: %s) must be deleted before IPAM Pool CIDR can be deprovisioned", resourceID, allocationCIDR) + } + log.Printf("[DEBUG] VPC %s already deleted, waiting for allocation (CIDR: %s) to be released from IPAM Pool %s", resourceID, allocationCIDR, poolID) + case awstypes.IpamPoolAllocationResourceTypeSubnet: + _, err := findSubnetByID(allocationCtx, allocationConn, resourceID) + if err == nil { + return sdkdiag.AppendErrorf(diags, "subnet %s (CIDR: %s) must be deleted before IPAM Pool CIDR can be deprovisioned", resourceID, allocationCIDR) + } + log.Printf("[DEBUG] Subnet %s already deleted, waiting for allocation (CIDR: %s) to be released from IPAM Pool %s", resourceID, allocationCIDR, poolID) + } + } + + log.Printf("[DEBUG] Waiting for IPAM to release %d allocation(s) from CIDR %s", len(allocationsToTrack), cidrBlock) + + _, err = tfresource.RetryUntilNotFound(allocationCtx, d.Timeout(schema.TimeoutDelete), func(ctx context.Context) (any, error) { + allocations, err := findIPAMPoolAllocations(ctx, allocationConn, ipamPoolAllocationsInput) + if intretry.NotFound(err) { + log.Printf("[DEBUG] IPAM Pool (%s) deleted during wait, allocations released", poolID) + return nil, &retry.NotFoundError{} + } + if err != nil { + return nil, err + } + + for _, allocation := range allocations { + allocationCIDR := aws.ToString(allocation.Cidr) + + if !types.CIDRBlocksOverlap(cidrBlock, allocationCIDR) { + continue + } + + return allocation, nil + } + return nil, &retry.NotFoundError{} + }) + + if intretry.TimedOut(err) { + return sdkdiag.AppendErrorf(diags, "timeout waiting for IPAM Pool (%s) allocations to be released after %s", poolID, d.Timeout(schema.TimeoutDelete)) + } + + if err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for IPAM Pool (%s) allocations to be released: %s", poolID, err) + } + } else { + log.Printf("[DEBUG] No VPC/Subnet allocations found for CIDR %s, proceeding with deprovision", cidrBlock) + } + log.Printf("[DEBUG] Deleting IPAM Pool CIDR: %s", d.Id()) input := ec2.DeprovisionIpamPoolCidrInput{ Cidr: aws.String(cidrBlock), @@ -198,7 +302,6 @@ func resourceIPAMPoolCIDRDelete(ctx context.Context, d *schema.ResourceData, met if _, err := waitIPAMPoolCIDRDeleted(ctx, conn, d.Get("ipam_pool_cidr_id").(string), poolID, cidrBlock, d.Timeout(schema.TimeoutDelete)); err != nil { return sdkdiag.AppendErrorf(diags, "waiting for IPAM Pool CIDR (%s) delete: %s", d.Id(), err) } - return diags } diff --git a/internal/service/ec2/vpc_ipam_pool_cidr_test.go b/internal/service/ec2/vpc_ipam_pool_cidr_test.go index a77e71734658..bac71c7edc59 100644 --- a/internal/service/ec2/vpc_ipam_pool_cidr_test.go +++ b/internal/service/ec2/vpc_ipam_pool_cidr_test.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" awstypes "github.com/aws/aws-sdk-go-v2/service/ec2/types" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-provider-aws/internal/acctest" @@ -128,6 +129,44 @@ func TestAccIPAMPoolCIDR_Disappears_ipam(t *testing.T) { // nosemgrep:ci.vpc-in- }) } +func TestAccIPAMPoolCIDR_ipam_VPCAllocation(t *testing.T) { // nosemgrep:ci.vpc-in-test-name + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var cidr awstypes.IpamPoolCidr + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_vpc_ipam_pool_cidr.test" + vpcResourceName := "aws_vpc.test" + cidrBlock := "10.0.0.0/16" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckIPAMPoolCIDRDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccIPAMPoolCIDRConfig_ipam_VPCAllocation(rName, cidrBlock), + Check: resource.ComposeTestCheckFunc( + testAccCheckIPAMPoolCIDRExists(ctx, resourceName, &cidr), + resource.TestCheckResourceAttr(resourceName, "cidr", cidrBlock), + resource.TestCheckResourceAttrPair(resourceName, "ipam_pool_id", "aws_vpc_ipam_pool.test", names.AttrID), + resource.TestCheckResourceAttrSet(resourceName, "ipam_pool_cidr_id"), + resource.TestCheckResourceAttrSet(vpcResourceName, names.AttrID), + resource.TestCheckResourceAttrSet(vpcResourceName, names.AttrCIDRBlock), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + func testAccCheckIPAMPoolCIDRExists(ctx context.Context, n string, v *awstypes.IpamPoolCidr) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] @@ -185,6 +224,20 @@ func testAccCheckIPAMPoolCIDRPrefix(cidr *awstypes.IpamPoolCidr, expected string } } +func testAccIPAMPoolCIDRConfig_privatePool(rName string) string { + return fmt.Sprintf(` +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + + tags = { + Name = %[1]q + } +} +`, rName) +} + const TestAccIPAMPoolCIDRConfig_base = ` data "aws_region" "current" {} @@ -192,7 +245,7 @@ resource "aws_vpc_ipam" "test" { description = "test" operating_regions { - region_name = data.aws_region.current.region + region_name = data.aws_region.current.name } cascade = true @@ -203,7 +256,7 @@ const TestAccIPAMPoolCIDRConfig_privatePool = ` resource "aws_vpc_ipam_pool" "test" { address_family = "ipv4" ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id - locale = data.aws_region.current.region + locale = data.aws_region.current.name } ` @@ -245,3 +298,23 @@ resource "aws_vpc_ipam_pool_cidr" "test" { } `, netmaskLength)) } + +func testAccIPAMPoolCIDRConfig_ipam_VPCAllocation(rName, cidr string) string { + return acctest.ConfigCompose(TestAccIPAMPoolCIDRConfig_base, testAccIPAMPoolCIDRConfig_privatePool(rName), fmt.Sprintf(` +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + cidr = %[1]q +} + +resource "aws_vpc" "test" { + ipv4_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv4_netmask_length = 24 + + tags = { + Name = %[2]q + } + + depends_on = [aws_vpc_ipam_pool_cidr.test] +} +`, cidr, rName)) +} diff --git a/internal/service/ec2/vpc_ipam_pool_data_source.go b/internal/service/ec2/vpc_ipam_pool_data_source.go index ebe3611459fe..82dd30d573b2 100644 --- a/internal/service/ec2/vpc_ipam_pool_data_source.go +++ b/internal/service/ec2/vpc_ipam_pool_data_source.go @@ -97,6 +97,30 @@ func dataSourceIPAMPool() *schema.Resource { Type: schema.TypeString, Computed: true, }, + "source_resource": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + names.AttrResourceID: { + Type: schema.TypeString, + Computed: true, + }, + names.AttrResourceOwner: { + Type: schema.TypeString, + Computed: true, + }, + "resource_region": { + Type: schema.TypeString, + Computed: true, + }, + names.AttrResourceType: { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, names.AttrState: { Type: schema.TypeString, Computed: true, @@ -147,6 +171,17 @@ func dataSourceIPAMPoolRead(ctx context.Context, d *schema.ResourceData, meta an d.Set("pool_depth", pool.PoolDepth) d.Set("publicly_advertisable", pool.PubliclyAdvertisable) d.Set("source_ipam_pool_id", pool.SourceIpamPoolId) + if pool.SourceResource != nil { + tfMap := map[string]any{ + names.AttrResourceID: aws.ToString(pool.SourceResource.ResourceId), + names.AttrResourceOwner: aws.ToString(pool.SourceResource.ResourceOwner), + "resource_region": aws.ToString(pool.SourceResource.ResourceRegion), + names.AttrResourceType: string(pool.SourceResource.ResourceType), + } + d.Set("source_resource", []any{tfMap}) + } else { + d.Set("source_resource", nil) + } d.Set(names.AttrState, pool.State) setTagsOut(ctx, pool.Tags) diff --git a/internal/service/ec2/vpc_ipam_pool_data_source_test.go b/internal/service/ec2/vpc_ipam_pool_data_source_test.go index 31835aac8fd6..a1916995bbeb 100644 --- a/internal/service/ec2/vpc_ipam_pool_data_source_test.go +++ b/internal/service/ec2/vpc_ipam_pool_data_source_test.go @@ -4,8 +4,10 @@ package ec2_test import ( + "fmt" "testing" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-provider-aws/internal/acctest" "github.com/hashicorp/terraform-provider-aws/names" @@ -47,6 +49,38 @@ func TestAccIPAMPoolDataSource_basic(t *testing.T) { // nosemgrep:ci.vpc-in-test }) } +func TestAccIPAMPoolDataSource_sourceResource(t *testing.T) { // nosemgrep:ci.vpc-in-test-name + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + ctx := acctest.Context(t) + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_vpc_ipam_pool.vpc" + dataSourceName := "data.aws_vpc_ipam_pool.vpc" + vpcResourceName := "aws_vpc.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccIPAMPoolDataSourceConfig_sourceResource(rName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair(dataSourceName, "source_resource.#", resourceName, "source_resource.#"), + resource.TestCheckResourceAttr(dataSourceName, "source_resource.#", "1"), + resource.TestCheckResourceAttrPair(dataSourceName, "source_resource.0.resource_id", vpcResourceName, names.AttrID), + resource.TestCheckResourceAttrPair(dataSourceName, "source_resource.0.resource_id", resourceName, "source_resource.0.resource_id"), + resource.TestCheckResourceAttrSet(dataSourceName, "source_resource.0.resource_owner"), + resource.TestCheckResourceAttrSet(dataSourceName, "source_resource.0.resource_region"), + resource.TestCheckResourceAttr(dataSourceName, "source_resource.0.resource_type", "vpc"), + ), + }, + }, + }) +} + var testAccIPAMPoolDataSourceConfig_optionsBasic = acctest.ConfigCompose(testAccIPAMPoolConfig_base, ` resource "aws_vpc_ipam_pool" "test" { address_family = "ipv4" @@ -65,3 +99,57 @@ data "aws_vpc_ipam_pool" "test" { ipam_pool_id = aws_vpc_ipam_pool.test.id } `) + +func testAccIPAMPoolDataSourceConfig_sourceResource(rName string) string { + return acctest.ConfigCompose(testAccIPAMPoolConfig_base, fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + cidr = "10.0.0.0/16" +} + +resource "aws_vpc" "test" { + ipv4_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv4_netmask_length = 24 + + tags = { + Name = %[1]q + } + + depends_on = [aws_vpc_ipam_pool_cidr.test] +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + source_ipam_pool_id = aws_vpc_ipam_pool.test.id + + source_resource { + resource_id = aws_vpc.test.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.current.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} + +data "aws_vpc_ipam_pool" "vpc" { + ipam_pool_id = aws_vpc_ipam_pool.vpc.id +} +`, rName)) +} diff --git a/internal/service/ec2/vpc_ipam_pool_test.go b/internal/service/ec2/vpc_ipam_pool_test.go index 938b68917b5c..d6174b4a06ab 100644 --- a/internal/service/ec2/vpc_ipam_pool_test.go +++ b/internal/service/ec2/vpc_ipam_pool_test.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" awstypes "github.com/aws/aws-sdk-go-v2/service/ec2/types" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/hashicorp/terraform-provider-aws/internal/acctest" @@ -285,6 +286,171 @@ func TestAccIPAMPool_ipv6PrivateScope(t *testing.T) { // nosemgrep:ci.vpc-in-tes }) } +func TestAccIPAMPool_ResourcePlanningVPC_ipv4(t *testing.T) { // nosemgrep:ci.vpc-in-test-name + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + ctx := acctest.Context(t) + var pool awstypes.IpamPool + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_vpc_ipam_pool.vpc" + vpcResourceName := "aws_vpc.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckIPAMPoolDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccIPAMPoolConfig_resourcePlanningVPC_IPv4(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckIPAMPoolExists(ctx, resourceName, &pool), + resource.TestCheckResourceAttr(resourceName, "source_resource.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "source_resource.0.resource_id", vpcResourceName, names.AttrID), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_owner"), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_region"), + resource.TestCheckResourceAttr(resourceName, "source_resource.0.resource_type", "vpc"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"cascade"}, + }, + }, + }) +} + +func TestAccIPAMPool_ResourcePlanningVPC_ipv6(t *testing.T) { // nosemgrep:ci.vpc-in-test-name + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + ctx := acctest.Context(t) + var pool awstypes.IpamPool + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_vpc_ipam_pool.vpc" + vpcResourceName := "aws_vpc.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckIPAMPoolDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccIPAMPoolConfig_resourcePlanningVPC_IPv6(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckIPAMPoolExists(ctx, resourceName, &pool), + resource.TestCheckResourceAttr(resourceName, "address_family", "ipv6"), + resource.TestCheckResourceAttr(resourceName, "source_resource.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "source_resource.0.resource_id", vpcResourceName, names.AttrID), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_owner"), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_region"), + resource.TestCheckResourceAttr(resourceName, "source_resource.0.resource_type", "vpc"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"cascade"}, + }, + }, + }) +} + +func TestAccIPAMPool_ResourcePlanningVPC_sourceResourceUpdate(t *testing.T) { // nosemgrep:ci.vpc-in-test-name + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + ctx := acctest.Context(t) + var pool1, pool2 awstypes.IpamPool + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_vpc_ipam_pool.vpc" + vpcResourceName := "aws_vpc.test" + vpcResourceName2 := "aws_vpc.new" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckIPAMPoolDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccIPAMPoolConfig_resourcePlanningVPC_IPv4(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckIPAMPoolExists(ctx, resourceName, &pool1), + resource.TestCheckResourceAttr(resourceName, "source_resource.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "source_resource.0.resource_id", vpcResourceName, names.AttrID), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_owner"), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_region"), + resource.TestCheckResourceAttr(resourceName, "source_resource.0.resource_type", "vpc"), + ), + }, + { + Config: testAccIPAMPoolConfig_resourcePlanningVPC_IPv4_updated(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckIPAMPoolExists(ctx, resourceName, &pool2), + testAccCheckIPAMPoolRecreated(&pool1, &pool2), + resource.TestCheckResourceAttr(resourceName, "source_resource.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "source_resource.0.resource_id", vpcResourceName2, names.AttrID), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_owner"), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_region"), + resource.TestCheckResourceAttr(resourceName, "source_resource.0.resource_type", "vpc"), + ), + }, + }, + }) +} + +func TestAccIPAMPool_ResourcePlanningVPC_crossRegion(t *testing.T) { // nosemgrep:ci.vpc-in-test-name + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + ctx := acctest.Context(t) + var pool awstypes.IpamPool + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_vpc_ipam_pool.vpc" + vpcResourceName := "aws_vpc.test" + parentPoolResourceName := "aws_vpc_ipam_pool.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckMultipleRegion(t, 2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5FactoriesAlternate(ctx, t), + CheckDestroy: testAccCheckIPAMPoolDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccIPAMPoolConfig_resourcePlanningVPC_crossRegion(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckIPAMPoolExists(ctx, resourceName, &pool), + resource.TestCheckResourceAttr(resourceName, "source_resource.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "source_resource.0.resource_id", vpcResourceName, names.AttrID), + resource.TestCheckResourceAttrSet(resourceName, "source_resource.0.resource_owner"), + resource.TestCheckResourceAttr(resourceName, "source_resource.0.resource_region", acctest.AlternateRegion()), + resource.TestCheckResourceAttr(resourceName, "source_resource.0.resource_type", "vpc"), + resource.TestCheckResourceAttrPair(resourceName, "source_ipam_pool_id", parentPoolResourceName, names.AttrID), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"cascade"}, + }, + }, + }) +} + func testAccCheckIPAMPoolExists(ctx context.Context, n string, v *awstypes.IpamPool) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] @@ -346,6 +512,16 @@ func testAccCheckIPAMPoolCIDRCreate(ctx context.Context, ipampool *awstypes.Ipam } } +func testAccCheckIPAMPoolRecreated(before, after *awstypes.IpamPool) resource.TestCheckFunc { + return func(s *terraform.State) error { + if before, after := aws.ToString(before.IpamPoolId), aws.ToString(after.IpamPoolId); before == after { + return fmt.Errorf("IPAM Pool (%s) was not recreated", before) + } + + return nil + } +} + const testAccIPAMPoolConfig_base = ` data "aws_region" "current" {} @@ -454,3 +630,236 @@ resource "aws_vpc_ipam_pool" "test" { locale = data.aws_region.current.region } `) + +func testAccIPAMPoolConfig_resourcePlanningVPC_IPv4(rName string) string { + return acctest.ConfigCompose(testAccIPAMPoolConfig_base, fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + cidr = "10.0.0.0/16" +} + +resource "aws_vpc" "test" { + ipv4_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv4_netmask_length = 24 + + depends_on = [aws_vpc_ipam_pool_cidr.test] + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + source_ipam_pool_id = aws_vpc_ipam_pool.test.id + + source_resource { + resource_id = aws_vpc.test.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.current.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccIPAMPoolConfig_resourcePlanningVPC_IPv4_updated(rName string) string { + return acctest.ConfigCompose(testAccIPAMPoolConfig_base, fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + cidr = "10.0.0.0/16" +} + +resource "aws_vpc" "test" { + ipv4_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv4_netmask_length = 24 + + depends_on = [aws_vpc_ipam_pool_cidr.test] + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "new" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name +} + +resource "aws_vpc_ipam_pool_cidr" "new" { + ipam_pool_id = aws_vpc_ipam_pool.new.id + cidr = "10.1.0.0/16" +} + +resource "aws_vpc" "new" { + ipv4_ipam_pool_id = aws_vpc_ipam_pool.new.id + ipv4_netmask_length = 24 + + depends_on = [aws_vpc_ipam_pool_cidr.new] + + tags = { + Name = "%[1]s-new" + } +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + source_ipam_pool_id = aws_vpc_ipam_pool.new.id + + source_resource { + resource_id = aws_vpc.new.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.current.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccIPAMPoolConfig_resourcePlanningVPC_IPv6(rName string) string { + return acctest.ConfigCompose(testAccIPAMPoolConfig_base, fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv6" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + netmask_length = 52 +} + +resource "aws_vpc" "test" { + cidr_block = "10.1.0.0/16" + ipv6_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv6_netmask_length = 56 + + depends_on = [aws_vpc_ipam_pool_cidr.test] + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv6" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + source_ipam_pool_id = aws_vpc_ipam_pool.test.id + + source_resource { + resource_id = aws_vpc.test.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.current.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccIPAMPoolConfig_resourcePlanningVPC_crossRegion(rName string) string { + return acctest.ConfigCompose(acctest.ConfigMultipleRegionProvider(2), fmt.Sprintf(` +data "aws_region" "current" {} + +data "aws_region" "alternate" { + provider = awsalternate +} + +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam" "test" { + operating_regions { + region_name = data.aws_region.current.name + } + + operating_regions { + region_name = data.aws_region.alternate.name + } + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.alternate.name + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + cidr = "10.0.0.0/16" +} + +resource "aws_vpc" "test" { + provider = awsalternate + + ipv4_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv4_netmask_length = 24 + + depends_on = [aws_vpc_ipam_pool_cidr.test] + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.alternate.name + source_ipam_pool_id = aws_vpc_ipam_pool.test.id + + source_resource { + resource_id = aws_vpc.test.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.alternate.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} diff --git a/internal/service/ec2/vpc_subnet.go b/internal/service/ec2/vpc_subnet.go index e088d1036e3d..a6db405fc73c 100644 --- a/internal/service/ec2/vpc_subnet.go +++ b/internal/service/ec2/vpc_subnet.go @@ -90,6 +90,7 @@ func resourceSubnet() *schema.Resource { names.AttrCIDRBlock: { Type: schema.TypeString, Optional: true, + Computed: true, ForceNew: true, ValidateFunc: verify.ValidIPv4CIDRNetworkAddress, }, @@ -118,15 +119,44 @@ func resourceSubnet() *schema.Resource { Optional: true, Default: false, }, + "ipv4_ipam_pool_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{names.AttrCIDRBlock, "customer_owned_ipv4_pool"}, + }, + "ipv4_netmask_length": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ValidateFunc: validation.IntBetween(vpcCIDRMinIPv4Netmask, vpcCIDRMaxIPv4Netmask), + ConflictsWith: []string{names.AttrCIDRBlock, "customer_owned_ipv4_pool"}, + RequiredWith: []string{"ipv4_ipam_pool_id"}, + }, "ipv6_cidr_block": { Type: schema.TypeString, Optional: true, + Computed: true, ValidateFunc: verify.ValidIPv6CIDRNetworkAddress, }, "ipv6_cidr_block_association_id": { Type: schema.TypeString, Computed: true, }, + "ipv6_ipam_pool_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{"ipv6_cidr_block"}, + }, + "ipv6_netmask_length": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ValidateFunc: validation.IntInSlice(vpcCIDRValidIPv6Netmasks), + ConflictsWith: []string{"ipv6_cidr_block"}, + RequiredWith: []string{"ipv6_ipam_pool_id"}, + }, "ipv6_native": { Type: schema.TypeBool, Optional: true, @@ -199,6 +229,22 @@ func resourceSubnetCreate(ctx context.Context, d *schema.ResourceData, meta any) input.CidrBlock = aws.String(v.(string)) } + if v, ok := d.GetOk("ipv4_ipam_pool_id"); ok { + input.Ipv4IpamPoolId = aws.String(v.(string)) + } + + if v, ok := d.GetOk("ipv4_netmask_length"); ok { + input.Ipv4NetmaskLength = aws.Int32(int32(v.(int))) + } + + if v, ok := d.GetOk("ipv6_ipam_pool_id"); ok { + input.Ipv6IpamPoolId = aws.String(v.(string)) + } + + if v, ok := d.GetOk("ipv6_netmask_length"); ok { + input.Ipv6NetmaskLength = aws.Int32(int32(v.(int))) + } + if v, ok := d.GetOk("ipv6_cidr_block"); ok { input.Ipv6CidrBlock = aws.String(v.(string)) } @@ -239,7 +285,16 @@ func resourceSubnetCreate(ctx context.Context, d *schema.ResourceData, meta any) } } - if err := modifySubnetAttributesOnCreate(ctx, conn, d, subnet, false); err != nil { + var computedIPv6CidrBlock bool + _, cidrExists := d.GetOk("ipv6_cidr_block") + + if v, ok := d.GetOk("ipv6_native"); ok && v.(bool) && !cidrExists { + computedIPv6CidrBlock = true + } else { + computedIPv6CidrBlock = false + } + + if err := modifySubnetAttributesOnCreate(ctx, conn, d, subnet, computedIPv6CidrBlock); err != nil { return sdkdiag.AppendFromErr(diags, err) } diff --git a/internal/service/ec2/vpc_subnet_test.go b/internal/service/ec2/vpc_subnet_test.go index 0181a3d4e12c..9aa2aa2f64f4 100644 --- a/internal/service/ec2/vpc_subnet_test.go +++ b/internal/service/ec2/vpc_subnet_test.go @@ -6,6 +6,7 @@ package ec2_test import ( "context" "fmt" + "strings" "testing" "github.com/YakDriver/regexache" @@ -639,6 +640,89 @@ func TestAccVPCSubnet_ipv6Native(t *testing.T) { }) } +func TestAccVPCSubnet_IPAM_serial(t *testing.T) { + t.Parallel() + + testCases := map[string]map[string]func(t *testing.T){ + "Allocation": { + "ipv4": testAccVPCSubnet_IPAM_ipv4Allocation, + "ipv6": testAccVPCSubnet_IPAM_ipv6Allocation, + }, + } + + acctest.RunSerialTests2Levels(t, testCases, 0) +} + +func testAccVPCSubnet_IPAM_ipv4Allocation(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var subnet awstypes.Subnet + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_subnet.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckSubnetDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccVPCSubnetConfig_ipv4IPAMAllocation(rName, 27), + Check: resource.ComposeTestCheckFunc( + testAccCheckSubnetExists(ctx, resourceName, &subnet), + resource.TestCheckResourceAttrPair(resourceName, "ipv4_ipam_pool_id", "aws_vpc_ipam_pool.vpc", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "ipv4_netmask_length", "27"), + testAccCheckSubnetCIDRPrefix(&subnet, "27"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ipv4_ipam_pool_id", "ipv4_netmask_length"}, + }, + }, + }) +} + +func testAccVPCSubnet_IPAM_ipv6Allocation(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var subnet awstypes.Subnet + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_subnet.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckSubnetDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccVPCSubnetConfig_ipv6IPAMAllocation(rName, 60), + Check: resource.ComposeTestCheckFunc( + testAccCheckSubnetExists(ctx, resourceName, &subnet), + resource.TestCheckResourceAttrPair(resourceName, "ipv6_ipam_pool_id", "aws_vpc_ipam_pool.vpc", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "ipv6_netmask_length", "60"), + testAccCheckSubnetIPv6CIDRPrefix(&subnet, "60"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ipv6_ipam_pool_id", "ipv6_netmask_length"}, + }, + }, + }) +} + func testAccCheckSubnetIPv6BeforeUpdate(subnet *awstypes.Subnet) resource.TestCheckFunc { return func(s *terraform.State) error { if subnet.Ipv6CidrBlockAssociationSet == nil { @@ -732,6 +816,29 @@ func testAccCheckSubnetUpdateTags(ctx context.Context, subnet *awstypes.Subnet, } } +func testAccCheckSubnetCIDRPrefix(subnet *awstypes.Subnet, expected string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if strings.Split(aws.ToString(subnet.CidrBlock), "/")[1] != expected { + return fmt.Errorf("Bad cidr prefix: got %s, expected /%s", aws.ToString(subnet.CidrBlock), expected) + } + return nil + } +} + +func testAccCheckSubnetIPv6CIDRPrefix(subnet *awstypes.Subnet, expected string) resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, association := range subnet.Ipv6CidrBlockAssociationSet { + if association.Ipv6CidrBlockState != nil && association.Ipv6CidrBlockState.State == awstypes.SubnetCidrBlockStateCodeAssociated { + if strings.Split(aws.ToString(association.Ipv6CidrBlock), "/")[1] != expected { + return fmt.Errorf("Bad IPv6 cidr prefix: got %s, expected /%s", aws.ToString(association.Ipv6CidrBlock), expected) + } + return nil + } + } + return fmt.Errorf("No associated IPv6 CIDR block found") + } +} + func testAccVPCSubnetConfig_basic(rName string) string { return fmt.Sprintf(` resource "aws_vpc" "test" { @@ -1205,3 +1312,153 @@ resource "aws_subnet" "test" { } `, rName) } + +const testAccVPCSubnetConfig_ipamBase = ` +data "aws_region" "current" {} + +resource "aws_vpc_ipam" "test" { + operating_regions { + region_name = data.aws_region.current.region + } +} +` + +func testAccVPCSubnetConfig_ipamIPv4(rName string) string { + return acctest.ConfigCompose(testAccVPCSubnetConfig_ipamBase, fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + cidr = "10.0.0.0/16" +} + +resource "aws_vpc" "test" { + ipv4_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv4_netmask_length = 24 + + depends_on = [aws_vpc_ipam_pool_cidr.test] + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv4" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + source_ipam_pool_id = aws_vpc_ipam_pool.test.id + + source_resource { + resource_id = aws_vpc.test.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.current.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccVPCSubnetConfig_ipamIPv6(rName string) string { + return acctest.ConfigCompose(testAccVPCSubnetConfig_ipamBase, fmt.Sprintf(` +data "aws_caller_identity" "current" {} + +resource "aws_vpc_ipam_pool" "test" { + address_family = "ipv6" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name +} + +resource "aws_vpc_ipam_pool_cidr" "test" { + ipam_pool_id = aws_vpc_ipam_pool.test.id + netmask_length = 52 +} + +resource "aws_vpc" "test" { + cidr_block = "10.1.0.0/16" + ipv6_ipam_pool_id = aws_vpc_ipam_pool.test.id + ipv6_netmask_length = 56 + + depends_on = [aws_vpc_ipam_pool_cidr.test] + + tags = { + Name = %[1]q + } +} + +resource "aws_vpc_ipam_pool" "vpc" { + address_family = "ipv6" + ipam_scope_id = aws_vpc_ipam.test.private_default_scope_id + locale = data.aws_region.current.name + source_ipam_pool_id = aws_vpc_ipam_pool.test.id + + source_resource { + resource_id = aws_vpc.test.id + resource_owner = data.aws_caller_identity.current.account_id + resource_region = data.aws_region.current.name + resource_type = "vpc" + } + + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccVPCSubnetConfig_ipv4IPAMAllocation(rName string, netmaskLength int) string { + return acctest.ConfigCompose(testAccVPCSubnetConfig_ipamIPv4(rName), fmt.Sprintf(` +data "aws_availability_zones" "available" { + state = "available" +} + +resource "aws_vpc_ipam_pool_cidr" "vpc" { + ipam_pool_id = aws_vpc_ipam_pool.vpc.id + cidr = aws_vpc.test.cidr_block +} + +resource "aws_subnet" "test" { + vpc_id = aws_vpc.test.id + ipv4_ipam_pool_id = aws_vpc_ipam_pool.vpc.id + ipv4_netmask_length = %[1]d + availability_zone = data.aws_availability_zones.available.names[0] + + depends_on = [aws_vpc_ipam_pool_cidr.vpc] +} +`, netmaskLength)) +} + +func testAccVPCSubnetConfig_ipv6IPAMAllocation(rName string, netmaskLength int) string { + return acctest.ConfigCompose(testAccVPCSubnetConfig_ipamIPv6(rName), fmt.Sprintf(` +data "aws_availability_zones" "available" { + state = "available" +} + +resource "aws_vpc_ipam_pool_cidr" "vpc" { + ipam_pool_id = aws_vpc_ipam_pool.vpc.id + cidr = aws_vpc.test.ipv6_cidr_block +} + +resource "aws_subnet" "test" { + vpc_id = aws_vpc.test.id + ipv6_native = true + assign_ipv6_address_on_creation = true + ipv6_ipam_pool_id = aws_vpc_ipam_pool.vpc.id + ipv6_netmask_length = %[1]d + availability_zone = data.aws_availability_zones.available.names[0] + enable_resource_name_dns_aaaa_record_on_launch = true + + depends_on = [aws_vpc_ipam_pool_cidr.vpc] +} +`, netmaskLength)) +} diff --git a/internal/types/cidr_block.go b/internal/types/cidr_block.go index ecdc58ee93eb..e6dcbb7df438 100644 --- a/internal/types/cidr_block.go +++ b/internal/types/cidr_block.go @@ -52,3 +52,32 @@ func CanonicalCIDRBlock(cidr string) string { return ipnet.String() } + +// CIDRBlocksOverlap returns whether the first CIDR block overlaps with (fully or partially contains) +// the second CIDR block. This works for both IPv4 and IPv6 CIDR blocks. +// Returns false if either CIDR block cannot be parsed. +func CIDRBlocksOverlap(cidr1, cidr2 string) bool { + _, net1, err := net.ParseCIDR(cidr1) + if err != nil { + return false + } + + ip2, net2, err := net.ParseCIDR(cidr2) + if err != nil { + return false + } + + return net1.Contains(ip2) || net1.Contains(getLastIP(net2)) +} + +// getLastIP returns the last IP address in a network +func getLastIP(ipnet *net.IPNet) net.IP { + ip := make(net.IP, len(ipnet.IP)) + copy(ip, ipnet.IP) + + for i := range ip { + ip[i] |= ^ipnet.Mask[i] + } + + return ip +} diff --git a/internal/types/cidr_block_test.go b/internal/types/cidr_block_test.go index e153aa3eb8f2..fa8b1b484868 100644 --- a/internal/types/cidr_block_test.go +++ b/internal/types/cidr_block_test.go @@ -72,3 +72,41 @@ func TestCanonicalCIDRBlock(t *testing.T) { } } } + +func TestCIDRBlocksOverlap(t *testing.T) { + t.Parallel() + + for _, ts := range []struct { + cidr1 string + cidr2 string + overlaps bool + }{ + {"10.0.0.0/16", "10.0.1.0/24", true}, + {"172.16.0.0/16", "172.16.0.0/16", true}, + {"192.168.0.0/24", "172.16.0.0/24", false}, + {"10.0.1.0/24", "10.0.0.0/24", false}, + {"10.0.0.0/16", "10.0.255.0/24", true}, + {"10.0.1.0/24", "10.0.0.0/16", false}, + {"2001:db8::/32", "2001:db8:1234::/48", true}, + {"2001:db8::/64", "2001:db8::/64", true}, + {"2001:db8:0000:0000::/64", "2001:db8::/64", true}, + {"2001:db8::/48", "2001:db9::/48", false}, + {"2001:db8::/32", "2a00:1450::/32", false}, + {"2001:db8::/48", "2001:db8:0:ffff::/64", true}, + {"", "10.0.0.0/24", false}, + {"10.0.0.0/24", "", false}, + {"", "", false}, + {"not-a-cidr", "10.0.0.0/24", false}, + {"10.0.0.0/24", "not-a-cidr", false}, + {"10.0.0.0/99", "10.0.0.0/24", false}, + {"0.0.0.0/0", "10.0.0.0/24", true}, + {"::/0", "2001:db8::/32", true}, + {"127.0.0.0/8", "127.0.0.1/32", true}, + {"::1/128", "::1/128", true}, + } { + overlaps := CIDRBlocksOverlap(ts.cidr1, ts.cidr2) + if ts.overlaps != overlaps { + t.Fatalf("CIDRBlocksOverlap(%q, %q) should be: %t", ts.cidr1, ts.cidr2, ts.overlaps) + } + } +}