diff --git a/internal/services/network/nat_gateway_public_ip_association_resource.go b/internal/services/network/nat_gateway_public_ip_association_resource.go index 508070588512..11046787c726 100644 --- a/internal/services/network/nat_gateway_public_ip_association_resource.go +++ b/internal/services/network/nat_gateway_public_ip_association_resource.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/go-azure-helpers/lang/response" "github.com/hashicorp/go-azure-helpers/resourcemanager/commonids" "github.com/hashicorp/go-azure-sdk/resource-manager/network/2025-01-01/natgateways" + "github.com/hashicorp/go-azure-sdk/resource-manager/network/2025-01-01/publicipaddresses" "github.com/hashicorp/terraform-provider-azurerm/helpers/tf" "github.com/hashicorp/terraform-provider-azurerm/internal/clients" "github.com/hashicorp/terraform-provider-azurerm/internal/locks" @@ -70,8 +71,8 @@ func resourceNATGatewayPublicIpAssociationCreate(d *pluginsdk.ResourceData, meta return err } - locks.ByName(natGatewayId.NatGatewayName, natGatewayResourceName) - defer locks.UnlockByName(natGatewayId.NatGatewayName, natGatewayResourceName) + locks.ByID(natGatewayId.ID()) + defer locks.UnlockByID(natGatewayId.ID()) natGateway, err := client.Get(ctx, *natGatewayId, natgateways.DefaultGetOperationOptions()) if err != nil { @@ -88,27 +89,42 @@ func resourceNATGatewayPublicIpAssociationCreate(d *pluginsdk.ResourceData, meta return fmt.Errorf("retrieving %s: `properties` was nil", natGatewayId) } - id := commonids.NewCompositeResourceID(natGatewayId, publicIpAddressId) - - publicIpAddresses := make([]natgateways.SubResource, 0) - if natGateway.Model.Properties.PublicIPAddresses != nil { - for _, existingPublicIPAddress := range *natGateway.Model.Properties.PublicIPAddresses { - if existingPublicIPAddress.Id == nil { - continue - } + publicIPAddress, err := meta.(*clients.Client).Network.PublicIPAddresses.Get(ctx, *publicIpAddressId, publicipaddresses.DefaultGetOperationOptions()) + if err != nil { + if response.WasNotFound(publicIPAddress.HttpResponse) { + return fmt.Errorf("%s was not found", publicIpAddressId) + } + return fmt.Errorf("retrieving %s: %+v", publicIpAddressId, err) + } + if publicIPAddress.Model == nil { + return fmt.Errorf("retrieving %s: `model` was nil", publicIpAddressId) + } + if publicIPAddress.Model.Properties == nil { + return fmt.Errorf("retrieving %s: `properties` was nil", publicIpAddressId) + } - if strings.EqualFold(*existingPublicIPAddress.Id, publicIpAddressId.ID()) { - return tf.ImportAsExistsError("azurerm_nat_gateway_public_ip_association", id.ID()) - } + isIPv6 := pointer.From(publicIPAddress.Model.Properties.PublicIPAddressVersion) == publicipaddresses.IPVersionIPvSix + id := commonids.NewCompositeResourceID(natGatewayId, publicIpAddressId) - publicIpAddresses = append(publicIpAddresses, existingPublicIPAddress) + gatewayProps := natGateway.Model.Properties + publicIpAddresses := pointer.From(gatewayProps.PublicIPAddresses) + if isIPv6 { + publicIpAddresses = pointer.From(gatewayProps.PublicIPAddressesV6) + } + for _, existingPublicIPAddress := range publicIpAddresses { + if strings.EqualFold(pointer.From(existingPublicIPAddress.Id), publicIpAddressId.ID()) { + return tf.ImportAsExistsError("azurerm_nat_gateway_public_ip_association", id.ID()) } } publicIpAddresses = append(publicIpAddresses, natgateways.SubResource{ Id: pointer.To(publicIpAddressId.ID()), }) - natGateway.Model.Properties.PublicIPAddresses = &publicIpAddresses + if isIPv6 { + gatewayProps.PublicIPAddressesV6 = pointer.To(publicIpAddresses) + } else { + gatewayProps.PublicIPAddresses = pointer.To(publicIpAddresses) + } if err := client.CreateOrUpdateThenPoll(ctx, *natGatewayId, *natGateway.Model); err != nil { return fmt.Errorf("updating %s: %+v", natGatewayId, err) @@ -139,32 +155,16 @@ func resourceNATGatewayPublicIpAssociationRead(d *pluginsdk.ResourceData, meta i return fmt.Errorf("retrieving %s: %+v", id.First, err) } - if model := natGateway.Model; model != nil { - if props := model.Properties; props != nil { - if props.PublicIPAddresses == nil { - log.Printf("[DEBUG] %s doesn't have any Public IP's - removing from state!", id.First) - d.SetId("") - return nil - } - - publicIPAddressId := "" - for _, pip := range *props.PublicIPAddresses { - if pip.Id == nil { - continue - } - - if strings.EqualFold(*pip.Id, id.Second.ID()) { - publicIPAddressId = *pip.Id - break - } - } - - if publicIPAddressId == "" { - log.Printf("[DEBUG] Association between %s and %s was not found - removing from state", id.First, id.Second) - d.SetId("") - return nil - } - } + if natGateway.Model == nil { + return fmt.Errorf("retrieving %s: `model` was nil", id.First) + } + if natGateway.Model.Properties == nil { + return fmt.Errorf("retrieving %s: `properties` was nil", id.First) + } + if !natGatewayPublicIpAssociationExists(natGateway.Model.Properties, id.Second.ID()) { + log.Printf("[DEBUG] Association between %s and %s was not found - removing from state", id.First, id.Second) + d.SetId("") + return nil } d.Set("nat_gateway_id", id.First.ID()) @@ -183,8 +183,8 @@ func resourceNATGatewayPublicIpAssociationDelete(d *pluginsdk.ResourceData, meta return err } - locks.ByName(id.First.NatGatewayName, natGatewayResourceName) - defer locks.UnlockByName(id.First.NatGatewayName, natGatewayResourceName) + locks.ByID(id.First.ID()) + defer locks.UnlockByID(id.First.ID()) natGateway, err := client.Get(ctx, *id.First, natgateways.DefaultGetOperationOptions()) if err != nil { @@ -201,23 +201,64 @@ func resourceNATGatewayPublicIpAssociationDelete(d *pluginsdk.ResourceData, meta return fmt.Errorf("retrieving %s: `properties` was nil", id.First) } - publicIpAddresses := make([]natgateways.SubResource, 0) - if publicIPAddresses := natGateway.Model.Properties.PublicIPAddresses; publicIPAddresses != nil { - for _, publicIPAddress := range *publicIPAddresses { - if publicIPAddress.Id == nil { - continue - } - - if !strings.EqualFold(*publicIPAddress.Id, id.Second.ID()) { - publicIpAddresses = append(publicIpAddresses, publicIPAddress) - } - } + if !removeNATGatewayPublicIpAssociation(natGateway.Model.Properties, id.Second.ID()) { + return nil } - natGateway.Model.Properties.PublicIPAddresses = &publicIpAddresses if err := client.CreateOrUpdateThenPoll(ctx, *id.First, *natGateway.Model); err != nil { - return fmt.Errorf("removing association between %s and %s: %+v", id.First, id.Second, err) + return fmt.Errorf("deleting %s: %+v", id, err) } return nil } + +func natGatewayPublicIpAssociationExists(properties *natgateways.NatGatewayPropertiesFormat, publicIPAddressId string) bool { + if properties == nil { + return false + } + + for _, publicIPAddress := range pointer.From(properties.PublicIPAddresses) { + if strings.EqualFold(pointer.From(publicIPAddress.Id), publicIPAddressId) { + return true + } + } + + for _, publicIPAddress := range pointer.From(properties.PublicIPAddressesV6) { + if strings.EqualFold(pointer.From(publicIPAddress.Id), publicIPAddressId) { + return true + } + } + + return false +} + +func removeNATGatewayPublicIpAssociation(properties *natgateways.NatGatewayPropertiesFormat, publicIPAddressId string) bool { + if properties == nil { + return false + } + + removed := false + updatedIPv4Addresses := make([]natgateways.SubResource, 0) + for _, publicIPAddress := range pointer.From(properties.PublicIPAddresses) { + if strings.EqualFold(pointer.From(publicIPAddress.Id), publicIPAddressId) { + removed = true + continue + } + + updatedIPv4Addresses = append(updatedIPv4Addresses, publicIPAddress) + } + properties.PublicIPAddresses = pointer.To(updatedIPv4Addresses) + + updatedIPv6Addresses := make([]natgateways.SubResource, 0) + for _, publicIPAddress := range pointer.From(properties.PublicIPAddressesV6) { + if strings.EqualFold(pointer.From(publicIPAddress.Id), publicIPAddressId) { + removed = true + continue + } + + updatedIPv6Addresses = append(updatedIPv6Addresses, publicIPAddress) + } + properties.PublicIPAddressesV6 = pointer.To(updatedIPv6Addresses) + + return removed +} diff --git a/internal/services/network/nat_gateway_public_ip_association_resource_test.go b/internal/services/network/nat_gateway_public_ip_association_resource_test.go index adde729536a8..2708a04251c4 100644 --- a/internal/services/network/nat_gateway_public_ip_association_resource_test.go +++ b/internal/services/network/nat_gateway_public_ip_association_resource_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/go-azure-helpers/lang/pointer" "github.com/hashicorp/go-azure-helpers/resourcemanager/commonids" "github.com/hashicorp/go-azure-sdk/resource-manager/network/2025-01-01/natgateways" + "github.com/hashicorp/go-azure-sdk/resource-manager/network/2025-01-01/publicipaddresses" "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance" "github.com/hashicorp/terraform-provider-azurerm/internal/acceptance/check" "github.com/hashicorp/terraform-provider-azurerm/internal/clients" @@ -58,6 +59,34 @@ func TestAccNatGatewayPublicIpAssociation_updateNatGateway(t *testing.T) { }) } +func TestAccNatGatewayPublicIpAssociation_ipv6(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_nat_gateway_public_ip_association", "test") + r := NatGatewayPublicAssociationResource{} + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.ipv6(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + +func TestAccNatGatewayPublicIpAssociation_multipleAssociations(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_nat_gateway_public_ip_association", "test") + r := NatGatewayPublicAssociationResource{} + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.multipleAssociations(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + ), + }, + data.ImportStep(), + }) +} + func TestAccNatGatewayPublicIpAssociation_requiresImport(t *testing.T) { data := acceptance.BuildTestData(t, "azurerm_nat_gateway_public_ip_association", "test") r := NatGatewayPublicAssociationResource{} @@ -100,13 +129,16 @@ func (t NatGatewayPublicAssociationResource) Exists(ctx context.Context, clients found := false if model := resp.Model; model != nil { if props := model.Properties; props != nil { - if props.PublicIPAddresses != nil { - for _, pip := range *props.PublicIPAddresses { - if pip.Id == nil { - continue - } + for _, pip := range pointer.From(props.PublicIPAddresses) { + if strings.EqualFold(pointer.From(pip.Id), id.Second.ID()) { + found = true + break + } + } - if strings.EqualFold(*pip.Id, id.Second.ID()) { + if !found { + for _, pip := range pointer.From(props.PublicIPAddressesV6) { + if strings.EqualFold(pointer.From(pip.Id), id.Second.ID()) { found = true break } @@ -138,18 +170,28 @@ func (NatGatewayPublicAssociationResource) Destroy(ctx context.Context, client * return nil, fmt.Errorf("retrieving %s: `properties` was nil", id.First) } - updatedAddresses := make([]natgateways.SubResource, 0) - if publicIpAddresses := resp.Model.Properties.PublicIPAddresses; publicIpAddresses != nil { - for _, publicIpAddress := range *publicIpAddresses { - if !strings.EqualFold(*publicIpAddress.Id, id.Second.ID()) { - updatedAddresses = append(updatedAddresses, publicIpAddress) - } + updatedIPv4Addresses := make([]natgateways.SubResource, 0) + for _, publicIpAddress := range pointer.From(resp.Model.Properties.PublicIPAddresses) { + if strings.EqualFold(pointer.From(publicIpAddress.Id), id.Second.ID()) { + continue } + + updatedIPv4Addresses = append(updatedIPv4Addresses, publicIpAddress) } - resp.Model.Properties.PublicIPAddresses = &updatedAddresses + resp.Model.Properties.PublicIPAddresses = pointer.To(updatedIPv4Addresses) + + updatedIPv6Addresses := make([]natgateways.SubResource, 0) + for _, publicIpAddress := range pointer.From(resp.Model.Properties.PublicIPAddressesV6) { + if strings.EqualFold(pointer.From(publicIpAddress.Id), id.Second.ID()) { + continue + } + + updatedIPv6Addresses = append(updatedIPv6Addresses, publicIpAddress) + } + resp.Model.Properties.PublicIPAddressesV6 = pointer.To(updatedIPv6Addresses) if err := client.Network.NatGateways.CreateOrUpdateThenPoll(ctx2, *id.First, *resp.Model); err != nil { - return nil, fmt.Errorf("removing Association between %s and %s: %+v", id.First, id.Second, err) + return nil, fmt.Errorf("deleting %s: %+v", id, err) } return pointer.To(true), nil @@ -157,10 +199,10 @@ func (NatGatewayPublicAssociationResource) Destroy(ctx context.Context, client * func (r NatGatewayPublicAssociationResource) basic(data acceptance.TestData) string { return fmt.Sprintf(` -%s +%[1]s resource "azurerm_nat_gateway" "test" { - name = "acctest-NatGateway-%d" + name = "acctest-NatGateway-%[2]d" location = azurerm_resource_group.test.location resource_group_name = azurerm_resource_group.test.name sku_name = "Standard" @@ -184,12 +226,58 @@ resource "azurerm_nat_gateway_public_ip_association" "import" { `, r.basic(data)) } +func (r NatGatewayPublicAssociationResource) ipv6(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azurerm_nat_gateway" "test" { + name = "acctest-NatGateway-%[2]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + sku_name = "StandardV2" +} + +resource "azurerm_nat_gateway_public_ip_association" "test" { + nat_gateway_id = azurerm_nat_gateway.test.id + public_ip_address_id = azurerm_public_ip.test.id +} +`, r.templateIPv6(data, string(publicipaddresses.PublicIPAddressSkuNameStandardVTwo)), data.RandomInteger) +} + +func (r NatGatewayPublicAssociationResource) multipleAssociations(data acceptance.TestData) string { + return fmt.Sprintf(` +%[1]s + +resource "azurerm_nat_gateway" "test" { + name = "acctest-NatGateway-%[2]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + sku_name = "StandardV2" +} + +resource "azurerm_nat_gateway_public_ip_association" "test" { + nat_gateway_id = azurerm_nat_gateway.test.id + public_ip_address_id = azurerm_public_ip.test.id +} + +resource "azurerm_nat_gateway_public_ip_association" "test2" { + nat_gateway_id = azurerm_nat_gateway.test.id + public_ip_address_id = azurerm_public_ip.test2.id +} + +resource "azurerm_nat_gateway_public_ip_association" "test3" { + nat_gateway_id = azurerm_nat_gateway.test.id + public_ip_address_id = azurerm_public_ip.test3.id +} +`, r.templateDualStack(data), data.RandomInteger) +} + func (r NatGatewayPublicAssociationResource) updateNatGateway(data acceptance.TestData) string { return fmt.Sprintf(` -%s +%[1]s resource "azurerm_nat_gateway" "test" { - name = "acctest-NatGateway-%d" + name = "acctest-NatGateway-%[2]d" location = azurerm_resource_group.test.location resource_group_name = azurerm_resource_group.test.name sku_name = "Standard" @@ -212,16 +300,77 @@ provider "azurerm" { } resource "azurerm_resource_group" "test" { - name = "acctestRG-ngpi-%d" - location = "%s" + name = "acctestRG-ngpi-%[1]d" + location = "%[2]s" } resource "azurerm_public_ip" "test" { - name = "acctest-PIP-%d" + name = "acctest-PIP-%[1]d" location = azurerm_resource_group.test.location resource_group_name = azurerm_resource_group.test.name allocation_method = "Static" sku = "Standard" } -`, data.RandomInteger, data.Locations.Primary, data.RandomInteger) +`, data.RandomInteger, data.Locations.Primary) +} + +func (NatGatewayPublicAssociationResource) templateIPv6(data acceptance.TestData, sku string) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "test" { + name = "acctestRG-ngpi-v6-%[1]d" + location = "%[2]s" +} + +resource "azurerm_public_ip" "test" { + name = "acctest-PIPv6-%[1]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Static" + sku = "%[3]s" + ip_version = "IPv6" +} +`, data.RandomInteger, data.Locations.Primary, sku) +} + +func (NatGatewayPublicAssociationResource) templateDualStack(data acceptance.TestData) string { + return fmt.Sprintf(` +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "test" { + name = "acctestRG-ngpi-dual-%[1]d" + location = "%[2]s" +} + +resource "azurerm_public_ip" "test" { + name = "acctest-PIPv4-%[1]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Static" + sku = "StandardV2" +} + +resource "azurerm_public_ip" "test2" { + name = "acctest-PIPv6-%[1]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Static" + sku = "StandardV2" + ip_version = "IPv6" +} + +resource "azurerm_public_ip" "test3" { + name = "acctest-PIPv6b-%[1]d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Static" + sku = "StandardV2" + ip_version = "IPv6" +} +`, data.RandomInteger, data.Locations.Primary) } diff --git a/website/docs/r/nat_gateway_public_ip_association.html.markdown b/website/docs/r/nat_gateway_public_ip_association.html.markdown index 8a3a59b333e4..0e9ae9adda43 100644 --- a/website/docs/r/nat_gateway_public_ip_association.html.markdown +++ b/website/docs/r/nat_gateway_public_ip_association.html.markdown @@ -3,13 +3,13 @@ subcategory: "Network" layout: "azurerm" page_title: "Azure Resource Manager: azurerm_nat_gateway_public_ip_association" description: |- - Manages the association between a NAT Gateway and a Public IP. + Manages a NAT Gateway Public IP Address association. --- # azurerm_nat_gateway_public_ip_association -Manages the association between a NAT Gateway and a Public IP. +Manages a NAT Gateway Public IP Address association. ## Example Usage @@ -40,39 +40,71 @@ resource "azurerm_nat_gateway_public_ip_association" "example" { } ``` +## Example Usage for IPv6 + +```hcl +resource "azurerm_resource_group" "example" { + name = "example-resources" + location = "West Europe" +} + +resource "azurerm_public_ip" "example" { + name = "example-pip-v6" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + allocation_method = "Static" + sku = "StandardV2" + ip_version = "IPv6" +} + +resource "azurerm_nat_gateway" "example" { + name = "example-nat-gateway-v6" + location = azurerm_resource_group.example.location + resource_group_name = azurerm_resource_group.example.name + sku_name = "StandardV2" +} + +resource "azurerm_nat_gateway_public_ip_association" "example" { + nat_gateway_id = azurerm_nat_gateway.example.id + public_ip_address_id = azurerm_public_ip.example.id +} +``` + ## Arguments Reference The following arguments are supported: * `nat_gateway_id` - (Required) The ID of the NAT Gateway. Changing this forces a new resource to be created. -* `public_ip_address_id` - (Required) The ID of the Public IP which this NAT Gateway which should be connected to. Changing this forces a new resource to be created. +* `public_ip_address_id` - (Required) The ID of the Public IP Address which this NAT Gateway should be connected to. Changing this forces a new resource to be created. + +~> **Note:** When `nat_gateway_id` references a NAT Gateway with SKU `Standard`, `public_ip_address_id` must reference a Public IP Address with SKU `Standard`. When `nat_gateway_id` references a NAT Gateway with SKU `StandardV2`, `public_ip_address_id` must reference a Public IP Address with SKU `StandardV2`. -~> **Note:** When `nat_gateway_id` references a `StandardV2` NAT Gateway, `public_ip_address_id` must reference a `StandardV2` Public IP. Azure rejects `Standard` Public IPs with `StandardV2` NAT Gateways, and this incompatibility is not validated during terraform plan phase. +~> **Note:** When `public_ip_address_id` references an `IPv6` Public IP Address, `nat_gateway_id` must reference a NAT Gateway with SKU `StandardV2`, and `public_ip_address_id` must reference an `IPv6` Public IP Address with SKU `StandardV2`. ## Attributes Reference In addition to the Arguments listed above - the following Attributes are exported: -* `id` - The (Terraform specific) ID of the Association between the NAT Gateway and the Public IP. +* `id` - The Terraform-specific ID of the NAT Gateway Public IP Address association. ## Timeouts The `timeouts` block allows you to specify [timeouts](https://developer.hashicorp.com/terraform/language/resources/configure#define-operation-timeouts) for certain actions: -* `create` - (Defaults to 30 minutes) Used when creating the association between the NAT Gateway and the Public IP. -* `read` - (Defaults to 5 minutes) Used when retrieving the association between the NAT Gateway and the Public IP. -* `delete` - (Defaults to 30 minutes) Used when deleting the association between the NAT Gateway and the Public IP. +* `create` - (Defaults to 30 minutes) Used when creating the NAT Gateway Public IP Address association. +* `read` - (Defaults to 5 minutes) Used when retrieving the NAT Gateway Public IP Address association. +* `delete` - (Defaults to 30 minutes) Used when deleting the NAT Gateway Public IP Address association. ## Import -Associations between NAT Gateway and Public IP Addresses can be imported using the `resource id`, e.g. +A NAT Gateway Public IP Address association can be imported using the `resource id`, e.g. ```shell -terraform import azurerm_nat_gateway_public_ip_association.example "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/group1/providers/Microsoft.Network/natGateways/gateway1|/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/mygroup1/providers/Microsoft.Network/publicIPAddresses/myPublicIpAddress1" +terraform import azurerm_nat_gateway_public_ip_association.example "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/resourceGroup1/providers/Microsoft.Network/natGateways/natGateway1|/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/resourceGroup1/providers/Microsoft.Network/publicIPAddresses/publicIPAddress1" ``` --> **Note:** This is a Terraform Specific ID in the format `{natGatewayID}|{publicIPAddressID}` +-> **Note:** This is a Terraform-specific ID in the format `{natGatewayID}|{publicIPAddressID}`. ## API Providers