From aecc09ec4c0069ffe88c731216d020d4bd6bd8c2 Mon Sep 17 00:00:00 2001 From: asudbring Date: Tue, 4 Aug 2026 04:36:18 +0100 Subject: [PATCH 1/2] Add 101-service-endpoint-storage quickstart sample Adds a Terraform quickstart that deploys a virtual network with a subnet that has a Microsoft.Storage service endpoint, a Standard static public IP that serves as the network identifier, a Linux virtual machine, and an Azure Storage account restricted to the subnet. Test-deployed and torn down against two Azure subscriptions in eastus2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../101-service-endpoint-storage/main.tf | 118 ++++++++++++++++++ .../101-service-endpoint-storage/outputs.tf | 27 ++++ .../101-service-endpoint-storage/providers.tf | 22 ++++ .../101-service-endpoint-storage/readme.md | 30 +++++ .../101-service-endpoint-storage/ssh.tf | 24 ++++ .../101-service-endpoint-storage/variables.tf | 23 ++++ 6 files changed, 244 insertions(+) create mode 100644 quickstart/101-service-endpoint-storage/main.tf create mode 100644 quickstart/101-service-endpoint-storage/outputs.tf create mode 100644 quickstart/101-service-endpoint-storage/providers.tf create mode 100644 quickstart/101-service-endpoint-storage/readme.md create mode 100644 quickstart/101-service-endpoint-storage/ssh.tf create mode 100644 quickstart/101-service-endpoint-storage/variables.tf diff --git a/quickstart/101-service-endpoint-storage/main.tf b/quickstart/101-service-endpoint-storage/main.tf new file mode 100644 index 000000000..27cb9c8b1 --- /dev/null +++ b/quickstart/101-service-endpoint-storage/main.tf @@ -0,0 +1,118 @@ +# Create a random name for the resource group using random_pet +resource "random_pet" "rg_name" { + prefix = var.resource_group_name_prefix +} + +# Create the resource group using the generated random name +resource "azurerm_resource_group" "rg" { + location = var.resource_group_location + name = random_pet.rg_name.id +} + +# Create the virtual network +resource "azurerm_virtual_network" "vnet" { + name = "vnet-1" + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name +} + +# Create the subnet and enable the Microsoft.Storage service endpoint. +# The service endpoint routes traffic from this subnet to Azure Storage +# over the Azure backbone. +resource "azurerm_subnet" "subnet" { + name = "subnet-1" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = ["10.0.0.0/24"] + service_endpoints = ["Microsoft.Storage"] +} + +# Create the Standard, static, IPv4 public IP address that serves as the +# network identifier for the standard service endpoint. In the standard +# service endpoint model this public IP identifies the subnet's service +# endpoint traffic; it is not attached to a NIC. +resource "azurerm_public_ip" "network_identifier" { + name = "pip-network-identifier" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Static" + sku = "Standard" + ip_version = "IPv4" +} + +# Create the network interface for the virtual machine. The VM uses a private +# IP address only and reaches Azure Storage through the subnet service endpoint. +resource "azurerm_network_interface" "nic" { + name = "nic-1" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + + ip_configuration { + name = "ipconfig-1" + subnet_id = azurerm_subnet.subnet.id + private_ip_address_allocation = "Dynamic" + } +} + +# Create the Linux virtual machine that represents the IaaS workload in the +# subscription. One VM is created per subscription. +resource "azurerm_linux_virtual_machine" "vm" { + name = "vm-1" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + network_interface_ids = [azurerm_network_interface.nic.id] + size = var.vm_size + + os_disk { + name = "osdisk-1" + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } + + computer_name = "vm-1" + admin_username = var.username + + admin_ssh_key { + username = var.username + public_key = azapi_resource_action.ssh_public_key_gen.output.publicKey + } + + boot_diagnostics {} +} + +# Generate a unique suffix for the globally unique storage account name +resource "random_string" "name" { + length = 8 + special = false + upper = false + lower = true + numeric = true +} + +# Create the Azure Storage account that acts as the PaaS resource behind the +# service endpoint. The account is locked down so it only accepts traffic from +# the subnet that has the Microsoft.Storage service endpoint enabled. +resource "azurerm_storage_account" "sa" { + name = "sa${random_string.name.result}" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + account_tier = "Standard" + account_replication_type = "LRS" + account_kind = "StorageV2" + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + + network_rules { + default_action = "Deny" + bypass = ["AzureServices"] + virtual_network_subnet_ids = [azurerm_subnet.subnet.id] + } +} diff --git a/quickstart/101-service-endpoint-storage/outputs.tf b/quickstart/101-service-endpoint-storage/outputs.tf new file mode 100644 index 000000000..981016967 --- /dev/null +++ b/quickstart/101-service-endpoint-storage/outputs.tf @@ -0,0 +1,27 @@ +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "virtual_network_name" { + value = azurerm_virtual_network.vnet.name +} + +output "subnet_name" { + value = azurerm_subnet.subnet.name +} + +output "subnet_service_endpoints" { + value = azurerm_subnet.subnet.service_endpoints +} + +output "network_identifier_public_ip" { + value = azurerm_public_ip.network_identifier.ip_address +} + +output "storage_account_name" { + value = azurerm_storage_account.sa.name +} + +output "virtual_machine_name" { + value = azurerm_linux_virtual_machine.vm.name +} diff --git a/quickstart/101-service-endpoint-storage/providers.tf b/quickstart/101-service-endpoint-storage/providers.tf new file mode 100644 index 000000000..8d74faa7e --- /dev/null +++ b/quickstart/101-service-endpoint-storage/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~>1.5" + } + azurerm = { + source = "hashicorp/azurerm" + version = "~>4.0" + } + random = { + source = "hashicorp/random" + version = "~>3.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/quickstart/101-service-endpoint-storage/readme.md b/quickstart/101-service-endpoint-storage/readme.md new file mode 100644 index 000000000..5bf269e83 --- /dev/null +++ b/quickstart/101-service-endpoint-storage/readme.md @@ -0,0 +1,30 @@ +# Azure Storage service endpoint + +This template deploys a virtual network with a subnet that has a Microsoft.Storage service endpoint enabled, a Standard static public IP address that serves as the network identifier, a Linux virtual machine, and an Azure Storage account restricted to the subnet. + +## Terraform resource types + +- [random_pet](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet) +- [azurerm_resource_group](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) +- [azurerm_virtual_network](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) +- [azurerm_subnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) +- [azurerm_public_ip](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/public_ip) +- [azurerm_network_interface](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_interface) +- [azapi_resource](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/azapi_resource) +- [azapi_resource_action](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/azapi_resource_action) +- [azurerm_linux_virtual_machine](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_virtual_machine) +- [random_string](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) +- [azurerm_storage_account](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_account) + +## Variables + +| **Name** | **Description** | **Default** | +|---|---|---| +| `resource_group_name_prefix` | Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription. | rg | +| `resource_group_location` | Location of the resource group. | eastus | +| `username` | The username for the local account that will be created on the new virtual machine. | azureadmin | +| `vm_size` | The size of the virtual machine. | Standard_DS1_v2 | + +## Example + +To see how to run this example, see [Quickstart: Configure an Azure Storage service endpoint using Terraform](https://learn.microsoft.com/azure/private-link/service-endpoint-standard-overview). diff --git a/quickstart/101-service-endpoint-storage/ssh.tf b/quickstart/101-service-endpoint-storage/ssh.tf new file mode 100644 index 000000000..cd475dccd --- /dev/null +++ b/quickstart/101-service-endpoint-storage/ssh.tf @@ -0,0 +1,24 @@ +resource "random_pet" "ssh_key_name" { + prefix = "ssh" + separator = "" +} + +resource "azapi_resource_action" "ssh_public_key_gen" { + type = "Microsoft.Compute/sshPublicKeys@2022-11-01" + resource_id = azapi_resource.ssh_public_key.id + action = "generateKeyPair" + method = "POST" + + response_export_values = ["publicKey", "privateKey"] +} + +resource "azapi_resource" "ssh_public_key" { + type = "Microsoft.Compute/sshPublicKeys@2022-11-01" + name = random_pet.ssh_key_name.id + location = azurerm_resource_group.rg.location + parent_id = azurerm_resource_group.rg.id +} + +output "key_data" { + value = azapi_resource_action.ssh_public_key_gen.output.publicKey +} diff --git a/quickstart/101-service-endpoint-storage/variables.tf b/quickstart/101-service-endpoint-storage/variables.tf new file mode 100644 index 000000000..798298ae7 --- /dev/null +++ b/quickstart/101-service-endpoint-storage/variables.tf @@ -0,0 +1,23 @@ +variable "resource_group_location" { + type = string + default = "eastus" + description = "Location of the resource group." +} + +variable "resource_group_name_prefix" { + type = string + default = "rg" + description = "Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription." +} + +variable "username" { + type = string + default = "azureadmin" + description = "The username for the local account that will be created on the new virtual machine." +} + +variable "vm_size" { + type = string + default = "Standard_DS1_v2" + description = "The size of the virtual machine." +} From 6105c1fd9aae13e156439a2a9abb7eeb7ea484ba Mon Sep 17 00:00:00 2001 From: asudbring Date: Wed, 5 Aug 2026 05:08:00 +0100 Subject: [PATCH 2/2] fix: implement actual standard service endpoint, not basic service endpoint The original sample configured a basic virtual network service endpoint (subnet service_endpoints plus storage network rules) and named it after the standard service endpoint feature. Those are different features. A standard service endpoint requires a network identifier associated with the subnet service endpoint, and a network security perimeter that authorizes that identifier with an IP-based inbound access rule. None of that was present. Changes: - Rename sample to 101-standard-service-endpoint-storage - Associate a public IP address as the service endpoint networkIdentifier via azapi (the azurerm provider does not expose this property) - Add public IP prefix backing the network identifier - Add network security perimeter, profile, association, and inbound access rule matching the network identifier prefix - Add NAT gateway for VM outbound connectivity - Add storage file share for connectivity validation - Drop subnet-scoped storage network rules; the perimeter governs access - Pin azapi to ~>2.0 to match the object-syntax body already in use - Default location to eastus2 Verified: terraform apply and destroy against a subscription with the AllowServiceEndpointNetworkIdentifier feature registered. 21 resources created and destroyed. Subnet serviceEndpoints reported networkIdentifier associated with provisioningState Succeeded. --- .../101-service-endpoint-storage/main.tf | 118 ---------- .../101-service-endpoint-storage/outputs.tf | 27 --- .../101-service-endpoint-storage/readme.md | 30 --- .../101-service-endpoint-storage/variables.tf | 23 -- .../main.tf | 211 ++++++++++++++++++ .../outputs.tf | 45 ++++ .../providers.tf | 2 +- .../readme.md | 51 +++++ .../ssh.tf | 0 .../variables.tf | 40 ++++ 10 files changed, 348 insertions(+), 199 deletions(-) delete mode 100644 quickstart/101-service-endpoint-storage/main.tf delete mode 100644 quickstart/101-service-endpoint-storage/outputs.tf delete mode 100644 quickstart/101-service-endpoint-storage/readme.md delete mode 100644 quickstart/101-service-endpoint-storage/variables.tf create mode 100644 quickstart/101-standard-service-endpoint-storage/main.tf create mode 100644 quickstart/101-standard-service-endpoint-storage/outputs.tf rename quickstart/{101-service-endpoint-storage => 101-standard-service-endpoint-storage}/providers.tf (93%) create mode 100644 quickstart/101-standard-service-endpoint-storage/readme.md rename quickstart/{101-service-endpoint-storage => 101-standard-service-endpoint-storage}/ssh.tf (100%) create mode 100644 quickstart/101-standard-service-endpoint-storage/variables.tf diff --git a/quickstart/101-service-endpoint-storage/main.tf b/quickstart/101-service-endpoint-storage/main.tf deleted file mode 100644 index 27cb9c8b1..000000000 --- a/quickstart/101-service-endpoint-storage/main.tf +++ /dev/null @@ -1,118 +0,0 @@ -# Create a random name for the resource group using random_pet -resource "random_pet" "rg_name" { - prefix = var.resource_group_name_prefix -} - -# Create the resource group using the generated random name -resource "azurerm_resource_group" "rg" { - location = var.resource_group_location - name = random_pet.rg_name.id -} - -# Create the virtual network -resource "azurerm_virtual_network" "vnet" { - name = "vnet-1" - address_space = ["10.0.0.0/16"] - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name -} - -# Create the subnet and enable the Microsoft.Storage service endpoint. -# The service endpoint routes traffic from this subnet to Azure Storage -# over the Azure backbone. -resource "azurerm_subnet" "subnet" { - name = "subnet-1" - resource_group_name = azurerm_resource_group.rg.name - virtual_network_name = azurerm_virtual_network.vnet.name - address_prefixes = ["10.0.0.0/24"] - service_endpoints = ["Microsoft.Storage"] -} - -# Create the Standard, static, IPv4 public IP address that serves as the -# network identifier for the standard service endpoint. In the standard -# service endpoint model this public IP identifies the subnet's service -# endpoint traffic; it is not attached to a NIC. -resource "azurerm_public_ip" "network_identifier" { - name = "pip-network-identifier" - resource_group_name = azurerm_resource_group.rg.name - location = azurerm_resource_group.rg.location - allocation_method = "Static" - sku = "Standard" - ip_version = "IPv4" -} - -# Create the network interface for the virtual machine. The VM uses a private -# IP address only and reaches Azure Storage through the subnet service endpoint. -resource "azurerm_network_interface" "nic" { - name = "nic-1" - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - - ip_configuration { - name = "ipconfig-1" - subnet_id = azurerm_subnet.subnet.id - private_ip_address_allocation = "Dynamic" - } -} - -# Create the Linux virtual machine that represents the IaaS workload in the -# subscription. One VM is created per subscription. -resource "azurerm_linux_virtual_machine" "vm" { - name = "vm-1" - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - network_interface_ids = [azurerm_network_interface.nic.id] - size = var.vm_size - - os_disk { - name = "osdisk-1" - caching = "ReadWrite" - storage_account_type = "Standard_LRS" - } - - source_image_reference { - publisher = "Canonical" - offer = "0001-com-ubuntu-server-jammy" - sku = "22_04-lts-gen2" - version = "latest" - } - - computer_name = "vm-1" - admin_username = var.username - - admin_ssh_key { - username = var.username - public_key = azapi_resource_action.ssh_public_key_gen.output.publicKey - } - - boot_diagnostics {} -} - -# Generate a unique suffix for the globally unique storage account name -resource "random_string" "name" { - length = 8 - special = false - upper = false - lower = true - numeric = true -} - -# Create the Azure Storage account that acts as the PaaS resource behind the -# service endpoint. The account is locked down so it only accepts traffic from -# the subnet that has the Microsoft.Storage service endpoint enabled. -resource "azurerm_storage_account" "sa" { - name = "sa${random_string.name.result}" - resource_group_name = azurerm_resource_group.rg.name - location = azurerm_resource_group.rg.location - account_tier = "Standard" - account_replication_type = "LRS" - account_kind = "StorageV2" - min_tls_version = "TLS1_2" - allow_nested_items_to_be_public = false - - network_rules { - default_action = "Deny" - bypass = ["AzureServices"] - virtual_network_subnet_ids = [azurerm_subnet.subnet.id] - } -} diff --git a/quickstart/101-service-endpoint-storage/outputs.tf b/quickstart/101-service-endpoint-storage/outputs.tf deleted file mode 100644 index 981016967..000000000 --- a/quickstart/101-service-endpoint-storage/outputs.tf +++ /dev/null @@ -1,27 +0,0 @@ -output "resource_group_name" { - value = azurerm_resource_group.rg.name -} - -output "virtual_network_name" { - value = azurerm_virtual_network.vnet.name -} - -output "subnet_name" { - value = azurerm_subnet.subnet.name -} - -output "subnet_service_endpoints" { - value = azurerm_subnet.subnet.service_endpoints -} - -output "network_identifier_public_ip" { - value = azurerm_public_ip.network_identifier.ip_address -} - -output "storage_account_name" { - value = azurerm_storage_account.sa.name -} - -output "virtual_machine_name" { - value = azurerm_linux_virtual_machine.vm.name -} diff --git a/quickstart/101-service-endpoint-storage/readme.md b/quickstart/101-service-endpoint-storage/readme.md deleted file mode 100644 index 5bf269e83..000000000 --- a/quickstart/101-service-endpoint-storage/readme.md +++ /dev/null @@ -1,30 +0,0 @@ -# Azure Storage service endpoint - -This template deploys a virtual network with a subnet that has a Microsoft.Storage service endpoint enabled, a Standard static public IP address that serves as the network identifier, a Linux virtual machine, and an Azure Storage account restricted to the subnet. - -## Terraform resource types - -- [random_pet](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet) -- [azurerm_resource_group](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) -- [azurerm_virtual_network](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) -- [azurerm_subnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) -- [azurerm_public_ip](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/public_ip) -- [azurerm_network_interface](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_interface) -- [azapi_resource](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/azapi_resource) -- [azapi_resource_action](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/azapi_resource_action) -- [azurerm_linux_virtual_machine](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_virtual_machine) -- [random_string](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) -- [azurerm_storage_account](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_account) - -## Variables - -| **Name** | **Description** | **Default** | -|---|---|---| -| `resource_group_name_prefix` | Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription. | rg | -| `resource_group_location` | Location of the resource group. | eastus | -| `username` | The username for the local account that will be created on the new virtual machine. | azureadmin | -| `vm_size` | The size of the virtual machine. | Standard_DS1_v2 | - -## Example - -To see how to run this example, see [Quickstart: Configure an Azure Storage service endpoint using Terraform](https://learn.microsoft.com/azure/private-link/service-endpoint-standard-overview). diff --git a/quickstart/101-service-endpoint-storage/variables.tf b/quickstart/101-service-endpoint-storage/variables.tf deleted file mode 100644 index 798298ae7..000000000 --- a/quickstart/101-service-endpoint-storage/variables.tf +++ /dev/null @@ -1,23 +0,0 @@ -variable "resource_group_location" { - type = string - default = "eastus" - description = "Location of the resource group." -} - -variable "resource_group_name_prefix" { - type = string - default = "rg" - description = "Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription." -} - -variable "username" { - type = string - default = "azureadmin" - description = "The username for the local account that will be created on the new virtual machine." -} - -variable "vm_size" { - type = string - default = "Standard_DS1_v2" - description = "The size of the virtual machine." -} diff --git a/quickstart/101-standard-service-endpoint-storage/main.tf b/quickstart/101-standard-service-endpoint-storage/main.tf new file mode 100644 index 000000000..920323312 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/main.tf @@ -0,0 +1,211 @@ +# Create a random name for the resource group using random_pet +resource "random_pet" "rg_name" { + prefix = var.resource_group_name_prefix +} + +# Create the resource group using the generated random name +resource "azurerm_resource_group" "rg" { + location = var.resource_group_location + name = random_pet.rg_name.id +} + +# Create the virtual network +resource "azurerm_virtual_network" "vnet" { + name = "vnet-1" + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name +} + +# Create the public IP address for the NAT gateway +resource "azurerm_public_ip" "nat" { + name = "public-ip-nat" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Static" + sku = "Standard" +} + +# Create the NAT gateway that provides outbound internet connectivity for the +# virtual machine. The virtual machine has no public IP address of its own. +resource "azurerm_nat_gateway" "nat" { + name = "nat-gateway" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + sku_name = "Standard" + idle_timeout_in_minutes = 4 +} + +# Associate the public IP address with the NAT gateway +resource "azurerm_nat_gateway_public_ip_association" "nat" { + nat_gateway_id = azurerm_nat_gateway.nat.id + public_ip_address_id = azurerm_public_ip.nat.id +} + +# Create the public IP prefix that backs the network identifier. The prefix +# range is what the network security perimeter inbound access rule authorizes. +resource "azurerm_public_ip_prefix" "network_identifier" { + name = "public-ip-prefix" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + prefix_length = 31 + sku = "Standard" + ip_version = "IPv4" +} + +# Create the public IP address that is used as the network identifier for the +# standard service endpoint. It must be Standard SKU, Static, and IPv4, and it +# must exist before it is associated with the subnet service endpoint. +resource "azurerm_public_ip" "network_identifier" { + name = "public-ip-1" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Static" + sku = "Standard" + ip_version = "IPv4" + public_ip_prefix_id = azurerm_public_ip_prefix.network_identifier.id +} + +# Create the subnet with a standard service endpoint. The azapi provider is used +# because the service endpoint networkIdentifier property is not yet exposed by +# the azurerm provider. Associating the network identifier with the service +# endpoint is the key configuration step for a standard service endpoint. +resource "azapi_resource" "subnet" { + type = "Microsoft.Network/virtualNetworks/subnets@2025-07-01" + name = "subnet-1" + parent_id = azurerm_virtual_network.vnet.id + + body = { + properties = { + addressPrefix = "10.0.0.0/24" + + natGateway = { + id = azurerm_nat_gateway.nat.id + } + + serviceEndpoints = [ + { + service = var.service_endpoint_service + + networkIdentifier = { + id = azurerm_public_ip.network_identifier.id + } + } + ] + } + } + + response_export_values = ["properties.serviceEndpoints"] + + depends_on = [azurerm_nat_gateway_public_ip_association.nat] +} + +# Create the network interface for the virtual machine. The virtual machine uses +# a private IP address only and reaches Azure Storage through the service endpoint. +resource "azurerm_network_interface" "nic" { + name = "nic-1" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + + ip_configuration { + name = "ipconfig-1" + subnet_id = azapi_resource.subnet.id + private_ip_address_allocation = "Dynamic" + } +} + +# Create the Linux virtual machine that represents the IaaS workload +resource "azurerm_linux_virtual_machine" "vm" { + name = "vm-1" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + network_interface_ids = [azurerm_network_interface.nic.id] + size = var.vm_size + + os_disk { + name = "osdisk-1" + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } + + computer_name = "vm-1" + admin_username = var.username + + admin_ssh_key { + username = var.username + public_key = azapi_resource_action.ssh_public_key_gen.output.publicKey + } + + boot_diagnostics {} +} + +# Generate a unique suffix for the globally unique storage account name +resource "random_string" "name" { + length = 8 + special = false + upper = false + lower = true + numeric = true +} + +# Create the Azure Storage account that acts as the PaaS resource behind the +# standard service endpoint. Access is governed by the network security +# perimeter rather than by subnet-scoped storage network rules. +resource "azurerm_storage_account" "sa" { + name = "sa${random_string.name.result}" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + account_tier = "Standard" + account_replication_type = "LRS" + account_kind = "StorageV2" + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false +} + +# Create a file share used to validate connectivity through the service endpoint +resource "azurerm_storage_share" "share" { + name = "fileshare-1" + storage_account_id = azurerm_storage_account.sa.id + quota = 50 +} + +# Create the network security perimeter that secures the PaaS resource +resource "azurerm_network_security_perimeter" "nsp" { + name = "nsp-1" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location +} + +# Create a profile in the network security perimeter. Access rules are defined +# on the profile and applied to every resource associated with it. +resource "azurerm_network_security_perimeter_profile" "profile" { + name = "profile-1" + network_security_perimeter_id = azurerm_network_security_perimeter.nsp.id +} + +# Associate the storage account with the network security perimeter profile. +# Learning mode logs traffic without blocking it, which is the recommended +# starting point when you evaluate access rules. +resource "azurerm_network_security_perimeter_association" "storage" { + name = "assoc-storage" + network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.profile.id + resource_id = azurerm_storage_account.sa.id + access_mode = var.nsp_access_mode +} + +# Add the IP-based inbound access rule that authorizes traffic from the network +# identifier. The rule matches the public IP prefix that the network identifier +# was allocated from. +resource "azurerm_network_security_perimeter_access_rule" "inbound" { + name = "allow-se-standard" + network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.profile.id + direction = "Inbound" + address_prefixes = [azurerm_public_ip_prefix.network_identifier.ip_prefix] +} diff --git a/quickstart/101-standard-service-endpoint-storage/outputs.tf b/quickstart/101-standard-service-endpoint-storage/outputs.tf new file mode 100644 index 000000000..dc65173e3 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/outputs.tf @@ -0,0 +1,45 @@ +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "virtual_network_name" { + value = azurerm_virtual_network.vnet.name +} + +output "subnet_name" { + value = azapi_resource.subnet.name +} + +output "subnet_service_endpoints" { + description = "The service endpoints on the subnet, including the associated network identifier." + value = azapi_resource.subnet.output.properties.serviceEndpoints +} + +output "network_identifier_public_ip_name" { + value = azurerm_public_ip.network_identifier.name +} + +output "network_identifier_public_ip_address" { + value = azurerm_public_ip.network_identifier.ip_address +} + +output "network_identifier_prefix" { + description = "The public IP prefix range authorized by the network security perimeter inbound access rule." + value = azurerm_public_ip_prefix.network_identifier.ip_prefix +} + +output "storage_account_name" { + value = azurerm_storage_account.sa.name +} + +output "storage_share_name" { + value = azurerm_storage_share.share.name +} + +output "network_security_perimeter_name" { + value = azurerm_network_security_perimeter.nsp.name +} + +output "virtual_machine_name" { + value = azurerm_linux_virtual_machine.vm.name +} diff --git a/quickstart/101-service-endpoint-storage/providers.tf b/quickstart/101-standard-service-endpoint-storage/providers.tf similarity index 93% rename from quickstart/101-service-endpoint-storage/providers.tf rename to quickstart/101-standard-service-endpoint-storage/providers.tf index 8d74faa7e..50612e571 100644 --- a/quickstart/101-service-endpoint-storage/providers.tf +++ b/quickstart/101-standard-service-endpoint-storage/providers.tf @@ -4,7 +4,7 @@ terraform { required_providers { azapi = { source = "azure/azapi" - version = "~>1.5" + version = "~>2.0" } azurerm = { source = "hashicorp/azurerm" diff --git a/quickstart/101-standard-service-endpoint-storage/readme.md b/quickstart/101-standard-service-endpoint-storage/readme.md new file mode 100644 index 000000000..3c4137602 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/readme.md @@ -0,0 +1,51 @@ +# Azure standard service endpoint for Azure Storage + +This template deploys a [standard service endpoint](https://learn.microsoft.com/azure/private-link/service-endpoint-standard-overview) for Azure Storage. + +A standard service endpoint connects IaaS workloads to PaaS resources by using a **network identifier** (a public IP address) and a **network security perimeter**. The network identifier marks service endpoint traffic leaving the subnet, and an IP-based inbound access rule on the perimeter authorizes it. This addresses the scale limits of basic service endpoints, because one public IP address can represent many virtual networks and subnets in the same region and subscription. + +## Resources deployed + +| Resource | Purpose | +|---|---| +| Virtual network and subnet | Hosts the IaaS workload | +| Public IP prefix and public IP address | The network identifier for the service endpoint | +| Subnet service endpoint (`azapi`) | Associates the network identifier with `Microsoft.Storage` | +| NAT gateway and public IP address | Outbound internet connectivity for the virtual machine | +| Linux virtual machine and network interface | The IaaS workload; private IP address only | +| Storage account and file share | The PaaS resource behind the service endpoint | +| Network security perimeter, profile, and association | Secures the storage account | +| Network security perimeter inbound access rule | Authorizes traffic from the network identifier prefix | + +The subnet is created with the `azapi` provider because the service endpoint `networkIdentifier` property is not yet exposed by the `azurerm` provider. + +## Prerequisites + +Standard service endpoint is in public preview and is gated behind a feature flag. Register the feature in your subscription before you deploy: + +```azurecli +az feature register \ + --namespace Microsoft.Network \ + --name AllowServiceEndpointNetworkIdentifier + +az feature show \ + --namespace Microsoft.Network \ + --name AllowServiceEndpointNetworkIdentifier \ + --query "properties.state" \ + --output tsv +``` + +Wait for the state to show `Registered`, then refresh the resource provider registration: + +```azurecli +az provider register --namespace Microsoft.Network +``` + +You also need the `Microsoft.Network/publicIPAddresses/joinServiceEndpointNetworkIdentifier/action` permission, which is included in the **Network Contributor** role. + +## Notes + +- The network security perimeter association defaults to `Learning` mode, which logs traffic without blocking it. Set `nsp_access_mode` to `Enforced` to apply the access rules. +- The public IP address used as the network identifier must be Standard SKU, Static, and IPv4, and it must exist before it is associated with the service endpoint. +- The default location is `eastus2`. +- The SSH key for the virtual machine is generated at deploy time. Only the public key is exposed in the outputs. diff --git a/quickstart/101-service-endpoint-storage/ssh.tf b/quickstart/101-standard-service-endpoint-storage/ssh.tf similarity index 100% rename from quickstart/101-service-endpoint-storage/ssh.tf rename to quickstart/101-standard-service-endpoint-storage/ssh.tf diff --git a/quickstart/101-standard-service-endpoint-storage/variables.tf b/quickstart/101-standard-service-endpoint-storage/variables.tf new file mode 100644 index 000000000..9ae26fd62 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/variables.tf @@ -0,0 +1,40 @@ +variable "resource_group_location" { + type = string + default = "eastus2" + description = "Location of the resource group." +} + +variable "resource_group_name_prefix" { + type = string + default = "rg" + description = "Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription." +} + +variable "username" { + type = string + default = "azureadmin" + description = "The username for the local account that will be created on the new virtual machine." +} + +variable "vm_size" { + type = string + default = "Standard_DS1_v2" + description = "The size of the virtual machine." +} + +variable "service_endpoint_service" { + type = string + default = "Microsoft.Storage" + description = "The service that the standard service endpoint is configured for." +} + +variable "nsp_access_mode" { + type = string + default = "Learning" + description = "The access mode of the network security perimeter association. Learning logs traffic without blocking it; Enforced applies the access rules." + + validation { + condition = contains(["Learning", "Enforced", "Audit"], var.nsp_access_mode) + error_message = "The nsp_access_mode value must be Learning, Enforced, or Audit." + } +}