Subsections of Usage Guide

Concepts

Note

This page is a work in progress and will be updated as we improve & finalize the content. Please check back regularly for updates.

When developing an Azure solution using AVM modules, there are several aspects to consider. This page covers important concepts and provides guidance the technical decisions. Each concept/topic referenced here will be further detailed in the corresponding Bicep or Terraform specific guidance.

Language-agnostic concepts

Topics/concepts that are relevant and applicable for both Bicep and Terraform.

Module Sourcing

Public Registry

Leveraging the public registries (i.e., the Bicep Public Registry or the Terraform Public Registry) is the most common and recommended approach.

This allows you to leverage the latest and greatest features of the AVM modules, as well as the latest security updates. While there aren’t any prerequisites for using the public registry - no extra software component or service needs to be installed and no configuration is needed - the client machine the deployment is initiated from will need to have access to the public registry.

Private Registry (synced)

A private registry - that is hosted in your own environment - can store modules originating from the public registry. Using a private registry still grants you the latest version of AVM modules while allowing you to review each version of each module before admitting them to your private registry. You also have control over who can access your own private registry. Note that using a private registry means that you’re still using each module as is, without making any changes.

Inner-sourcing

Inner-sourcing AVM means maintaining your own, synchronized copy of AVM modules in your own internal private registry, repositories or other storage option. Customers normally look to inner-source AVM modules when they have strict security and compliance requirements, or when they want to publish their own lightly wrapped versions of the modules to meet their specific needs; for example changing some allowed or default values for parameter or variable inputs.

This is a more complex approach and requires more effort to maintain, but it can be beneficial in certain scenarios, however, it should not be the default approach as it can lead to a lot of overhead and maintenance and requires significant skills and resources to set up and maintain.

There are many ways to approach inner-sourcing AVM modules for both Bicep and Terraform. The AVM team will be publishing guidance on this topic, based on customer experience and learnings.

Tip

You can see the AVM team talking about inner-sourcing on the AVM February 2025 community call on YouTube.

Solution Development

This section provides advanced guidance for developing solutions using Azure Verified Modules (AVM). It covers technical decisions and concepts that are important for building and deploying Azure solutions using AVM modules.

Planning your solution

When implementing infrastructure in Azure leveraging IaaS and PaaS services, there are multiple options for Azure deployments. In this article we assume that a decision has been made to implement your solution, using Infrastructure-as-Code (IaC). This is best suited to allow programmatic declarative control of the target infrastructure and is ideal for projects that require repeatability and idempotency.

Choosing an Infrastructure-as-Code language

There are multiple language choices when implementing your solution using IaC in Azure. The Azure Verified Modules project currently supports Bicep and Terraform. The following guidance summarizes considerations that can help choose the option that best suits your requirements.

Reasons to choose Bicep

Bicep is the Microsoft 1st party offering for IaC deployments. It supports Generally Available (GA) and preview features for all Azure resources and allows for modular composition of resources and solution templates. The use of simplified syntax makes IaC development intuitive and the use of the Bicep extension for VSCode provides IntelliSense and syntax validation to assist with coding. Finally, Bicep is well suited for infrastructure projects and teams that don’t require management of other cloud platforms or services outside of Azure. For a more detailed read on reasons to choose Bicep, read this article from the Bicep documentation.

Reasons to choose Terraform

HashiCorp’s Terraform is an extensible 3rd party platform that can be used across multiple cloud and on-premises platforms using multiple provider plugins. It has widespread adoption due to its simplified human-readable configuration files, common functionality, and the ability to allow a project to span multiple provider spaces.

Terraform can target Azure through multiple providers. New AVM Terraform module authoring requires AzAPI: every resource, pattern, and utility module MUST use AzAPI for every control-plane resource and supported data-plane operation, including supporting resources in examples and tests. AzureRM is permitted only for a documented data-plane/non-ARM operation that AzAPI cannot implement under TFFR3. AzAPI talks directly to the Azure Resource Manager REST API, providing day-zero access to new resource types, properties and API versions, built-in retry and pre-flight validation, and conceptual alignment with Bicep modules.

Projects typically choose Terraform when they bridge multiple cloud infrastructure platforms or when the development team has previous experience coding in Terraform. Modern Integrated Development Environments (IDE) - such as Visual Studio Code - include extension support for Terraform features as well as additional Azure specific extensions. These extensions enable syntax validation and highlighting as well as code formatting and HashiCorp Cloud Platform (HCP) integration for HashiCorp Cloud customers. For a more detailed read on reasons to choose Terraform, read this article from the Terraform on Azure documentation.

Architecture design

Before starting the process of codifying infrastructure, it is important to develop a detailed architecture of what will be created. This should include details for:

  1. Organizational elements such as management groups, subscriptions, and resource groups as well as any tagging and Role Based Access (RBAC) configurations for each.
  2. Infrastructure services that will be created along with key configuration details like sku values, network CIDR range sizes, or other solution specific configuration.
  3. Any relationship between services that will be codified as part of the deployment.
  4. Identify inputs to your solution for designs that are intended to be used as templates.
Note

For a production grade solution, you need to

  • follow the recommendations of the Cloud Adoption Framework (CAF) and have your platform and application landing zones defined, as per Azure Landing Zones (ALZ);
  • follow the recommendations of the Azure Well-Architected Framework (WAF) to ensure that your solution is compliant with and integrates into your organization’s policies and standards. This includes considerations for security, identity, networking, monitoring, cost management, and governance.

Sourcing content for deployment

Once the architecture is agreed upon, it is time to plan the development of your IaC code. There are several key decision points that should be considered during this phase.

Content creation methods

The two primary methods used to create your solutions module are:

  1. Using base resources (“vanilla resources”) from scratch or
  2. Leveraging pre-created modules from the AVM library to minimize the time to value during development.

The trade-off between the two options is primarily around control vs. speed. AVM works to provide the best of both options by providing modules with opinionated and recommended practice defaults while allowing for more detailed configuration as needed. In our sample exercise we’ll be using AVM modules to demonstrate building the example solution.

AVM module type considerations

When using AVM modules for your solution, there is an additional choice that should be considered. The AVM library includes both pattern and resource module types. If your architecture includes or follows a well-known pattern then a pattern module may be the right option for you. If you determine this is the case, then search the module index for pattern modules in your chosen language to see if an option exists for your scenario. Otherwise, using resource modules from the library will be your best option.

In cases where an AVM resource or pattern module isn’t available for use, review the Bicep or Terraform provider documentation to identify how to augment AVM modules with standalone resources. If you feel that additional resource or pattern modules would be useful, you can also request the creation of a pattern or resource module by creating a module proposal issue on the AVM github repository.

Module source considerations

Once the decision has been made to use AVM modules to help accelerate solution development, a decision about where those modules will be sourced from is the next key decision point. A detailed exploration of the different sourcing options can be found in the Module Sourcing section of the Concepts page. Take a moment to review the options discussed there.

For our solution we will leverage the Public Registry option by sourcing AVM modules directly from the respective Terraform and Bicep public registries. This will avoid the need to fork copies of the modules for private use.

Subsections of Solution Development

Bicep - Solution Development

Introduction

Azure Verified Modules (AVM) for Bicep are a powerful tool that leverage the Bicep domain-specific language (DSL), industry knowledge, and an Open Source community, which altogether enable developers to quickly deploy Azure resources that follow Microsoft’s recommended practices for Azure.
In this article, we will walk through the Bicep specific considerations and recommended practices on developing your solution leveraging Azure Verified Modules. We’ll review some of the design features and trade-offs and include sample code to illustrate each discussion point.

In this tutorial, we will:

  • Deploy a basic Virtual Machine architecture into Azure
  • Explore recommended practices related to Bicep template development
  • Demonstrate the ease with which you can deploy AVM modules
  • Describe each of the development and deployment steps in detail

After completing this tutorial, you’ll have a working knowledge of:

  • How to discover and add AVM modules to your Bicep template
  • How to reference and use outputs across AVM modules
  • Recommended practices for parameterization and structure of your Bicep file
  • Configuration of AVM modules to meet Microsoft’s Well Architected Framework (WAF) principles
  • How to deploy your Bicep template into an Azure subscription from your local machine

Let’s get started!

Prerequisites

You will need the following tools and components to complete this guide:

Before you begin, make sure you have these tools installed in your development environment.

Solution Architecture

Before we begin coding, it is important to have details about what the infrastructure architecture will include. For our example, we will be building a solution that will host a simple application on a Linux virtual machine (VM). The solution must be secure and auditable. The VM must not be accessible from the internet and its logs should be easily accessible. All Azure services should utilize logging tools for auditing purposes.

Azure VM Solution Architecture

Develop the Solution Code

Creating the main.bicep file

The architecture diagram shows all components needed for a successful solution deployment. Rather than building the complete solution at once, this tutorial takes an incremental approach building the Bicep file piece-by-piece and testing the deployment at each stage. This approach allows for discussion of each design decision along the way.

The development will start with core platform components: first the backend logging services (Log Analytics) and then the virtual network.

Let’s begin by creating our folder structure along with a main.bicep file. Your folder structure should be as follows:

VirtualMachineAVM_Example1/
└── main.bicep

After you have your folder structure and main.bicep file, we can proceed with our first AVM resources!

Log Analytics

Let’s start by adding a logging service to our main.bicep since all other deployed resources will use this service for their logs.

Tip

Always begin template development by adding resources that create dependencies for other downstream services. This approach simplifies referencing these dependencies within your other modules as you develop them. For example, starting with Logging and Virtual Network services makes sense since all other services will depend on these.

The logging solution depicted in our Architecture Diagram shows we will be using a Log Analytics workspace. Let’s add that to our template! Open your main.bicep file and add the following:

➕ Expand Code
1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
2  name: 'logAnalyticsWorkspace'
3  params: {
4    // Required parameters
5    name: 'VM-AVM-Ex1-law'
6    // Non-required parameters
7    location: 'westus2'
8  }
9}
Note

Always click on the “Copy to clipboard” button in the top right corner of the Code sample area in order not to have the line numbers included in the copied code.

You now have a fully functional Bicep template that will deploy a working Log Analytics workspace! If you would like to try it, run the following in your console:

Note

For keeping the example below simple, we are using the traditional deployment commands, e.g., az deployment group create or New-AzResourceGroupDeployment. However, we encourage you to look into using Deployment Stacks instead by simply replacing the previous commands with az stack group create or New-AzResourceGroupDeploymentStack as well as the other required input parameters as shown here.

Deployment Stacks allow you to deploy a Bicep file as a stack, which is a collection of resources that are deployed together. This allows you to manage the lifecycle of the stack as a single unit, making it easier to deploy, update, and now even delete resources via Bicep. You can also implement RBAC Deny Assignments on your stacks deployed resources to prevent changes to the resources or specific actions on the resources to all but an excluded list of users, groups or other principals.

Deploy with
# Log in to Azure
Connect-AzAccount

# Select your subscription
Set-AzContext -SubscriptionId '<subscriptionId>'

# Deploy a resource group
New-AzResourceGroup -Name 'avm-bicep-vmexample1' -Location '<location>'

# Invoke your deployment
New-AzResourceGroupDeployment -DeploymentName 'avm-bicep-vmexample1-deployment' -ResourceGroupName 'avm-bicep-vmexample1' -TemplateFile '/<path-to>/VirtualMachineAVM_Example1/main.bicep'
# Log in to Azure
az login

# Select your subscription
az account set --subscription '<subscriptionId>'

# Deploy a resource group
az group create --name 'avm-bicep-vmexample1' --location '<location>'

# Invoke your deployment
az deployment group create --name 'avm-bicep-vmexample1-deployment' --resource-group 'avm-bicep-vmexample1' --template-file '/<path-to>/VirtualMachineAVM_Example1/main.bicep'

The above commands will log you in to your Azure subscription, select a subscription to use, create a resource group, then deploy the main.bicep template to your resource group.

AVM Makes the deployment of Azure resources incredibly easy. Many of the parameters you would normally be required to define are taken care of by the AVM module itself. In fact, the location parameter is not even needed in your template—when left blank, by default, all AVM modules will deploy to the location in which your target Resource Group exists.

Now we have a Log Analytics workspace in our resource group which doesn’t do a whole lot of good on its own. Let’s take our template a step further by adding a Virtual Network that integrates with the Log Analytics workspace.

Virtual Network

We will now add a Virtual Network to our main.bicep file. This VNet will contain subnets and Network Security Groups (NSGs) for any of the resources we deploy that require IP addresses.

In your main.bicep file, add the following:

➕ Expand Code
 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 2  name: 'logAnalyticsWorkspace'
 3  params: {
 4    // Required parameters
 5    name: 'VM-AVM-Ex1-law'
 6    // Non-required parameters
 7    location: 'westus2'
 8  }
 9}
10
11module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
12  name: 'virtualNetworkDeployment'
13  params: {
14    // Required parameters
15    addressPrefixes: [
16      '10.0.0.0/16'
17    ]
18    name: 'VM-AVM-Ex1-vnet'
19    // Non-required parameters
20    location: 'westus2'
21  }
22}

Again, the Virtual Network AVM module requires only two things: a name and an addressPrefixes parameter.

Configure Diagnostics Settings

There is an additional parameter available in most AVM modules named diagnosticSettings. This parameter allows you to configure your resource to send its logs to any suitable logging service. In our case, we are using a Log Analytics workspace.

Let’s update our main.bicep file to have our VNet send all of its logging data to our Log Analytics workspace:

➕ Expand Code
 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 2  name: 'logAnalyticsWorkspace'
 3  params: {
 4    // Required parameters
 5    name: 'VM-AVM-Ex1-law'
 6    // Non-required parameters
 7    location: 'westus2'
 8  }
 9}
10
11module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
12  name: 'virtualNetworkDeployment'
13  params: {
14    // Required parameters
15    addressPrefixes: [
16      '10.0.0.0/16'
17    ]
18    name: 'VM-AVM-Ex1-vnet'
19    // Non-required parameters
20    location: 'westus2'
21    diagnosticSettings: [
22      {
23        name: 'vNetDiagnostics'
24        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
25      }
26    ]
27  }
28}

Notice how the diagnosticsSettings parameter needs a workspaceResourceId? All you need to do is add a reference to the built-in logAnalyticsWorkspaceId output of the logAnalyticsWorkspace AVM module. That’s it! Our VNet now has integrated its logging with our Log Analytics workspace. All AVM modules come with a set of built-in outputs that can be easily referenced by other modules within your template.

Info

All AVM modules have built-in outputs which can be referenced using the <moduleName>.outputs.<outputName> syntax.

When using plain Bicep, many of these outputs require multiple lines of code or knowledge of the correct object ID references to get at the desired output. AVM modules do much of this heavy lifting for you by taking care of these complex tasks within the module itself, then exposing them to you through the module’s outputs. Find out more about Bicep Outputs.

Add a Subnet and NAT Gateway

We can’t use a Virtual Network without subnets, so let’s add a subnet next. According to our Architecture, we will have three subnets: one for the Virtual Machine, one for the Bastion host, and one for Private Endpoints. We can start with the VM subnet for now. While we’re at it, let’s also add the NAT Gateway, the NAT Gateway’s Public IP, the attach the NAT Gateway to the VM subnet.

Add the following to your main.bicep:

➕ Expand Code
 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 2  name: 'logAnalyticsWorkspace'
 3  params: {
 4    // Required parameters
 5    name: 'VM-AVM-Ex1-law'
 6    // Non-required parameters
 7    location: 'westus2'
 8  }
 9}
10
11module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
12  name: 'natGwPublicIpDeployment'
13  params: {
14    // Required parameters
15    name: 'VM-AVM-Ex1-natgwpip'
16    // Non-required parameters
17    location: 'westus2'
18    diagnosticSettings: [
19      {
20        name: 'natGwPublicIpDiagnostics'
21        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
22      }
23    ]
24  }
25}
26
27module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
28  name: 'natGatewayDeployment'
29  params: {
30    // Required parameters
31    name: 'VM-AVM-Ex1-natGw'
32    zone: 1
33    // Non-required parameters
34    publicIpResourceIds: [
35      natGwPublicIp.outputs.resourceId
36    ]
37  }
38}
39
40module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
41  name: 'virtualNetworkDeployment'
42  params: {
43    // Required parameters
44    addressPrefixes: [
45      '10.0.0.0/16'
46    ]
47    name: 'VM-AVM-Ex1-vnet'
48    // Non-required parameters
49    location: 'westus2'
50    diagnosticSettings: [
51      {
52        name: 'vNetDiagnostics'
53        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
54      }
55    ]
56    subnets: [
57      {
58        name: 'VMSubnet'
59        addressPrefix: cidrSubnet('10.0.0.0/16', 24, 0) // first subnet in address space
60        natGatewayResourceId: natGateway.outputs.resourceId
61      }
62    ]
63  }
64}

The modification adds a subnets property to our virtualNetwork module. The AVM network/virtual-network module supports the creation of subnets directly within the module itself. We can also link our NAT Gateway directly to the subnet within this submodule.

A nice feature within Bicep are the various functions available. We use the cidrSubnet() function to declare CIDR blocks without having to calculate them on your own.

Switch to Parameters and Variables

See how we are reusing the same CIDR block 10.0.0.0/16 in multiple locations? You may have noticed we are defining the same location in two different spots as well. We’re now at a point in the development where we should leverage one of our first recommended practices: using parameters and variables!

Tip

Use Bicep variables to define values that will be constant and reused with your template; use parameters anywhere you may need a modifiable value.

Let’s enhance the template by adding variables for the CIDR block and prefix, then use a location parameter with a default value. We’ll then reference those in the module:

➕ Expand Code
 1param location string = 'westus2'
 2
 3var addressPrefix = '10.0.0.0/16'
 4var prefix = 'VM-AVM-Ex1'
 5
 6module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 7  name: 'logAnalyticsWorkspace'
 8  params: {
 9    // Required parameters
10    name: '${prefix}-law'
11    // Non-required parameters
12    location: location
13  }
14}
15
16module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
17  name: 'natGwPublicIpDeployment'
18  params: {
19    // Required parameters
20    name: '${prefix}-natgwpip'
21    // Non-required parameters
22    location: location
23    diagnosticSettings: [
24      {
25        name: 'natGwPublicIpDiagnostics'
26        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
27      }
28    ]
29  }
30}
31
32module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
33  name: 'natGatewayDeployment'
34  params: {
35    // Required parameters
36    name: '${prefix}-natgw'
37    zone: 1
38    // Non-required parameters
39    publicIpResourceIds: [
40      natGwPublicIp.outputs.resourceId
41    ]
42  }
43}
44
45module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
46  name: 'virtualNetworkDeployment'
47  params: {
48    // Required parameters
49    addressPrefixes: [
50      addressPrefix
51    ]
52    name: '${prefix}-vnet'
53    // Non-required parameters
54    location: location
55    diagnosticSettings: [
56      {
57        name: 'vNetDiagnostics'
58        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
59      }
60    ]
61    subnets: [
62      {
63        name: 'VMSubnet'
64        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
65        natGatewayResourceId: natGateway.outputs.resourceId
66      }
67    ]
68  }
69}

We now have a good basis for the infrastructure to be utilized by the rest of the resources in our Architecture. We will come back to our networking in a future step, once we are ready to create some Network Security Groups. For now, let’s move on to other modules.

Key Vault

Key Vaults are one of the key components in most Azure architectures as they create a place where you can save and reference secrets in a secure manner (“secrets” in the general sense, as opposed to the secret object type in Key Vaults). The Key Vault AVM module makes it very simple to store secrets generated in your template. In this tutorial, we will use one of the most secure methods of storing and retrieving secrets by leveraging this Key Vault in our Bicep template.

The first step is easy: add the Key Vault AVM module to our main.bicep file. In addition, let’s also ensure it’s hooked into our Log Analytics workspace (we will do this for every new module from here on out).

➕ Expand Code
 1param location string = 'westus2'
 2
 3var addressPrefix = '10.0.0.0/16'
 4var prefix = 'VM-AVM-Ex1'
 5
 6module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 7  name: 'logAnalyticsWorkspace'
 8  params: {
 9    // Required parameters
10    name: '${prefix}-law'
11    // Non-required parameters
12    location: location
13  }
14}
15
16module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
17  name: 'natGwPublicIpDeployment'
18  params: {
19    // Required parameters
20    name: '${prefix}-natgwpip'
21    // Non-required parameters
22    location: location
23    diagnosticSettings: [
24      {
25        name: 'natGwPublicIpDiagnostics'
26        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
27      }
28    ]
29  }
30}
31
32module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
33  name: 'natGatewayDeployment'
34  params: {
35    // Required parameters
36    name: '${prefix}-natgw'
37    zone: 1
38    // Non-required parameters
39    publicIpResourceIds: [
40      natGwPublicIp.outputs.resourceId
41    ]
42  }
43}
44
45module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
46  name: 'virtualNetworkDeployment'
47  params: {
48    // Required parameters
49    addressPrefixes: [
50      addressPrefix
51    ]
52    name: '${prefix}-vnet'
53    // Non-required parameters
54    location: location
55    diagnosticSettings: [
56      {
57        name: 'vNetDiagnostics'
58        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
59      }
60    ]
61    subnets: [
62      {
63        name: 'VMSubnet'
64        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
65        natGatewayResourceId: natGateway.outputs.resourceId
66      }
67    ]
68  }
69}
70
71module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
72  name: 'keyVaultDeployment'
73  params: {
74    // Required parameters
75    name: '${uniqueString(resourceGroup().id)}-kv'
76    // Non-required parameters
77    location: location
78    diagnosticSettings: [
79      {
80        name: 'keyVaultDiagnostics'
81        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
82      }
83    ]
84  }
85}

The name of the Key Vault we will deploy uses the uniqueString() Bicep function. Key Vault names must be globally unique. We will therefore deviate from our standard naming convention thus far and make an exception for the Key Vault. Note how we are still adding a suffix to the Key Vault name, so its name remains recognizable; you can use a combination of concatenating unique strings, prefixes, or suffixes to follow your own naming standard preferences.

When we generate our unique string, we will pass in the resourceGroup().id as the seed for the uniqueString() function so that every time you deploy this main.bicep to the same resource group, it will use the same randomly generated name for your Key Vault (since resourceGroup().id will be the same).

Tip

Bicep has many built-in functions available. We used two here: uniqueString() and resourceGroup(). The resourceGroup(), subscription(), and deployment() functions are very useful when seeding uniqueString() or guid() functions. Just be cautious about name length limitations for each Azure service! Visit this page to learn more about Bicep functions.

We will use this Key Vault later on when we create a VM and need to store its password. Now that we have it, a Virtual Network, Subnet, and Log Analytics prepared, we should have everything we need to deploy a Virtual Machine!

Info

In the future, we will update this guide to show how to generate and store a certificate in the Key Vault, then use that certificate to authenticate into the Virtual Machine.

Virtual Machine

Warning

The AVM Virtual Machine module enables the EncryptionAtHost feature by default. You must enable this feature within your Azure subscription successfully deploy this example code. To do so, run the following:

Deploy with
# Wait a few minutes after running the command to allow it to propagate
Register-AzProviderFeature -FeatureName "EncryptionAtHost" -ProviderNamespace "Microsoft.Compute"
az feature register --namespace Microsoft.Compute --name EncryptionAtHost

# Propagate the change
az provider register --namespace Microsoft.Compute

For our Virtual Machine (VM) deployment, we need to add the following to our main.bicep file:

➕ Expand Code
  1param location string = 'westus2'
  2
  3// START add-password-param
  4@description('Required. A password for the VM admin user.')
  5@secure()
  6param vmAdminPass string
  7// END add-password-param
  8
  9var addressPrefix = '10.0.0.0/16'
 10var prefix = 'VM-AVM-Ex1'
 11
 12module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 13  name: 'logAnalyticsWorkspace'
 14  params: {
 15    // Required parameters
 16    name: '${prefix}-law'
 17    // Non-required parameters
 18    location: location
 19  }
 20}
 21
 22module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 23  name: 'natGwPublicIpDeployment'
 24  params: {
 25    // Required parameters
 26    name: '${prefix}-natgwpip'
 27    // Non-required parameters
 28    location: location
 29    diagnosticSettings: [
 30      {
 31        name: 'natGwPublicIpDiagnostics'
 32        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 33      }
 34    ]
 35  }
 36}
 37
 38module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 39  name: 'natGatewayDeployment'
 40  params: {
 41    // Required parameters
 42    name: '${prefix}-natgw'
 43    zone: 1
 44    // Non-required parameters
 45    publicIpResourceIds: [
 46      natGwPublicIp.outputs.resourceId
 47    ]
 48  }
 49}
 50
 51module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 52  name: 'virtualNetworkDeployment'
 53  params: {
 54    // Required parameters
 55    addressPrefixes: [
 56      addressPrefix
 57    ]
 58    name: '${prefix}-vnet'
 59    // Non-required parameters
 60    location: location
 61    diagnosticSettings: [
 62      {
 63        name: 'vNetDiagnostics'
 64        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 65      }
 66    ]
 67    subnets: [
 68      {
 69        name: 'VMSubnet'
 70        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 71        natGatewayResourceId: natGateway.outputs.resourceId
 72      }
 73    ]
 74  }
 75}
 76
 77module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
 78  name: 'keyVaultDeployment'
 79  params: {
 80    // Required parameters
 81    name: '${uniqueString(resourceGroup().id)}-kv'
 82    // Non-required parameters
 83    location: location
 84    diagnosticSettings: [
 85      {
 86        name: 'keyVaultDiagnostics'
 87        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 88      }
 89    ]
 90    // START add-keyvault-secret
 91    secrets: [
 92      {
 93        name: 'vmAdminPassword'
 94        value: vmAdminPass
 95      }
 96    ]
 97    // END add-keyvault-secret
 98  }
 99}
100
101module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.1' = {
102  name: 'linuxVirtualMachineDeployment'
103  params: {
104    // Required parameters
105    adminUsername: 'localAdminUser'
106    adminPassword: vmAdminPass
107    imageReference: {
108      offer: '0001-com-ubuntu-server-jammy'
109      publisher: 'Canonical'
110      sku: '22_04-lts-gen2'
111      version: 'latest'
112    }
113    name: '${prefix}-vm1'
114    // START vm-subnet-reference
115    nicConfigurations: [
116      {
117        ipConfigurations: [
118          {
119            name: 'ipconfig01'
120            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
121          }
122        ]
123        nicSuffix: '-nic-01'
124      }
125    ]
126    // END vm-subnet-reference
127    osDisk: {
128      caching: 'ReadWrite'
129      diskSizeGB: 128
130      managedDisk: {
131        storageAccountType: 'Standard_LRS'
132      }
133    }
134    osType: 'Linux'
135    vmSize: 'Standard_B2s_v2'
136    zone: 0
137    // Non-required parameters
138    location: location
139  }
140}

The VM module is one of the more complex modules in AVM—behind the scenes, it takes care of a lot of heavy lifting that, without AVM, would require multiple Bicep resources to be deployed and referenced.

For example, look at the nicConfigurations parameter: normally, you would need to deploy a separate NIC resource, which itself also requires an IP resource, then attach them to each other, and finally, attach them all to your VM.

With the AVM VM module, the nicConfigurations parameter accepts an object, allowing you to create any number of NICs to attach to your VM from within the VM resource deployment itself. It handles all the naming, creation of other necessary dependencies, and attaches them all together, so you don’t have to. The osDisk parameter is similar, though slightly less complex. There are many more parameters within the VM module that you can leverage if needed, that share a similar ease-of-use.

Since this is the real highlight of our main.bicep file, we need to take a closer look at some of the other changes that were made.

  • VM Admin Password Parameter

    1@description('Required. A password for the VM admin user.')
    2@secure()
    3param vmAdminPass string

    First, we added a new parameter. The value of this will be provided when the main.bicep template is deployed. We don’t want any passwords stored as text in code; for our purposes, the safest way to do this is to prompt the end user for the password at the time of deployment.

    Warning

    The supplied password must be between 6-72 characters long and must satisfy at least 3 of password complexity requirements from the following: Contains an uppercase character; Contains a lowercase character; Contains a numeric digit; Contains a special character. Control characters are not allowed

    Also note how we are using the @secure() decorator on the password parameter. This will ensure the value of the password is never displayed in any of the deployment logs or in Azure. We have also added the @description() decorator and started the description with “Required.” It’s a good habit and recommended practice to document your parameters in Bicep. This will ensure that VS Code’s built-in Bicep linter can provide end-users insightful information when deploying your Bicep templates.

    Info

    Always use the @secure() decorator when creating a parameter that will hold sensitive data!

  • Add the VM Admin Password to Key Vault

    1    secrets: [
    2      {
    3        name: 'vmAdminPassword'
    4        value: vmAdminPass
    5      }
    6    ]

    The next thing we have done is save the value of our vmAdminPass parameter to our Key Vault. We have done this by adding a secrets parameter to the Key Vault module. Adding secrets to Key Vaults is very simple when using the AVM module.

    By adding our password to the Key Vault, it will ensure that we never lose the password and that it is stored securely. As long as a user has appropriate permissions on the vault, the password can be fetched easily.

  • Reference the VM Subnet

     1    nicConfigurations: [
     2      {
     3        ipConfigurations: [
     4          {
     5            name: 'ipconfig01'
     6            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
     7          }
     8        ]
     9        nicSuffix: '-nic-01'
    10      }
    11    ]

    Here, we reference another built-in output, this time from the AVM Virtual Network module. This example shows how to use an output that is part of an array. When the Virtual Network module creates subnets, it automatically creates a set of pre-defined outputs for them, one of which is an array that contains each subnet’s subnetResourceId. Our VM Subnet was the first one created which is position [0] in the array.

    Other AVM modules may make use of arrays to store outputs. If you are unsure what type of outputs a module provides, you can always reference the Outputs section of each module’s README.md.

Storage Account

The last major component we need to add is a Storage Account. Because this Storage Account will be used as a backend storage to hold blobs for the hypothetical application that runs on our VM, we’ll also create a blob container within it using the same AVM Storage Account module.

➕ Expand Code
  1param location string = 'westus2'
  2
  3@description('Required. A password for the VM admin user.')
  4@secure()
  5param vmAdminPass string
  6
  7var addressPrefix = '10.0.0.0/16'
  8var prefix = 'VM-AVM-Ex1'
  9
 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 11  name: 'logAnalyticsWorkspace'
 12  params: {
 13    // Required parameters
 14    name: '${prefix}-law'
 15    // Non-required parameters
 16    location: location
 17  }
 18}
 19
 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 21  name: 'natGwPublicIpDeployment'
 22  params: {
 23    // Required parameters
 24    name: '${prefix}-natgwpip'
 25    // Non-required parameters
 26    location: location
 27    diagnosticSettings: [
 28      {
 29        name: 'natGwPublicIpDiagnostics'
 30        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 31      }
 32    ]
 33  }
 34}
 35
 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 37  name: 'natGatewayDeployment'
 38  params: {
 39    // Required parameters
 40    name: '${prefix}-natgw'
 41    zone: 1
 42    // Non-required parameters
 43    publicIpResourceIds: [
 44      natGwPublicIp.outputs.resourceId
 45    ]
 46  }
 47}
 48
 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 50  name: 'virtualNetworkDeployment'
 51  params: {
 52    // Required parameters
 53    addressPrefixes: [
 54      addressPrefix
 55    ]
 56    name: '${prefix}-vnet'
 57    // Non-required parameters
 58    location: location
 59    diagnosticSettings: [
 60      {
 61
 62        name: 'vNetDiagnostics'
 63        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 64      }
 65    ]
 66    subnets: [
 67      {
 68        name: 'VMSubnet'
 69        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 70        natGatewayResourceId: natGateway.outputs.resourceId
 71      }
 72    ]
 73  }
 74}
 75
 76module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
 77  name: 'keyVaultDeployment'
 78  params: {
 79    // Required parameters
 80    name: '${uniqueString(resourceGroup().id)}-kv'
 81    // Non-required parameters
 82    location: location
 83    diagnosticSettings: [
 84      {
 85        name: 'keyVaultDiagnostics'
 86        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 87      }
 88    ]
 89    enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it
 90    secrets: [
 91      {
 92        name: 'vmAdminPassword'
 93        value: vmAdminPass
 94      }
 95    ]
 96  }
 97}
 98
 99module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.1' = {
100  name: 'linuxVirtualMachineDeployment'
101  params: {
102    // Required parameters
103    adminUsername: 'localAdminUser'
104    adminPassword: vmAdminPass
105    imageReference: {
106      offer: '0001-com-ubuntu-server-jammy'
107      publisher: 'Canonical'
108      sku: '22_04-lts-gen2'
109      version: 'latest'
110    }
111    name: '${prefix}-vm1'
112    nicConfigurations: [
113      {
114        ipConfigurations: [
115          {
116            name: 'ipconfig01'
117            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
118          }
119        ]
120        nicSuffix: '-nic-01'
121      }
122    ]
123    osDisk: {
124      caching: 'ReadWrite'
125      diskSizeGB: 128
126      managedDisk: {
127        storageAccountType: 'Standard_LRS'
128      }
129    }
130
131    osType: 'Linux'
132    vmSize: 'Standard_B2s_v2'
133    zone: 0
134    // Non-required parameters
135    location: location
136  }
137}
138
139module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = {
140  name: 'storageAccountDeployment'
141  params: {
142    // Required parameters
143    name: '${uniqueString(resourceGroup().id)}sa'
144    // Non-required parameters
145    location: location
146    skuName: 'Standard_LRS'
147    diagnosticSettings: [
148      {
149        name: 'storageAccountDiagnostics'
150        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
151      }
152    ]
153    blobServices: {
154      containers: [
155        {
156          name: 'vmstorage'
157          publicAccess: 'None'
158        }
159      ]
160    }
161  }
162}

We now have all the major components of our Architecture diagram built!

The last steps we need to take to meet our requirements is to ensure our networking resources are secure and that we are using least privileged access by leveraging Role-Based Access Control (RBAC). Let’s get to it!

Network Security Groups

We’ll add a Network Security Group (NSG) to our VM subnet. This will act as a layer 3 and layer 4 firewall for networked resources. This implementation includes an appropriate inbound rule to allow SSH traffic from the Bastion host:

➕ Expand Code
  1param location string = 'westus2'
  2
  3@description('Required. A password for the VM admin user.')
  4@secure()
  5param vmAdminPass string
  6
  7var addressPrefix = '10.0.0.0/16'
  8var prefix = 'VM-AVM-Ex1'
  9
 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 11  name: 'logAnalyticsWorkspace'
 12  params: {
 13    // Required parameters
 14    name: '${prefix}-law'
 15    // Non-required parameters
 16    location: location
 17  }
 18}
 19
 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 21  name: 'natGwPublicIpDeployment'
 22  params: {
 23    // Required parameters
 24    name: '${prefix}-natgwpip'
 25    // Non-required parameters
 26    location: location
 27    diagnosticSettings: [
 28      {
 29        name: 'natGwPublicIpDiagnostics'
 30        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 31      }
 32    ]
 33  }
 34}
 35
 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 37  name: 'natGatewayDeployment'
 38  params: {
 39    // Required parameters
 40    name: '${prefix}-natgw'
 41    zone: 1
 42    // Non-required parameters
 43    publicIpResourceIds: [
 44      natGwPublicIp.outputs.resourceId
 45    ]
 46  }
 47}
 48
 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 50  name: 'virtualNetworkDeployment'
 51  params: {
 52    // Required parameters
 53    addressPrefixes: [
 54      addressPrefix
 55    ]
 56    name: '${prefix}-vnet'
 57    // Non-required parameters
 58    location: location
 59    diagnosticSettings: [
 60      {
 61        name: 'vNetDiagnostics'
 62        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 63      }
 64    ]
 65    subnets: [
 66      {
 67        name: 'VMSubnet'
 68        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 69        natGatewayResourceId: natGateway.outputs.resourceId
 70        networkSecurityGroupResourceId: nsgVM.outputs.resourceId
 71      }
 72    ]
 73  }
 74}
 75
 76module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = {
 77  name: 'nsgVmDeployment'
 78  params: {
 79    name: '${prefix}-NSG-VM'
 80    location: location
 81    securityRules: [
 82      {
 83        name: 'AllowBastionSSH'
 84        properties: {
 85          access: 'Allow'
 86          direction: 'Inbound'
 87          priority: 100
 88          protocol: 'Tcp'
 89          sourceAddressPrefix: 'virtualNetwork'
 90          sourcePortRange: '*'
 91          destinationAddressPrefix: '*'
 92          destinationPortRange: '22'
 93        }
 94      }
 95    ]
 96  }
 97}
 98
 99module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
100  name: 'keyVaultDeployment'
101  params: {
102    // Required parameters
103    name: '${uniqueString(resourceGroup().id)}-kv'
104    // Non-required parameters
105    location: location
106    diagnosticSettings: [
107      {
108        name: 'keyVaultDiagnostics'
109        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
110      }
111    ]
112    enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it
113    secrets: [
114      {
115        name: 'vmAdminPassword'
116        value: vmAdminPass
117      }
118    ]
119  }
120}
121
122module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.1' = {
123  name: 'linuxVirtualMachineDeployment'
124  params: {
125    // Required parameters
126    adminUsername: 'localAdminUser'
127    adminPassword: vmAdminPass
128    imageReference: {
129      offer: '0001-com-ubuntu-server-jammy'
130      publisher: 'Canonical'
131      sku: '22_04-lts-gen2'
132      version: 'latest'
133    }
134    name: '${prefix}-vm1'
135    nicConfigurations: [
136      {
137        ipConfigurations: [
138          {
139            name: 'ipconfig01'
140            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
141          }
142        ]
143        nicSuffix: '-nic-01'
144      }
145    ]
146    osDisk: {
147      caching: 'ReadWrite'
148      diskSizeGB: 128
149      managedDisk: {
150        storageAccountType: 'Standard_LRS'
151      }
152    }
153
154    osType: 'Linux'
155    vmSize: 'Standard_B2s_v2'
156    zone: 0
157    // Non-required parameters
158    location: location
159  }
160}
161
162module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = {
163  name: 'storageAccountDeployment'
164  params: {
165    // Required parameters
166    name: '${uniqueString(resourceGroup().id)}sa'
167    // Non-required parameters
168    location: location
169    skuName: 'Standard_LRS'
170    diagnosticSettings: [
171      {
172        name: 'storageAccountDiagnostics'
173        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
174      }
175    ]
176    blobServices: {
177      containers: [
178        {
179          name: 'vmstorage'
180          publicAccess: 'None'
181        }
182      ]
183    }
184  }
185}

Disable Public Access to Storage Account

Since the Storage Account serves as a backend resource exclusively for the Virtual Machine, it will be secured as much as possible. This involves adding a Private Endpoint and disabling public internet access. AVM makes creation and assignment of Private Endpoints to resources incredibly easy. Take a look:

➕ Expand Code
  1param location string = 'westus2'
  2
  3@description('Required. A password for the VM admin user.')
  4@secure()
  5param vmAdminPass string
  6
  7var addressPrefix = '10.0.0.0/16'
  8var prefix = 'VM-AVM-Ex1'
  9
 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 11  name: 'logAnalyticsWorkspace'
 12  params: {
 13    // Required parameters
 14    name: '${prefix}-law'
 15    // Non-required parameters
 16    location: location
 17  }
 18}
 19
 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 21  name: 'natGwPublicIpDeployment'
 22  params: {
 23    // Required parameters
 24    name: '${prefix}-natgwpip'
 25    // Non-required parameters
 26    location: location
 27    diagnosticSettings: [
 28      {
 29        name: 'natGwPublicIpDiagnostics'
 30        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 31      }
 32    ]
 33  }
 34}
 35
 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 37  name: 'natGatewayDeployment'
 38  params: {
 39    // Required parameters
 40    name: '${prefix}-natgw'
 41    zone: 1
 42    // Non-required parameters
 43    publicIpResourceIds: [
 44      natGwPublicIp.outputs.resourceId
 45    ]
 46  }
 47}
 48
 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 50  name: 'virtualNetworkDeployment'
 51  params: {
 52    // Required parameters
 53    addressPrefixes: [
 54      addressPrefix
 55    ]
 56    name: '${prefix}-vnet'
 57    // Non-required parameters
 58    location: location
 59    diagnosticSettings: [
 60      {
 61        name: 'vNetDiagnostics'
 62        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 63      }
 64    ]
 65    subnets: [
 66      {
 67        name: 'VMSubnet'
 68        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 69        natGatewayResourceId: natGateway.outputs.resourceId
 70        networkSecurityGroupResourceId: nsgVM.outputs.resourceId
 71      }
 72      {
 73        name: 'PrivateEndpointSubnet'
 74        addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space
 75      }
 76    ]
 77  }
 78}
 79
 80module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = {
 81  name: 'nsgVmDeployment'
 82  params: {
 83    name: '${prefix}-NSG-VM'
 84    location: location
 85    securityRules: [
 86      {
 87        name: 'AllowBastionSSH'
 88        properties: {
 89          access: 'Allow'
 90          direction: 'Inbound'
 91          priority: 100
 92          protocol: 'Tcp'
 93          sourceAddressPrefix: 'virtualNetwork'
 94          sourcePortRange: '*'
 95          destinationAddressPrefix: '*'
 96          destinationPortRange: '22'
 97        }
 98      }
 99    ]
100  }
101}
102
103module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
104  name: 'keyVaultDeployment'
105  params: {
106    // Required parameters
107    name: '${uniqueString(resourceGroup().id)}-kv'
108    // Non-required parameters
109    location: location
110    diagnosticSettings: [
111      {
112        name: 'keyVaultDiagnostics'
113        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
114      }
115    ]
116    enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it
117    secrets: [
118      {
119        name: 'vmAdminPassword'
120        value: vmAdminPass
121      }
122    ]
123  }
124}
125
126module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = {
127  name: 'linuxVirtualMachineDeployment'
128  params: {
129    // Required parameters
130    adminUsername: 'localAdminUser'
131    adminPassword: vmAdminPass
132    imageReference: {
133      offer: '0001-com-ubuntu-server-jammy'
134      publisher: 'Canonical'
135      sku: '22_04-lts-gen2'
136      version: 'latest'
137    }
138    name: '${prefix}-vm1'
139    nicConfigurations: [
140      {
141        ipConfigurations: [
142          {
143            name: 'ipconfig01'
144            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
145          }
146        ]
147        nicSuffix: '-nic-01'
148      }
149    ]
150    osDisk: {
151      caching: 'ReadWrite'
152      diskSizeGB: 128
153      managedDisk: {
154        storageAccountType: 'Standard_LRS'
155      }
156    }
157    osType: 'Linux'
158    vmSize: 'Standard_B2s_v2'
159    zone: 0
160    // Non-required parameters
161    location: location
162  }
163}
164
165module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = {
166  name: 'storageAccountDeployment'
167  params: {
168    // Required parameters
169    name: '${uniqueString(resourceGroup().id)}sa'
170    // Non-required parameters
171    location: location
172    skuName: 'Standard_LRS'
173    diagnosticSettings: [
174      {
175        name: 'storageAccountDiagnostics'
176        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
177      }
178    ]
179    publicNetworkAccess: 'Disabled'
180    allowBlobPublicAccess: false
181    blobServices: {
182      containers: [
183        {
184          name: 'vmstorage'
185          publicAccess: 'None'
186        }
187      ]
188    }
189    privateEndpoints: [
190      {
191        service: 'Blob'
192        subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet
193        privateDnsZoneGroup: {
194          privateDnsZoneGroupConfigs: [
195            {
196              privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId
197            }
198          ]
199        }
200      }
201    ]
202  }
203}
204
205module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = {
206  name: '${prefix}-privatedns-blob'
207  params: {
208    name: 'privatelink.blob.${environment().suffixes.storage}'
209    location: 'global'
210    virtualNetworkLinks: [
211      {
212        name: '${virtualNetwork.outputs.name}-vnetlink'
213        virtualNetworkResourceId: virtualNetwork.outputs.resourceId
214      }
215    ]
216  }
217}

This implementation adds a dedicated subnet for Private Endpoints following the recommended practice of isolating Private Endpoints in their own subnet.

The addition of just a few lines of code in the privateEndpoints parameter handles the complex tasks of creating the Private Endpoint, associating it with the VNet, and attaching it to the resource. AVM drastically simplifies the creation of Private Endpoints for just about every Azure Resource that supports them.

The implementation also disables all public network connectivity to the Storage Account, ensuring it only accepts traffic via the Private Endpoint.

Finally, a Private DNS zone is added and linked to the VNet, enabling the VM to resolve the Private IP address associated with the Storage Account.

Bastion

To securely access the Virtual Machine without exposing its SSH port to the public internet, we’ll create an Azure Bastion host. The Bastion Host requires a subnet with the exact name AzureBastionSubnet which cannot contain anything other than Bastion Hosts.

➕ Expand Code
  1param location string = 'westus2'
  2
  3@description('Required. A password for the VM admin user.')
  4@secure()
  5param vmAdminPass string
  6
  7var addressPrefix = '10.0.0.0/16'
  8var prefix = 'VM-AVM-Ex1'
  9
 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 11  name: 'logAnalyticsWorkspace'
 12  params: {
 13    // Required parameters
 14    name: '${prefix}-law'
 15    // Non-required parameters
 16    location: location
 17  }
 18}
 19
 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 21  name: 'natGwPublicIpDeployment'
 22  params: {
 23    // Required parameters
 24    name: '${prefix}-natgwpip'
 25    // Non-required parameters
 26    location: location
 27    diagnosticSettings: [
 28      {
 29        name: 'natGwPublicIpDiagnostics'
 30        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 31      }
 32    ]
 33  }
 34}
 35
 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 37  name: 'natGatewayDeployment'
 38  params: {
 39    // Required parameters
 40    name: '${prefix}-natgw'
 41    zone: 1
 42    // Non-required parameters
 43    publicIpResourceIds: [
 44      natGwPublicIp.outputs.resourceId
 45    ]
 46  }
 47}
 48
 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 50  name: 'virtualNetworkDeployment'
 51  params: {
 52    // Required parameters
 53    addressPrefixes: [
 54      addressPrefix
 55    ]
 56    name: '${prefix}-vnet'
 57    // Non-required parameters
 58    location: location
 59    diagnosticSettings: [
 60      {
 61        name: 'vNetDiagnostics'
 62        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 63      }
 64    ]
 65    subnets: [
 66      {
 67        name: 'VMSubnet'
 68        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 69        natGatewayResourceId: natGateway.outputs.resourceId
 70        networkSecurityGroupResourceId: nsgVM.outputs.resourceId
 71      }
 72      {
 73        name: 'PrivateEndpointSubnet'
 74        addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space
 75      }
 76      {
 77        name: 'AzureBastionSubnet' // Azure Bastion Host requires this subnet to be named exactly "AzureBastionSubnet"
 78        addressPrefix: cidrSubnet(addressPrefix, 24, 2) // third subnet in address space
 79      }
 80    ]
 81  }
 82}
 83
 84module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = {
 85  name: 'nsgVmDeployment'
 86  params: {
 87    name: '${prefix}-NSG-VM'
 88    location: location
 89    securityRules: [
 90      {
 91        name: 'AllowBastionSSH'
 92        properties: {
 93          access: 'Allow'
 94          direction: 'Inbound'
 95          priority: 100
 96          protocol: 'Tcp'
 97          sourceAddressPrefix: 'virtualNetwork'
 98          sourcePortRange: '*'
 99          destinationAddressPrefix: '*'
100          destinationPortRange: '22'
101        }
102      }
103    ]
104  }
105}
106
107module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
108  name: 'keyVaultDeployment'
109  params: {
110    // Required parameters
111    name: '${uniqueString(resourceGroup().id)}-kv'
112    // Non-required parameters
113    location: location
114    diagnosticSettings: [
115      {
116        name: 'keyVaultDiagnostics'
117        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
118      }
119    ]
120    enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it
121    secrets: [
122      {
123        name: 'vmAdminPassword'
124        value: vmAdminPass
125      }
126    ]
127  }
128}
129
130module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = {
131  name: 'linuxVirtualMachineDeployment'
132  params: {
133    // Required parameters
134    adminUsername: 'localAdminUser'
135    adminPassword: vmAdminPass
136    imageReference: {
137      offer: '0001-com-ubuntu-server-jammy'
138      publisher: 'Canonical'
139      sku: '22_04-lts-gen2'
140      version: 'latest'
141    }
142    name: '${prefix}-vm1'
143    nicConfigurations: [
144      {
145        ipConfigurations: [
146          {
147            name: 'ipconfig01'
148            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
149          }
150        ]
151        nicSuffix: '-nic-01'
152      }
153    ]
154    osDisk: {
155      caching: 'ReadWrite'
156      diskSizeGB: 128
157      managedDisk: {
158        storageAccountType: 'Standard_LRS'
159      }
160    }
161    osType: 'Linux'
162    vmSize: 'Standard_B2s_v2'
163    zone: 0
164    // Non-required parameters
165    location: location
166  }
167}
168
169module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = {
170  name: 'storageAccountDeployment'
171  params: {
172    // Required parameters
173    name: '${uniqueString(resourceGroup().id)}sa'
174    // Non-required parameters
175    location: location
176    skuName: 'Standard_LRS'
177    diagnosticSettings: [
178      {
179        name: 'storageAccountDiagnostics'
180        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
181      }
182    ]
183    publicNetworkAccess: 'Disabled'
184    allowBlobPublicAccess: false
185    blobServices: {
186      containers: [
187        {
188          name: 'vmstorage'
189          publicAccess: 'None'
190        }
191      ]
192    }
193    privateEndpoints: [
194      {
195        service: 'Blob'
196        subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet
197        privateDnsZoneGroup: {
198          privateDnsZoneGroupConfigs: [
199            {
200              privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId
201            }
202          ]
203        }
204      }
205    ]
206  }
207}
208
209module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = {
210  name: '${prefix}-privatedns-blob'
211  params: {
212    name: 'privatelink.blob.${environment().suffixes.storage}'
213    location: 'global'
214    virtualNetworkLinks: [
215      {
216        name: '${virtualNetwork.outputs.name}-vnetlink'
217        virtualNetworkResourceId: virtualNetwork.outputs.resourceId
218      }
219    ]
220  }
221}
222
223// Note: Deploying a Bastion Host will automatically create a Public IP and use the subnet named "AzureBastionSubnet"
224// within our VNet. This subnet is required and must be named exactly "AzureBastionSubnet" for the Bastion Host to work.
225module bastion 'br/public:avm/res/network/bastion-host:0.6.1' = {
226  name: 'bastionDeployment'
227  params: {
228    name: '${prefix}-bastion'
229    virtualNetworkResourceId: virtualNetwork.outputs.resourceId
230    skuName: 'Basic'
231    location: location
232    diagnosticSettings: [
233      {
234        name: 'bastionDiagnostics'
235        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
236      }
237    ]
238  }
239}

This simple addition of the bastion-host AVM module completes the secure access component of our architecture. You can now access the Virtual Machine by way of the Bastion Host in the Azure Portal.

Role-Based Access Control

To complete our solution, we have one final task: to apply Role-Based Access Control (RBAC) restrictions on our services, namely the Key Vault and Storage Account. The goal is to explicitly allow only the Virtual Machine to have Create, Read, Update, or Delete (CRUD) permissions on these two services.

This is accomplished by enabling a System-assigned Managed Identity on the Virtual Machine, then granting the VM’s Managed Identity appropriate permissions on the Storage Account and Key Vault:

➕ Expand Code
  1param location string = 'westus2'
  2
  3@description('Required. A password for the VM admin user.')
  4@secure()
  5param vmAdminPass string
  6
  7var addressPrefix = '10.0.0.0/16'
  8var prefix = 'VM-AVM-Ex1'
  9
 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 11  name: 'logAnalyticsWorkspace'
 12  params: {
 13    // Required parameters
 14    name: '${prefix}-law'
 15    // Non-required parameters
 16    location: location
 17  }
 18}
 19
 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 21  name: 'natGwPublicIpDeployment'
 22  params: {
 23    // Required parameters
 24    name: '${prefix}-natgwpip'
 25    // Non-required parameters
 26    location: location
 27    diagnosticSettings: [
 28      {
 29        name: 'natGwPublicIpDiagnostics'
 30        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 31      }
 32    ]
 33  }
 34}
 35
 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 37  name: 'natGatewayDeployment'
 38  params: {
 39    // Required parameters
 40    name: '${prefix}-natgw'
 41    zone: 1
 42    // Non-required parameters
 43    publicIpResourceIds: [
 44      natGwPublicIp.outputs.resourceId
 45    ]
 46  }
 47}
 48
 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 50  name: 'virtualNetworkDeployment'
 51  params: {
 52    // Required parameters
 53    addressPrefixes: [
 54      addressPrefix
 55    ]
 56    name: '${prefix}-vnet'
 57    // Non-required parameters
 58    location: location
 59    diagnosticSettings: [
 60      {
 61        name: 'vNetDiagnostics'
 62        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 63      }
 64    ]
 65    subnets: [
 66      {
 67        name: 'VMSubnet'
 68        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 69        natGatewayResourceId: natGateway.outputs.resourceId
 70        networkSecurityGroupResourceId: nsgVM.outputs.resourceId
 71      }
 72      {
 73        name: 'PrivateEndpointSubnet'
 74        addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space
 75      }
 76      {
 77        name: 'AzureBastionSubnet' // Azure Bastion Host requires this subnet to be named exactly "AzureBastionSubnet"
 78        addressPrefix: cidrSubnet(addressPrefix, 24, 2) // third subnet in address space
 79      }
 80    ]
 81  }
 82}
 83
 84module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = {
 85  name: 'nsgVmDeployment'
 86  params: {
 87    name: '${prefix}-NSG-VM'
 88    location: location
 89    securityRules: [
 90      {
 91        name: 'AllowBastionSSH'
 92        properties: {
 93          access: 'Allow'
 94          direction: 'Inbound'
 95          priority: 100
 96          protocol: 'Tcp'
 97          sourceAddressPrefix: 'virtualNetwork'
 98          sourcePortRange: '*'
 99          destinationAddressPrefix: '*'
100          destinationPortRange: '22'
101        }
102      }
103    ]
104  }
105}
106
107module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
108  name: 'keyVaultDeployment'
109  params: {
110    // Required parameters
111    name: '${uniqueString(resourceGroup().id)}-kv'
112    // Non-required parameters
113    location: location
114    diagnosticSettings: [
115      {
116        name: 'keyVaultDiagnostics'
117        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
118      }
119    ]
120    enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it
121    secrets: [
122      {
123        name: 'vmAdminPassword'
124        value: vmAdminPass
125      }
126    ]
127    roleAssignments: [
128      {
129        principalId: virtualMachine.outputs.systemAssignedMIPrincipalId
130        principalType: 'ServicePrincipal'
131        roleDefinitionIdOrName: 'Key Vault Secrets User' // Allows read access to secrets
132      }
133    ]
134  }
135}
136
137module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = {
138  name: 'linuxVirtualMachineDeployment'
139  params: {
140    // Required parameters
141    adminUsername: 'localAdminUser'
142    adminPassword: vmAdminPass
143    imageReference: {
144      offer: '0001-com-ubuntu-server-jammy'
145      publisher: 'Canonical'
146      sku: '22_04-lts-gen2'
147      version: 'latest'
148    }
149    name: '${prefix}-vm1'
150    nicConfigurations: [
151      {
152        ipConfigurations: [
153          {
154            name: 'ipconfig01'
155            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
156          }
157        ]
158        nicSuffix: '-nic-01'
159      }
160    ]
161    osDisk: {
162      caching: 'ReadWrite'
163      diskSizeGB: 128
164      managedDisk: {
165        storageAccountType: 'Standard_LRS'
166      }
167    }
168    osType: 'Linux'
169    vmSize: 'Standard_B2s_v2'
170    zone: 0
171    // Non-required parameters
172    location: location
173    managedIdentities: {
174      systemAssigned: true
175    }
176  }
177}
178
179module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = {
180  name: 'storageAccountDeployment'
181  params: {
182    // Required parameters
183    name: '${uniqueString(resourceGroup().id)}sa'
184    // Non-required parameters
185    location: location
186    skuName: 'Standard_LRS'
187    diagnosticSettings: [
188      {
189        name: 'storageAccountDiagnostics'
190        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
191      }
192    ]
193    publicNetworkAccess: 'Disabled'
194    allowBlobPublicAccess: false
195    blobServices: {
196      containers: [
197        {
198          name: 'vmstorage'
199          publicAccess: 'None'
200        }
201      ]
202      roleAssignments:[
203        {
204          principalId: virtualMachine.outputs.systemAssignedMIPrincipalId
205          principalType: 'ServicePrincipal'
206          roleDefinitionName: 'Storage Blob Data Contributor' // Allows read/write/delete on blob containers
207        }
208      ]
209    }
210    privateEndpoints: [
211      {
212        service: 'Blob'
213        subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet
214        privateDnsZoneGroup: {
215          privateDnsZoneGroupConfigs: [
216            {
217              privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId
218            }
219          ]
220        }
221      }
222    ]
223  }
224}
225
226module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = {
227  name: '${prefix}-privatedns-blob'
228  params: {
229    name: 'privatelink.blob.${environment().suffixes.storage}'
230    location: 'global'
231    virtualNetworkLinks: [
232      {
233        name: '${virtualNetwork.outputs.name}-vnetlink'
234        virtualNetworkResourceId: virtualNetwork.outputs.resourceId
235      }
236    ]
237  }
238}
239
240// Note: Deploying a Bastion Host will automatically create a Public IP and use the subnet named "AzureBastionSubnet"
241// within our VNet. This subnet is required and must be named exactly "AzureBastionSubnet" for the Bastion Host to work.
242module bastion 'br/public:avm/res/network/bastion-host:0.6.1' = {
243  name: 'bastionDeployment'
244  params: {
245    name: '${prefix}-bastion'
246    virtualNetworkResourceId: virtualNetwork.outputs.resourceId
247    skuName: 'Basic'
248    location: location
249    diagnosticSettings: [
250      {
251        name: 'bastionDiagnostics'
252        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
253      }
254    ]
255  }
256}
Info

The Azure Subscription owner will have CRUD permissions for the Storage Account but not for the Key Vault. The Key Vault requires explicit RBAC permissions assigned to a user to grant them access: Provide access to Key Vaults using RBAC. Important!: at this point, you will only be able to access the Storage Account from the Bastion Host. Remember, public internet access has been disabled!

The RBAC policies have been successfully applied using a System-assigned Managed Identity on the Virtual Machine. This identity has been granted permissions on both the Key Vault and Storage Account. Now the VM can read secrets from the Key Vault and Read, Create, or Delete blobs in the Storage Account.

In a real production environment, the principle of least privileged access should be applied, providing only the exact permissions each service needs to carry out its functions. Learn more about Microsoft’s recommendations for identity and access management.

Conclusion

In this tutorial, we’ve explored how to leverage Azure Verified Modules (AVM) to build a secure, well-architected solution in Azure. AVM modules significantly simplify the deployment of Azure resources by abstracting away much of the complexity involved in configuring individual resources.

Your final, deployable Bicep template file should now look like this:

➕ Expand Code
  1param location string = 'westus2'
  2
  3@description('Required. A password for the VM admin user.')
  4@secure()
  5param vmAdminPass string
  6
  7var addressPrefix = '10.0.0.0/16'
  8var prefix = 'VM-AVM-Ex1'
  9
 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = {
 11  name: 'logAnalyticsWorkspace'
 12  params: {
 13    // Required parameters
 14    name: '${prefix}-law'
 15    // Non-required parameters
 16    location: location
 17  }
 18}
 19
 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = {
 21  name: 'natGwPublicIpDeployment'
 22  params: {
 23    // Required parameters
 24    name: '${prefix}-natgwpip'
 25    // Non-required parameters
 26    location: location
 27    diagnosticSettings: [
 28      {
 29        name: 'natGwPublicIpDiagnostics'
 30        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 31      }
 32    ]
 33  }
 34}
 35
 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = {
 37  name: 'natGatewayDeployment'
 38  params: {
 39    // Required parameters
 40    name: '${prefix}-natgw'
 41    zone: 1
 42    // Non-required parameters
 43    publicIpResourceIds: [
 44      natGwPublicIp.outputs.resourceId
 45    ]
 46  }
 47}
 48
 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = {
 50  name: 'virtualNetworkDeployment'
 51  params: {
 52    // Required parameters
 53    addressPrefixes: [
 54      addressPrefix
 55    ]
 56    name: '${prefix}-vnet'
 57    // Non-required parameters
 58    location: location
 59    diagnosticSettings: [
 60      {
 61        name: 'vNetDiagnostics'
 62        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
 63      }
 64    ]
 65    subnets: [
 66      {
 67        name: 'VMSubnet'
 68        addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space
 69        natGatewayResourceId: natGateway.outputs.resourceId
 70        networkSecurityGroupResourceId: nsgVM.outputs.resourceId
 71      }
 72      {
 73        name: 'PrivateEndpointSubnet'
 74        addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space
 75      }
 76      {
 77        name: 'AzureBastionSubnet' // Azure Bastion Host requires this subnet to be named exactly "AzureBastionSubnet"
 78        addressPrefix: cidrSubnet(addressPrefix, 24, 2) // third subnet in address space
 79      }
 80    ]
 81  }
 82}
 83
 84module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = {
 85  name: 'nsgVmDeployment'
 86  params: {
 87    name: '${prefix}-NSG-VM'
 88    location: location
 89    securityRules: [
 90      {
 91        name: 'AllowBastionSSH'
 92        properties: {
 93          access: 'Allow'
 94          direction: 'Inbound'
 95          priority: 100
 96          protocol: 'Tcp'
 97          sourceAddressPrefix: 'virtualNetwork'
 98          sourcePortRange: '*'
 99          destinationAddressPrefix: '*'
100          destinationPortRange: '22'
101        }
102      }
103    ]
104  }
105}
106
107module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = {
108  name: 'keyVaultDeployment'
109  params: {
110    // Required parameters
111    name: '${uniqueString(resourceGroup().id)}-kv'
112    // Non-required parameters
113    location: location
114    diagnosticSettings: [
115      {
116        name: 'keyVaultDiagnostics'
117        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
118      }
119    ]
120    enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it
121    secrets: [
122      {
123        name: 'vmAdminPassword'
124        value: vmAdminPass
125      }
126    ]
127    roleAssignments: [
128      {
129        principalId: virtualMachine.outputs.systemAssignedMIPrincipalId
130        principalType: 'ServicePrincipal'
131        roleDefinitionIdOrName: 'Key Vault Secrets User' // Allows read access to secrets
132      }
133    ]
134  }
135}
136
137module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = {
138  name: 'linuxVirtualMachineDeployment'
139  params: {
140    // Required parameters
141    adminUsername: 'localAdminUser'
142    adminPassword: vmAdminPass
143    imageReference: {
144      offer: '0001-com-ubuntu-server-jammy'
145      publisher: 'Canonical'
146      sku: '22_04-lts-gen2'
147      version: 'latest'
148    }
149    name: '${prefix}-vm1'
150    nicConfigurations: [
151      {
152        ipConfigurations: [
153          {
154            name: 'ipconfig01'
155            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet
156          }
157        ]
158        nicSuffix: '-nic-01'
159      }
160    ]
161    osDisk: {
162      caching: 'ReadWrite'
163      diskSizeGB: 128
164      managedDisk: {
165        storageAccountType: 'Standard_LRS'
166      }
167    }
168    osType: 'Linux'
169    vmSize: 'Standard_B2s_v2'
170    zone: 0
171    // Non-required parameters
172    location: location
173    managedIdentities: {
174      systemAssigned: true
175    }
176  }
177}
178
179module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = {
180  name: 'storageAccountDeployment'
181  params: {
182    // Required parameters
183    name: '${uniqueString(resourceGroup().id)}sa'
184    // Non-required parameters
185    location: location
186    skuName: 'Standard_LRS'
187    diagnosticSettings: [
188      {
189        name: 'storageAccountDiagnostics'
190        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
191      }
192    ]
193    publicNetworkAccess: 'Disabled'
194    allowBlobPublicAccess: false
195    blobServices: {
196      containers: [
197        {
198          name: 'vmstorage'
199          publicAccess: 'None'
200        }
201      ]
202      roleAssignments:[
203        {
204          principalId: virtualMachine.outputs.systemAssignedMIPrincipalId
205          principalType: 'ServicePrincipal'
206          roleDefinitionName: 'Storage Blob Data Contributor' // Allows read/write/delete on blob containers
207        }
208      ]
209    }
210    privateEndpoints: [
211      {
212        service: 'Blob'
213        subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet
214        privateDnsZoneGroup: {
215          privateDnsZoneGroupConfigs: [
216            {
217              privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId
218            }
219          ]
220        }
221      }
222    ]
223  }
224}
225
226module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = {
227  name: '${prefix}-privatedns-blob'
228  params: {
229    name: 'privatelink.blob.${environment().suffixes.storage}'
230    location: 'global'
231    virtualNetworkLinks: [
232      {
233        name: '${virtualNetwork.outputs.name}-vnetlink'
234        virtualNetworkResourceId: virtualNetwork.outputs.resourceId
235      }
236    ]
237  }
238}
239
240// Note: Deploying a Bastion Host will automatically create a Public IP and use the subnet named "AzureBastionSubnet"
241// within our VNet. This subnet is required and must be named exactly "AzureBastionSubnet" for the Bastion Host to work.
242module bastion 'br/public:avm/res/network/bastion-host:0.6.1' = {
243  name: 'bastionDeployment'
244  params: {
245    name: '${prefix}-bastion'
246    virtualNetworkResourceId: virtualNetwork.outputs.resourceId
247    skuName: 'Basic'
248    location: location
249    diagnosticSettings: [
250      {
251        name: 'bastionDiagnostics'
252        workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId
253      }
254    ]
255  }
256}

AVM modules provide several key advantages over writing raw Bicep templates:

  1. Simplified Resource Configuration: AVM modules handle much of the complex configuration work behind the scenes
  2. Built-in Recommended Practices: The modules implement many of Microsoft’s recommended practices by default
  3. Consistent Outputs: Each module exposes a consistent set of outputs that can be easily referenced
  4. Reduced Boilerplate Code: What would normally require hundreds of lines of Bicep code can be accomplished in a fraction of the space

As you continue your journey with Azure and AVM, remember that this approach can be applied to more complex architectures as well. The modular nature of AVM allows you to mix and match components to build solutions that meet your specific needs while adhering to Microsoft’s Well-Architected Framework.

By using AVM modules as building blocks, you can focus more on your solution architecture and less on the intricacies of individual resource configurations, ultimately leading to faster development cycles and more reliable deployments.

Clean up your environment

When you are ready, you can remove the infrastructure deployed in this example. Key Vaults are set to a soft-delete state so you will also need to purge the one we created in order to fully delete it. The following commands will remove all resources created by your deployment:

Clean up with
# Delete the resource group
Remove-AzResourceGroup -Name "avm-bicep-vmexample1" -Force

# Purge the Key Vault
Remove-AzKeyVault -VaultName "<keyVaultName>" -Location "<location>" -InRemovedState -Force
# Delete the resource group
az group delete --name 'avm-bicep-vmexample1' --yes --no-wait

# Purge the Key Vault
az keyvault purge --name '<keyVaultName>' --no-wait

Congratulations, you have successfully leveraged AVM Bicep modules to deploy resources in Azure!

Tip

We welcome your contributions and feedback to help us improve the AVM modules and the overall experience for the community!

Terraform - Solution Development

Introduction

Azure Verified Modules (AVM) for Terraform are a powerful tool that leverage the Terraform domain-specific language (DSL), industry knowledge, and an Open Source community, which altogether enable developers to quickly deploy Azure resources that follow Microsoft’s recommended practices for Azure.
In this article, we will walk through the Terraform specific considerations and recommended practices on developing your solution leveraging Azure Verified Modules. We’ll review some of the design features and trade-offs and include sample code to illustrate each discussion point.

Warning

This is a solution-composition lab, not a template for authoring an AVM module. Every new AVM Terraform module MUST use AzAPI for every control-plane resource and supported data-plane operation it declares.
In a new AVM module repository, AzureRM is permitted only for a specific documented data-plane/non-ARM operation that AzAPI cannot implement. It must not be used for control-plane resources in the root module, submodules, examples/end-to-end tests, Terraform tests, fixtures, or documentation snippets.
A solution root may configure AzureRM only when a currently published AVM dependency requires it; direct supporting Azure resources must still use AzAPI.
Current modules expose parent_id instead of resource_group_name (per TFRMFR1), name their primary resource this (per TFRMNFR2), and source the AzAPI resource type from var.resource_types (per TFFR6).
For a concise current example, use the Terraform quickstart and follow TFFR3.

Prerequisites

You will need the following tools and components to complete this guide:

Before you begin, ensure you have these tools installed in your development environment.

Planning

Good module development should start with a good plan. Let’s first review the architecture and module design prior to developing our solution.

Solution Architecture

Before we begin coding, it is important to have details about what the infrastructure architecture will include. For our example, we will be building a solution that will host a simple application on a Linux virtual machine (VM).

In our design, the resource group for our solution will require appropriate tagging to comply with our corporate standards. Resources that support Diagnostic Settings must also send metric data to a Log Analytics workspace, so that the infrastructure support teams can get metric telemetry. The virtual machine will require outbound internet access to allow the application to properly function. A Key Vault will be included to store any secrets and key artifacts, and we will include a Bastion instance to allow support personnel to access the virtual machine if needed. Finally, the VM is intended to run without interaction, so we will auto-generate an SSH private key and store it in the Key Vault for the rare event of someone needing to log into the VM.

Based on this narrative, we will create the following resources:

  • A resource group to contain all the resources with tagging
  • A random string resource for use in resources with global naming (Key Vault)
  • A Log Analytics workspace for diagnostic data
  • A Key Vault with:
    • Role-Based Access Control (RBAC) to allow data access
    • Logging to the Log Analytics workspace
  • A virtual network with:
    • A virtual machine subnet
    • A Bastion subnet
    • Network Security Group on the VM subnet allowing SSH traffic
    • Logging to the Log Analytics workspace
  • A NAT Gateway for enabling outbound internet access
    • Associated to the virtual machine subnet
  • A Bastion service for secure remote access to the Virtual Machine
    • Logging to the Log Analytics workspace
  • A virtual machine resource with
    • A single private IPv4 interface attached to the VM subnet
    • A randomly generated admin account private key stored in the Key Vault
    • Metrics sent to the log Analytics workspace
Azure VM Solution Architecture

Solution template (root module) design

Since our solution template (root module) is intended to be deployed multiple times, we want to develop it in a way that provides flexibility while minimizing the amount of input necessary to deploy the solution. For these reasons, we will create our module with a small set of variables that allow for deployment differentiation while still populating solution-specific defaults to minimize input. We will also separate our content into variables.tf, outputs.tf, terraform.tf, and main.tf files to simplify future maintenance.

Based on this, our file system will take the following structure:

  • Module Directory
    • terraform.tf - This file holds the provider definitions and versions.
    • variables.tf - This file contains the input variable definitions and defaults.
    • outputs.tf - This file contains the outputs and their descriptions for use by any external modules calling this root module.
    • main.tf - This file contains the core module code for creating the solutions infrastructure.
    • development.tfvars - This file will contain the inputs for the instance of the module that is being deployed. Content in this file will vary from instance to instance.
Note

Terraform will merge content from any file ending in a .tf extension in the module folder to create the full module content. Because of this, using different files is not required. We encourage file separation to allow for organizing code in a way that makes it easier to maintain. While the naming structure we’ve used is common, there are many other valid file naming and organization options that can be used.

In our example, we will use the following variables as inputs to allow for customization:

  • location - The location where our infrastructure will be deployed.
  • name_prefix - This will be used to preface all of the resource naming.
  • virtual_network_prefix - This will be used to ensure IP uniqueness for the deployment.
  • tags - The custom tags to use for each deployment.

Finally, we will export the following outputs:

  • resource_group_name - This will allow for finding this deployment if there are multiples.
  • virtual_machine_name - This can be used to find and login to the vm if needed.

Identifying AVM modules that match our solution

Now that we’ve determined our architecture and module configurations, we need to see what AVM modules exist for use in our solution. To do this, we will open the AVM Terraform pattern module index and check if there are any existing pattern modules that match our requirement. In this case, no pattern modules fit our needs. If this was a common pattern, we could open an issue on the AVM github repository to get assistance from the AVM project to create a pattern module matching our requirements. Since our architecture isn’t common, we’ll continue to the next step.

When a pattern module fitting our needs doesn’t exist for a solution, leveraging AVM resource modules to build our own solution is the next best option. Review the AVM Terraform published resource module index for each of the resource types included in your architecture. For each AVM module, capture a link to the module to allow for a review of the documentation details on the Terraform Registry website.

Note

Some of the published pattern modules cover multi-resource configurations that can sometimes be interpreted as a single resource. Be sure to check the pattern index for groups of resources that may be part of your architecture and that don’t exist in the resource module index. (e.g., Virtual WAN)

For our sample architecture, we have the following AVM resource modules at our disposal. Click on each module to explore its documentation on the Terraform Registry.

Develop the Solution Code

We can now begin coding our solution. We will create each element individually, to allow us to test our deployment as we build it out. This will also allow us to correct any bugs incrementally, so that we aren’t troubleshooting a large number of resources at the end.

Creating the terraform.tf file

Let’s begin by configuring the provider details necessary to build our solution. Since this is a root module, we want to include any provider and Terraform version constraints for this module. We’ll periodically come back and add any needed additional providers if our design includes a resource from a new provider.

Open up your development IDE (Visual studio code in our example) and create a file named terraform.tf in your root directory.

Add the following code to your terraform.tf file:

➕ Expand Code
1terraform {
2  required_version = "~> 1.9"
3  required_providers {
4  }
5}
Note

Always click on the “Copy to clipboard” button in the top right corner of the Code sample area in order not to have the line numbers included in the copied code.

This specifies that the required Terraform binary version to run your module can be any version between 1.9 and 2.0. This is a good compromise for allowing a range of binary versions while also ensuring support for any required features that are used as part of the module. This can include things like newly introduced functions or support for new key words.

Since we are developing our solution incrementally, we should validate our code. To do this, we will take the following steps:

  1. Open up a terminal window if it is not already open. In some IDE’s this can be done as a function of the IDE.
  2. Change directory to the module directory by typing cd and then the path to the module. As an example, if the module directory was named example we would run cd example.
  3. Run terraform init to initialize your provider file.

You should now see a message indicating that Terraform has been successfully initialized. This indicates that our code is error free and we can continue on. If you get errors, examine the provider syntax for typos, missing quotes, or missing brackets.

Creating a variables.tf file

Because our module is intended to be reusable, we want to provide the capability to customize each module call with those items that will differ between them. This is done by using variables to accept inputs into the module. We’ll define these inputs in a separate file named variables.tf.

Go back to the IDE, and create a file named variables.tf in the working directory.

Add the following code to your variables.tf file to configure the inputs for our example:

➕ Expand Code
 1variable "name_prefix" {
 2  description = "Prefix for the name of the resources"
 3  type        = string
 4  default     = "example"
 5}
 6
 7variable "location" {
 8  description = "The Azure location to deploy the resources"
 9  type        = string
10  default     = "East US"
11}
12
13variable "virtual_network_cidr" {
14  description = "The CIDR prefix for the virtual network. This should be at least a /22. Example 10.0.0.0/22"
15  type        = string
16}
17
18variable "tags" {
19  description = "Tags to be applied to all resources"
20  type        = map(string)
21  default     = {}
22}
Note

Note that each variable definition includes a type definition to guide module users on how to properly define an input. Also note that it is possible to set a default value. This allows module consumers to avoid setting a value if they find the default to be acceptable.

We should now test the new content we’ve created for our module. To do this, first re-run terraform init on your command line. Note that nothing has changed and the initialization completes successfully. Since we now have module content, we will attempt to run the plan as the next step of the workflow.

Type terraform plan on your command line. Note that it now asks for us to provide a value for the var.virtual_network_cidr variable. This is because we don’t provide a default value for that input so Terraform must have a valid input before it can continue. Type 10.0.0.0/22 into the input and press enter to allow the plan to complete. You should now see a message indicating that Your infrastructure matches the configuration and that no changes are needed.

Creating a development.tfvars file

There are multiple ways to provide input to the module we’re creating. We will create a tfvars file that can be supplied during plan and apply stages to minimize the need for manual input. tfvars files are a nice way to document inputs as well as allow for deploying different versions of your module. This is useful if you have a pipeline where infrastructure code is deployed first for development, and then is deployed for QA, staging, or production with different input values.

In your IDE, create a new file named development.tfvars in your working directory.

Now add the following content to your development.tfvars file.

➕ Expand Code
1location = "westus2"
2prefix = dev
3virtual_network_cidr = "10.1.0.0/22"
4tags = {
5  environment = "development"
6  owner       = "dev-team"
7}
Note

Note that each variable has a value defined. Although, only inputs without default values are required, we include values for all of the inputs for clarity. Consider doing this in your environments so that someone looking at the tfvars files has a full picture of what values are being set.

Re-run the terraform apply, but this time, reference the .tfvars file by using the following command: terraform plan -var-file=development.tfvars. You should get a successful completion without needing to manually provide inputs.

Creating the main.tf file

Now that we’ve created the supporting files, we can start building the actual infrastructure code in our main file. We will add one AVM resource module at a time so that we can test each as we implement them.

Return to your IDE and create a new file named main.tf.

Add a resource group

In Azure, we need a resource group to hold any infrastructure resources we create. This is a simple resource that typically wouldn’t require an AVM module, but we’ll include the AVM module so we can take advantage of the Role-Based Access Control (RBAC) interface if we need to restrict access to the resource group in future versions.

First, let’s visit the Terraform registry documentation page for the resource group and explore several key sections.

  1. Note the Provision Instructions box on the right-hand side of the page. This contains the module source and version details which allows us to copy the latest version syntax without needing to type everything ourselves.
  2. Now review the Readme tab in the middle of the page. It contains details about all required and optional inputs, resources that are created with the module, and any outputs that are defined. If you want to explore any of these items in detail, each element has a tab that you can review as needed.
  3. Finally, in the middle of the page, there is a drop-down menu named Examples that contains functioning examples for the AVM module. These showcase a good example of using copy/paste to bootstrap module code and then modify it for your specific purpose.

Now that we’ve explored the registry content, let’s add a resource group to our module.

First, copy the content from the Provision Instructions box into our main.tf file.

➕ Expand Code
1module "avm-res-resources-resourcegroup" {
2  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
3  version = "0.2.1"
4  # insert the 2 required variables here
5}

On the modules documentation page, go to the inputs tab. Review the Required Inputs tab. These are the values that don’t have defaults and are the minimum required values to deploy the module. There are additional inputs in the Optional Inputs section that can be used to configure additional module functionality. Review these inputs and determine which values you would like to define in your AVM module call.

Now, replace the # insert the 2 required variables here comment with the following code to define the module inputs. Our main.tf code should look like the following:

➕ Expand Code
1module "avm-res-resources-resourcegroup" {
2  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
3  version = "0.2.1"
4
5  name = "${var.name_prefix}-rg"
6  location = var.location
7  tags = var.tags
8}
Note

Note how we’ve used the prefix variable and Terraform interpolation syntax to dynamically name the resource group. This allows for module customization and re-use. Also note that even though we chose to use the default module name of avm-res-resources-resourcegroup, we could modify the name of the module if needed.

After saving the file, we want to test our new content. To do this, return to the command line and first run terraform init. Notice how Terraform has downloaded the module code and its providers. Currently published modules in this solution still require AzureRM, while direct Azure resources should use AzAPI.

Let’s now deploy our resource group. First, let’s run a plan operation to review what will be created. Type terraform plan -var-file=development.tfvars and press enter to initiate the plan.

Configure the providers

Configure AzAPI explicitly and enable preflight validation. The published AVM modules used by this solution also require an AzureRM provider configuration. This is a dependency of those existing modules and does not permit direct AzureRM resources in a new AVM module repository.

Return to the terraform.tf file and add the following content:

➕ Expand Code
 1terraform {
 2  required_version = "~> 1.9"
 3  required_providers {
 4    azapi = {
 5      source  = "Azure/azapi"
 6      version = "~> 2.12"
 7    }
 8    azurerm = {
 9      source  = "hashicorp/azurerm"
10      version = "~> 4.27"
11    }
12  }
13}
14
15provider "azapi" {
16  enable_preflight = true
17}
18
19provider "azurerm" {
20  features {}
21}

Run terraform init -upgrade to install AzAPI and update the lock file, then re-run terraform plan -var-file=development.tfvars.

Select the subscription

AzAPI uses the active Azure authentication context. Confirm that Azure CLI is signed in to the subscription where the resources should be created.

First, we need to get the subscription ID value. We will use the portal for this exercise, but using the Azure CLI, PowerShell, or the resource graph will also work to retrieve this value.

  1. Open the Azure portal.
  2. Enter Subscriptions in the search field at the top middle of the page.
  3. Select Subscriptions from the services menu in the search drop-down.
  4. Select the subscription you wish to deploy to, from the list of subscriptions.
  5. Find the Subscription ID field on the overview page and click the copy button to copy it to the clipboard.

Set the environment variable required by AzureRM v4 for the currently published AVM dependencies, then select the same subscription in Azure CLI:

$env:ARM_SUBSCRIPTION_ID = "<subscription-id>"
az account set --subscription "<subscription-id>"
az account show --query "{name:name,id:id}" --output table

Finally, we should now be able to complete our plan operation by re-running terraform plan -var-file=development.tfvars. Note that the plan will create three resources, two for telemetry and one for the resource group.

Deploy the resource group

We can complete testing by implementing the resource group. Run terraform apply -var-file="development.tfvars" and type yes and press enter when prompted to accept the changes. Terraform will create the resource group and notify you with a Apply complete message and a summary of the resources that were added, changed, and destroyed.

Deploy the Log Analytics Workspace

We can now continue by adding the Log Analytics Workspace to our main.tf file. We will follow a workflow similar to what we did with the resource group.

  1. Browse to the AVM Log Analytics Workspace module page in the Terraform Registry.
  2. Copy the module content from the Provision Instructions portion of the page into the main.tf file.

This time, instead of manually supplying module inputs, we will copy module content from one of the examples to minimize the amount of typing required. In most examples, the AVM module call is located at the bottom of the example.

  1. Navigate to the Examples drop-down menu in the documentation and select the default example from the menu. You will see a fully functioning example code which includes the module and any supporting resources. Since we only care about the workspace resource from this example, we can scroll to the bottom of the code block and find the module "log_analytics_workspace" line.
  2. Copy the content between the module brackets with the exception of the line defining the module source. Because these examples are part of the testing methodology for the module, they use a dot reference value (../..) for the module source value which will not work in our module call. To work around this, we copied those values from the provision instructions section of the module documentation in a previous step.
  3. Update the location and resource group name values to reference outputs from the resource group module. Using implicit references such as these allow Terraform to determine the order in which resources should be built.
  4. Update the name field using the prefix variable to allow for customization using a similar pattern to what we used on the resource group.

The Log Analytics module content should look like the following code block. For simplicity, you can also copy this directly to avoid multiple copy/paste actions.

➕ Expand Code
 1module "avm-res-operationalinsights-workspace" {
 2  source  = "Azure/avm-res-operationalinsights-workspace/azurerm"
 3  version = "0.4.2"
 4
 5  enable_telemetry                          = true
 6  location                                  = module.avm-res-resources-resourcegroup.resource.location
 7  resource_group_name                       = module.avm-res-resources-resourcegroup.name
 8  name                                      = "${var.name_prefix}-law"
 9  log_analytics_workspace_retention_in_days = 30
10  log_analytics_workspace_sku               = "PerGB2018"
11}

Again, we will need to run terraform init to allow Terraform to initialize a copy of the AVM Log Analytics module.

Now, we can deploy the Log Analytics workspace by running terraform apply -var-file="development.tfvars", typing yes and pressing enter. Note that Terraform will only create the new Log Analytics resources since the resource group already exists. This is one of the key benefits of deploying using Infrastructure as Code (IAC) tools like Terraform.

Note

Note that we ran the terraform apply command without first running terraform plan. Because terraform apply runs a plan before prompting for the apply, we opted to shorten the instructions by skipping the explicit plan step. If you are testing in a live environment, you may want to run the plan step and save the plan as part of your governance or change control processes.

Deploy the Azure Key Vault

Our solution calls for a simple Key Vault implementation to store virtual machine secrets. We’ll follow the same workflow for deploying the Key Vault as we used for the previous resource group and Log Analytics workspace resources. However, since Key Vaults require data roles to manage secrets and keys, we will need to use the RBAC interface and a data resource to configure Role-Based Access Control (RBAC) during the deployment.

Note

For this exercise, we will provision the deployment user with data rights on the Key Vault. In your environment, you will likely want to either provide additional roles as inputs or statically assign users, or groups to the Key Vault data roles. For simplicity we also set the Key Vault to have public access enabled due to us not being able to dictate a private deployment environment. In your environment where your deployment machine will be on a private network it is recommended to restrict public access for the Key Vault.

Before we implement the AVM module for the Key Vault, we want to use a data resource to read the client details about the user context of the current Terraform deployment.

Add the following line to your main.tf file and save it.

➕ Expand Code
1data "azapi_client_config" "this" {}

Key vaults use a global namespace which means that we will also need to add a randomization resource to allow us to randomize the name to avoid any potential name intersection issues with other Key Vault deployments. We will use Terraform’s random provider to generate the random string which we will append to the Key Vault name. Add the following code to your main module to create the random_string resource we will use for naming.

➕ Expand Code
1resource "random_string" "name_suffix" {
2  length  = 4
3  special = false
4  upper   = false
5}

Now we can continue with adding the AVM Key Vault module to our solution.

  1. Browse to the AVM Key Vault resource module page in the Terraform Registry.
  2. Copy the module content from the Provision Instructions portion of the page into the main.tf file.
  3. This time, we’re going to select relevant content from the Create secret example to fill out our module.
  4. Copy the name, location, enable_telemetry, resource_group_name, tenant_id, and role_assignments value content from the example and paste it into the new Key Vault module in your solution.
  5. Update the name value to be "${var.prefix}-kv-${random_string.name_suffix.result}"
  6. Update the location and resource_group_name values to the same implicit resource group module references we used in the Log Analytics workspace.
  7. Set the enable_telemetry value to true.
  8. Leave the tenant_id and role_assignments values to the same values that are in the example.

Our architecture calls for us to include a diagnostic settings configuration for each resource that supports it. We’ll use the diagnostic-settings example to copy this content.

  1. Return to the documentation page and select the diagnostic-settings option from the examples drop-down.
  2. Locate the Key Vault resource in the example’s code block and copy the diagnostic_settings value and paste it into the Key Vault module block we’re building in main.tf.
  3. Update the name value to use our prefix variable to allow for name customization.
  4. Update the workspace_resource_id value to be an implicit reference to the output from the previously implemented Log Analytics module (module.avm-res-operationalinsights-workspace.resource_id in our code).

Finally, we will allow public access, so that our deployer machine can add secrets to the Key Vault. If your environment doesn’t allow public access for Key Vault deployments, locate the public IP address of your deployer machine (this may be an external NAT IP for your network) and add it to the network_acls.ip_rules list value using CIDR notation.

  1. Set the network_acls input to null in your module block for the Key Vault.

Your Key Vault module definition should now look like the following:

➕ Expand Code
 1module "avm-res-keyvault-vault" {
 2  source  = "Azure/avm-res-keyvault-vault/azurerm"
 3  version = "0.10.0"
 4
 5  enable_telemetry    = true
 6  location            = module.avm-res-resources-resourcegroup.resource.location
 7  resource_group_name = module.avm-res-resources-resourcegroup.name
 8  name                = "${var.name_prefix}-kv-${random_string.name_suffix.result}"
 9  tenant_id           = data.azapi_client_config.this.tenant_id
10  network_acls        = null
11
12  diagnostic_settings = {
13    to_la = {
14      name                  = "${var.name_prefix}-kv-diags"
15      workspace_resource_id = module.avm-res-operationalinsights-workspace.resource_id
16    }
17  }
18
19  role_assignments = {
20    deployment_user_kv_admin = {
21      role_definition_id_or_name = "Key Vault Administrator"
22      principal_id               = data.azapi_client_config.this.object_id
23    }
24  }
25}
Note

One of the core values of AVM is the standard configuration for interfaces across modules. The Role Assignments interface we used as part of the Key Vault deployment is a good example of this.

Continue the incremental testing of your module by running another terraform init and terraform apply -var-file="development.tfvars" sequence.

Deploy the NAT Gateway

Our architecture calls for a NAT Gateway to allow virtual machines to access the internet. We will use the NAT Gateway resource_id output in future modules to link the virtual machine subnet.

  1. Browse to the AVM NAT Gateway resource module page in the Terraform Registry.
  2. Copy the module definition and source from the Provision Instructions card from the module main page.
  3. Copy the remaining module content from the default example excluding the subnet associations map, as we will do the association when we build the vnet.
  4. Update the location and resource_group_nameusing implicit references from our resource group module.
  5. Then update each of the name values to use the name_prefix variables.

Review the following code to see each of these changes.

➕ Expand Code
 1module "avm-res-network-natgateway" {
 2  source  = "Azure/avm-res-network-natgateway/azurerm"
 3  version = "0.2.1"
 4
 5  name                = "${var.name_prefix}-natgw"
 6  enable_telemetry    = true
 7  location            = module.avm-res-resources-resourcegroup.resource.location
 8  resource_group_name = module.avm-res-resources-resourcegroup.name
 9
10  public_ips = {
11    public_ip_1 = {
12      name = "${var.name_prefix}-natgw-pip"
13    }
14  }
15}

Continue the incremental testing of your module by running another terraform init and terraform apply -var-file="development.tfvars" sequence.

Deploy the Network Security Group

Our architecture calls for a Network Security Group (NSG) allowing SSH access to the virtual machine subnet. We will use the NSG AVM resource module to accomplish this task.

  1. Browse to the AVM Network Security Group resource module page in the Terraform Registry.
  2. Copy the module definition and source from the Provision Instructions card from the module main page.
  3. Copy the remaining module content from the example_with_NSG_rule example.
  4. Update the location and resource_group_nameusing implicit references from our resource group module.
  5. Update the name value using the name_prefix variable interpolation as we did with the other modules.
  6. Copy the map entry labeled rule02 from the locals nsg_rules map and paste it between two curly braces to create the security_rules attribute in the NSG module we’re building.
  7. Make the following updates to the rule details:
    1. Rename the map key to "rule01" from "rule02".
    2. Update the name to use the var.prefix interpolation and SSH to describe the rule.
    3. Update the destination_port_ranges list to be ["22"].

Upon completion the code for the NSG module should be as follows:

➕ Expand Code
 1module "avm-res-network-networksecuritygroup" {
 2  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
 3  version = "0.4.0"
 4  resource_group_name = module.avm-res-resources-resourcegroup.name
 5  name                = "${var.name_prefix}-vm-subnet-nsg"
 6  location            = module.avm-res-resources-resourcegroup.resource.location
 7
 8  security_rules = {
 9    "rule01" = {
10      name                       = "${var.name_prefix}-ssh"
11      access                     = "Allow"
12      destination_address_prefix = "*"
13      destination_port_ranges    = ["22"]
14      direction                  = "Inbound"
15      priority                   = 200
16      protocol                   = "Tcp"
17      source_address_prefix      = "*"
18      source_port_range          = "*"
19    }
20  }
21}

Continue the incremental testing of your module by running another terraform init and terraform apply -var-file="development.tfvars" sequence.

Deploy the Virtual Network

We can now continue the build-out of our architecture by configuring the virtual network (vnet) deployment. This will follow a similar pattern as the previous resource modules, but this time, we will also add some network functions to help us customize the subnet configurations.

  1. Browse to the AVM Virtual Network resource module page in the Terraform Registry.
  2. Copy the module definition and source from the Provision Instructions card from the module main page.
  3. After looking through the examples, this time, we’ll use the complete example as a source to copy our content.
  4. Copy the resource_group_name, location, name, and address_space lines and replace their values with our deployment specific variables or module references.
  5. We’ll copy the subnets map and duplicate the subnet0 map for each subnet.
  6. Now we will update the map key and name values for each subnet so that they are unique.
  7. Then we’ll use the cidrsubnet function to dynamically generate the CIDR range for each subnet. You can explore the function documentation for more details on how it can be used.
  8. We will also populate the nat_gateway object on subnet0 with the resource_id output from our NAT Gateway module.
  9. To configure the NSG on the VM subnet we need to link it. Add a network_security_group attribute to the subnet0 definition and replace the value with the resource_id output from the NSG module.
  10. Finally, we’ll copy the diagnostic settings from the example and update the implicit references to point to our previously deployed Log Analytics workspace.

After making these changes our virtual network module call code will be as follows:

➕ Expand Code
 1module "avm-res-network-virtualnetwork" {
 2  source  = "Azure/avm-res-network-virtualnetwork/azurerm"
 3  version = "0.8.1"
 4
 5  resource_group_name = module.avm-res-resources-resourcegroup.name
 6  location            = module.avm-res-resources-resourcegroup.resource.location
 7  name                = "${var.name_prefix}-vnet"
 8
 9  address_space = [var.virtual_network_cidr]
10
11  subnets = {
12    subnet0 = {
13      name                            = "${var.name_prefix}-vm-subnet"
14      default_outbound_access_enabled = false
15      address_prefixes = [cidrsubnet(var.virtual_network_cidr, 1, 0)]
16      nat_gateway = {
17        id = module.avm-res-network-natgateway.resource_id
18      }
19      network_security_group = {
20        id = module.avm-res-network-networksecuritygroup.resource_id
21      }
22    }
23    bastion = {
24      name                            = "AzureBastionSubnet"
25      default_outbound_access_enabled = false
26      address_prefixes = [cidrsubnet(var.virtual_network_cidr, 1, 1)]
27    }
28  }
29
30  diagnostic_settings = {
31    sendToLogAnalytics = {
32      name                           = "${var.name_prefix}-vnet-diagnostic"
33      workspace_resource_id          = module.avm-res-operationalinsights-workspace.resource_id
34      log_analytics_destination_type = "Dedicated"
35    }
36  }
37}
Note

Note how the Log Analytics workspace reference ends in resource_id. Each AVM module is required to export its Azure resource ID with the resource_id name to allow for consistent references.

Continue the incremental testing of your module by running another terraform init and terraform apply -var-file="development.tfvars" sequence.

Deploy the Bastion service

We want to allow for secure remote access to the virtual machine for configuration and troubleshooting tasks. We’ll use Azure Bastion to accomplish this objective following a similar workflow to our other resources.

  1. Browse to the AVM Bastion resource module page in the Terraform Registry.
  2. Copy the module definition and source from the Provision Instructions card from the module main page.
  3. Copy the remaining module content from the Simple Deployment example.
  4. Update the location and resource_group_nameusing implicit references from our resource group module.
  5. Update the name value using the name_prefix variable interpolation as we did with the other modules.
  6. Finally, update the subnet_id value to include an implicit reference to the bastion keyed subnet from our virtual network module.

Our architecture calls for diagnostic settings to be configured on the Azure Bastion resource. In this case, there aren’t any examples that include this configuration. However, since the diagnostic settings interface is one of the standard interfaces in Azure Verified Modules, we can just copy the interface definition from our virtual network module.

  1. Locate the virtual network module and copy the diagnostic_settings value from it.
  2. Paste the diagnostic_settings value into the code for our Bastion module.
  3. Update the diagnostic setting’s name value from vnet to Bastion.

The new code we added for the Bastion resource will be as follows:

➕ Expand Code
 1module "avm-res-network-bastionhost" {
 2  source  = "Azure/avm-res-network-bastionhost/azurerm"
 3  version = "0.7.2"
 4
 5  name                = "${var.name_prefix}-bastion"
 6  resource_group_name = module.avm-res-resources-resourcegroup.name
 7  location            = module.avm-res-resources-resourcegroup.resource.location
 8  ip_configuration = {
 9    subnet_id = module.avm-res-network-virtualnetwork.subnets["bastion"].resource_id
10  }
11
12  diagnostic_settings = {
13    sendToLogAnalytics = {
14      name                           = "${var.name_prefix}-bastion-diagnostic"
15      workspace_resource_id          = module.avm-res-operationalinsights-workspace.resource_id
16      log_analytics_destination_type = "Dedicated"
17    }
18  }
19}
Note

Pay attention to the subnet_id syntax. In the virtual network module, the subnets are created as a sub-module allowing us to reference each of them using the map key that was defined in the subnets input. Again, we see the consistent output naming with the resource_id output for the sub-module.

Continue the incremental testing of your module by running another terraform init and terraform apply -var-file="development.tfvars" sequence.

Deploy the virtual machine

The final step in our deployment will be our application virtual machine. We’ve had good success with our workflow so far, so we’ll use it for this step as well.

  1. Browse to the AVM Virtual Machine resource module page in the Terraform Registry.
  2. Copy the module definition and source from the Provision Instructions card from the module main page.
  3. Copy the remaining module content from the linux_default example.
  4. Update the location and resource_group_nameusing implicit references from our resource group module.
  5. To be compliant with Well Architected Framework guidance we encourage defining a zone if your region supports it. Update the zone input to 1.
  6. Update the sku_size input to “Standard_D2s_v5”.
  7. Update the name values using the name_prefix variable interpolation as we did with the other modules and include the output from the random_string.name_suffix resource to add uniqueness.
  8. Set the account_credentials.key_vault_configuration.resource_id value to reference the resource_id output from the Key Vault module.
  9. Update the private_ip_subnet_resource_id value to an implicit reference to the subnet0 subnet output from the virtual network module.

Because the default Linux example doesn’t include diagnostic settings, we need to add that content in a different way. Since the diagnostic settings interface has a standard schema, we can copy the diagnostic_settings input from our virtual network module.

  1. Locate the virtual network module in your code and copy the diagnostic_settings map from it.
  2. Paste the diagnostic_settings content into your virtual machine module code.
  3. Update the name value to reflect that it applies to the virtual machine.

The new code we added for the virtual machine resource will be as follows:

➕ Expand Code
 1module "avm-res-compute-virtualmachine" {
 2  source  = "Azure/avm-res-compute-virtualmachine/azurerm"
 3  version = "0.19.1"
 4
 5  enable_telemetry    = true
 6  location            = module.avm-res-resources-resourcegroup.resource.location
 7  resource_group_name = module.avm-res-resources-resourcegroup.name
 8  name                = "${var.name_prefix}-vm"
 9  os_type             = "Linux"
10  sku_size            = "Standard_D2s_v5"
11  zone                = 1
12
13  source_image_reference = {
14    publisher = "Canonical"
15    offer     = "0001-com-ubuntu-server-focal"
16    sku       = "20_04-lts-gen2"
17    version   = "latest"
18  }
19
20  network_interfaces = {
21    network_interface_1 = {
22      name = "${var.name_prefix}-nic-${random_string.name_suffix.result}"
23      ip_configurations = {
24        ip_configuration_1 = {
25          name                          = "${var.name_prefix}-ipconfig-${random_string.name_suffix.result}"
26          private_ip_subnet_resource_id = module.avm-res-network-virtualnetwork.subnets["subnet0"].resource_id
27        }
28      }
29    }
30  }
31
32  diagnostic_settings = {
33    sendToLogAnalytics = {
34      name                           = "${var.name_prefix}-vm-diagnostic"
35      workspace_resource_id          = module.avm-res-operationalinsights-workspace.resource_id
36      log_analytics_destination_type = "Dedicated"
37    }
38  }
39}

Continue the incremental testing of your module by running another terraform init and terraform apply -var-file="development.tfvars" sequence.

Creating the outputs.tf file

The final piece of our module is to export any values that may need to be consumed by module users. From our architecture, we’ll export the resource group name and the virtual machine resource name.

  1. Create an outputs.tf file in your IDE.
  2. Create an output named resource_group_name and set the value to an implicit reference to the resource group modules name output. Include a brief description for the output.
  3. Create an output named virtual_machine_name and set the value to an implicit reference to the virtual machine module’s name output. Include a brief description for the output.

The new code we added for the outputs will be as follows:

➕ Expand Code
1output "resource_group_name" {
2  value =  module.avm-res-resources-resourcegroup.name
3  description = "The resource group name where the resources are deployed"
4}
5
6output "virtual_machine_name" {
7    value = module.avm-res-compute-virtualmachine.name
8    description = "The name of the virtual machine"
9}

Because no new modules were created, we don’t need to run terraform init to test this change. Run terraform apply -var-file="development.tfvars" to see the new outputs that have been created.

Update the terraform.tf file

It is a recommended practice to define the required versions of the providers for your module to ensure consistent behavior when it is being run. In this case we are going to be slightly permissive and allow increases in minor and patch versions to fluctuate, since those are not supposed to include breaking changes. In a production environment, you would likely want to pin on a specific version to guarantee behavior.

  1. Run terraform init to review the providers and versions that are currently installed.
  2. Update your terraform.tf file’s required providers field for each provider listed in the downloaded providers.

The updated code we added for the providers in the terraform.tf file will be as follows:

➕ Expand Code
 1terraform {
 2  required_version = "~> 1.9"
 3  required_providers {
 4    azapi = {
 5      source  = "Azure/azapi"
 6      version = "~> 2.12"
 7    }
 8    azurerm = {
 9      source  = "hashicorp/azurerm"
10      version = "~> 4.27"
11    }
12    modtm = {
13      source  = "azure/modtm"
14      version = "~> 0.3"
15    }
16    random = {
17      source  = "hashicorp/random"
18      version = "~> 3.7"
19    }
20    time = {
21      source  = "hashicorp/time"
22      version = "~> 0.13"
23    }
24    tls = {
25      source  = "hashicorp/tls"
26      version = "~> 4.1"
27    }
28  }
29}
30
31provider "azapi" {
32  enable_preflight = true
33}
34
35provider "azurerm" {
36  features {}
37}

Conclusion

Congratulations on successfully implementing a solution using Azure Verified Modules! You were able to build out our sample architecture using module documentation and taking advantage of features like standard interfaces and pre-defined defaults to simplify the development experience.

Note

This was a long exercise and mistakes can happen. If you’re getting errors or a resource is incomplete and you want to see the final main.tf, expand the following code block to see the full file.

➕ Expand Code
  1module "avm-res-resources-resourcegroup" {
  2  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
  3  version = "0.2.1"
  4
  5  name = "${var.name_prefix}-rg"
  6  location = var.location
  7  tags = var.tags
  8}
  9
 10module "avm-res-operationalinsights-workspace" {
 11  source  = "Azure/avm-res-operationalinsights-workspace/azurerm"
 12  version = "0.4.2"
 13
 14  enable_telemetry                          = true
 15  location                                  = module.avm-res-resources-resourcegroup.resource.location
 16  resource_group_name                       = module.avm-res-resources-resourcegroup.name
 17  name                                      = "${var.name_prefix}-law"
 18  log_analytics_workspace_retention_in_days = 30
 19  log_analytics_workspace_sku               = "PerGB2018"
 20}
 21
 22data "azapi_client_config" "this" {}
 23
 24resource "random_string" "name_suffix" {
 25  length  = 4
 26  special = false
 27  upper   = false
 28}
 29
 30module "avm-res-keyvault-vault" {
 31  source  = "Azure/avm-res-keyvault-vault/azurerm"
 32  version = "0.10.0"
 33
 34  enable_telemetry    = true
 35  location            = module.avm-res-resources-resourcegroup.resource.location
 36  resource_group_name = module.avm-res-resources-resourcegroup.name
 37  name                = "${var.name_prefix}-kv-${random_string.name_suffix.result}"
 38  tenant_id           = data.azapi_client_config.this.tenant_id
 39  network_acls        = null
 40
 41  diagnostic_settings = {
 42    to_la = {
 43      name                  = "${var.name_prefix}-kv-diags"
 44      workspace_resource_id = module.avm-res-operationalinsights-workspace.resource_id
 45    }
 46  }
 47
 48  role_assignments = {
 49    deployment_user_kv_admin = {
 50      role_definition_id_or_name = "Key Vault Administrator"
 51      principal_id               = data.azapi_client_config.this.object_id
 52    }
 53  }
 54}
 55
 56module "avm-res-network-natgateway" {
 57  source  = "Azure/avm-res-network-natgateway/azurerm"
 58  version = "0.2.1"
 59
 60  name                = "${var.name_prefix}-natgw"
 61  enable_telemetry    = true
 62  location            = module.avm-res-resources-resourcegroup.resource.location
 63  resource_group_name = module.avm-res-resources-resourcegroup.name
 64
 65  public_ips = {
 66    public_ip_1 = {
 67      name = "${var.name_prefix}-natgw-pip"
 68    }
 69  }
 70}
 71
 72module "avm-res-network-virtualnetwork" {
 73  source  = "Azure/avm-res-network-virtualnetwork/azurerm"
 74  version = "0.8.1"
 75
 76  resource_group_name = module.avm-res-resources-resourcegroup.name
 77  location            = module.avm-res-resources-resourcegroup.resource.location
 78  name                = "${var.name_prefix}-vnet"
 79
 80  address_space = [var.virtual_network_cidr]
 81
 82  subnets = {
 83    subnet0 = {
 84      name                            = "${var.name_prefix}-vm-subnet"
 85      default_outbound_access_enabled = false
 86      address_prefixes = [cidrsubnet(var.virtual_network_cidr, 1, 0)]
 87      nat_gateway = {
 88        id = module.avm-res-network-natgateway.resource_id
 89      }
 90      network_security_group = {
 91        id = module.avm-res-network-networksecuritygroup.resource_id
 92      }
 93    }
 94    bastion = {
 95      name                            = "AzureBastionSubnet"
 96      default_outbound_access_enabled = false
 97      address_prefixes = [cidrsubnet(var.virtual_network_cidr, 1, 1)]
 98    }
 99  }
100
101  diagnostic_settings = {
102    sendToLogAnalytics = {
103      name                           = "${var.name_prefix}-vnet-diagnostic"
104      workspace_resource_id          = module.avm-res-operationalinsights-workspace.resource_id
105      log_analytics_destination_type = "Dedicated"
106    }
107  }
108}
109
110module "avm-res-network-bastionhost" {
111  source  = "Azure/avm-res-network-bastionhost/azurerm"
112  version = "0.7.2"
113
114  name                = "${var.name_prefix}-bastion"
115  resource_group_name = module.avm-res-resources-resourcegroup.name
116  location            = module.avm-res-resources-resourcegroup.resource.location
117  ip_configuration = {
118    subnet_id = module.avm-res-network-virtualnetwork.subnets["bastion"].resource_id
119  }
120
121  diagnostic_settings = {
122    sendToLogAnalytics = {
123      name                           = "${var.name_prefix}-bastion-diagnostic"
124      workspace_resource_id          = module.avm-res-operationalinsights-workspace.resource_id
125      log_analytics_destination_type = "Dedicated"
126    }
127  }
128}
129
130module "avm-res-network-networksecuritygroup" {
131  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
132  version = "0.4.0"
133  resource_group_name = module.avm-res-resources-resourcegroup.name
134  name                = "${var.name_prefix}-vm-subnet-nsg"
135  location            = module.avm-res-resources-resourcegroup.resource.location
136
137  security_rules = {
138    "rule01" = {
139      name                       = "${var.name_prefix}-ssh"
140      access                     = "Allow"
141      destination_address_prefix = "*"
142      destination_port_ranges    = ["22"]
143      direction                  = "Inbound"
144      priority                   = 200
145      protocol                   = "Tcp"
146      source_address_prefix      = "*"
147      source_port_range          = "*"
148    }
149  }
150}
151
152module "avm-res-compute-virtualmachine" {
153  source  = "Azure/avm-res-compute-virtualmachine/azurerm"
154  version = "0.19.1"
155
156  enable_telemetry    = true
157  location            = module.avm-res-resources-resourcegroup.resource.location
158  resource_group_name = module.avm-res-resources-resourcegroup.name
159  name                = "${var.name_prefix}-vm"
160  os_type             = "Linux"
161  sku_size            = "Standard_D2s_v5"
162  zone                = 1
163
164  source_image_reference = {
165    publisher = "Canonical"
166    offer     = "0001-com-ubuntu-server-focal"
167    sku       = "20_04-lts-gen2"
168    version   = "latest"
169  }
170
171  network_interfaces = {
172    network_interface_1 = {
173      name = "${var.name_prefix}-nic-${random_string.name_suffix.result}"
174      ip_configurations = {
175        ip_configuration_1 = {
176          name                          = "${var.name_prefix}-ipconfig-${random_string.name_suffix.result}"
177          private_ip_subnet_resource_id = module.avm-res-network-virtualnetwork.subnets["subnet0"].resource_id
178        }
179      }
180    }
181  }
182
183  diagnostic_settings = {
184    sendToLogAnalytics = {
185      name                           = "${var.name_prefix}-vm-diagnostic"
186      workspace_resource_id          = module.avm-res-operationalinsights-workspace.resource_id
187      log_analytics_destination_type = "Dedicated"
188    }
189  }
190}

AVM modules provide several key advantages over writing raw Terraform templates:

  1. Simplified Resource Configuration: AVM modules handle much of the complex configuration work behind the scenes
  2. Built-in Recommended Practices: The modules implement many of Microsoft’s recommended practices by default
  3. Consistent Outputs: Each module exposes a consistent set of outputs that can be easily referenced
  4. Reduced Boilerplate Code: What would normally require hundreds of lines of Terraform code can be accomplished in a fraction of the space

As you continue your journey with Azure and AVM, remember that this approach can be applied to more complex architectures as well. The modular nature of AVM allows you to mix and match components to build solutions that meet your specific needs while adhering to Microsoft’s Well-Architected Framework.

By using AVM modules as building blocks, you can focus more on your solution architecture and less on the intricacies of individual resource configurations, ultimately leading to faster development cycles and more reliable deployments.

Additional exercises

For additional learning, it can be helpful to experiment with modifying this solution. Here are some ideas you can try if you have time and would like to experiment further.

  1. Use the managed_identities interface to add a system assigned managed identity to the virtual machine and give it Key Vault Administrator rights on the Key Vault.
  2. Use the tags interface to assign tags directly to one or more resources.
  3. Add an Azure Monitoring Agent extension to the virtual machine resource.
  4. Add additional inputs like VM sku to your module to make it more customizable. Be sure to update the code and tfvars files to match.

Clean up your environment

Once you have completed this set of exercises, it is a good idea to clean up your resources to avoid incurring costs for them. This can be done typing terraform destroy -var-file=development.tfvars and entering yes when prompted.

Solution Development

Considerations and steps of Solution Development

  • Decide on the IaC language (Bicep or Terraform)
  • Decide on the module sourcing method (public registry, private registry, inner-sourcing)
  • Decide on the orchestration method (template or pipeline)
  • Identify the resources needed for the solution (are they all available in AVM?)
  • Implement, validate, deploy, test the solution

Questions to cover on this page

  • Pick a realistically complex solution and demonstrate how to build it using AVM modules
  • Best practices for coding (link to official language specific guidance AND AVM specs where/if applicable)
  • Best practices for input and output parameters

Next steps

To be covered in separate, future articles.

To make this solution enterprise-ready, you need to consider the following:

  • Deploy with DevOps tools and practices (e.g., CI/CD in Azure DevOps, GitHub Actions, etc.)
  • Deploy into Azure Landing Zones (ALZ)
  • Make sure the solution follows the recommendations of the Well-Architected Framework (WAF) and it’s compliant with and integrates into your organization’s policies and standards, e.g.:
    • Security & Identity (e.g., RBAC, Entra ID, service principals, secrets management, MFA, etc.)
    • Networking (e.g., Azure Firewall, NSGs, etc.)
    • Monitoring (e.g., Azure Monitor, Log Analytics, etc.)
    • Cost management (e.g., Azure Cost Management, budgets, etc.)
    • Governance (e.g., Azure Policy, etc.)

Other recommendations

  • Don’t use latest, but a specific version of the module
  • Don’t expose secrets in output parameters/command line/logs/etc.
  • Don’t use hard-coded values, but use parameters and variables

AI-Assisted IaC Solution Development

For End Users

This section covers solution development scenarios and is primarily relevant for end users aiming to develop complex solutions based on AVM modules using AI. If you are a module owner looking for content on maintaining and developing AVM modules with AI assistance, see AI-Assisted Module Development.

This section explains concepts of developing IaC solution templates using Azure Verified Modules (AVM) with the assistance of AI tools such as GitHub Copilot and covers how AI-assisted development can accelerate the process of building and deploying Azure solutions using AVM modules.

This approach is referred to as AI-assisted IaC solution development rather than AI-driven or AI-led because humans remain fully in control of architectural decisions and solution design - AI simply handles the tedious, error-prone tasks of code generation and standards compliance, amplifying human ingenuity rather than replacing it.

AVM and GitHub Copilot

In the rapidly evolving landscape of AI, the convergence of Azure Verified Modules (AVM) and GitHub Copilot represents a transformative approach to building Azure solutions, addressing the fundamental challenges developers face: maintaining quality, consistency, and speed while navigating the complexity of modern cloud architectures.

How They Work Together

  1. AVM provides the foundational “knowledge base”: Comprehensive, standardized, continuously updated, pre-validated modules that embody Azure best practices, security standards, and architectural patterns. Each module undergoes rigorous testing and validation, ensuring reliability and compliance with Microsoft’s standards.
  2. GitHub Copilot brings AI-powered intelligence to the development workflow: AI-assisted code generation, module discovery, and real-time guidance based on AVM specifications with understanding both natural language intent and code context. Equipped with knowledge of AVM specifications and practices, Copilot serves as an expert guide that can:
    • Intelligently discover and recommend the appropriate AVM modules for your specific requirements
    • Generate compliant infrastructure code that adheres to AVM standards and Azure best practices
    • Accelerate development cycles by automating repetitive tasks and reducing manual lookups
    • Maintain consistency across your infrastructure codebase
    • Reduce errors by leveraging validated patterns and catching compliance issues early
  3. Developers focus on solution architecture: This approach enables teams to dedicate more time to designing solutions that meet business requirements, rather than working on repetitive tasks.

This synergy means developers can express their infrastructure needs in natural language, and Copilot translates these into production-ready IaC code using validated AVM modules, complete with appropriate configurations, security settings, and dependencies.

The Result

What once required hours of documentation review, module discovery, and manual code writing can now be accomplished in minutes. Infrastructure teams can iterate faster, maintain higher quality standards, and deliver Azure solutions with confidence, knowing they’re built on a foundation of verified, best-practice modules guided by AI technology.

The combination of human expertise, Azure Verified Modules, and AI assistance creates a new opportunity to transform how we build and deploy cloud solutions at scale.

Subsections of AI-Assisted IaC Solution Development

Spec Kit

Overview

Spec Kit is a reference implementation of Specification-Driven Development (SDD) principles, developed by members of the open-source community, including engineers from Microsoft and Anthropic. It provides a structured, AI-native workflow for translating project requirements into specifications, plans, executable tasks and implemented code - demonstrating how SDD concepts can be operationalized.

For detailed information (installation instructions, usage guidelines, etc.) visit the official repository: https://github.com/github/spec-kit

Example Scenario

In this chapter we’ll explore each step of the Spec Kit workflow in detail. We’ll use the following example to illustrate the process: using AVM modules, we need to develop a solution that will host a simple legacy application on a Windows virtual machine (VM). The solution must be secure and auditable. The VM must not be accessible from the internet and its logs should be easily accessible.

The Spec Kit Workflow

Spec Kit guides development teams through a systematic process that ensures specifications remain the single source of truth throughout the development lifecycle:


flowchart TB
    subgraph CAS["Constitution&nbsp;and&nbsp;Specification"]
        direction LR
        A["**Step 1**<br><code>/speckit.constitution</code><br>Establish project principles"] --> B["**Step 2**<br><code>/speckit.specify</code><br>Create baseline specification"]
        B --> C["**Step 3** <br><code>/speckit.clarify</code><br>(optional)<br>Ask structured questions"]
    end

    subgraph PAT["Implementation&nbsp;Plan&nbsp;and&nbsp;Tasks"]
        direction LR
        D["**Step 4** <br><code>/speckit.plan</code><br>Create implementation plan"] --> E["**Step 5** <br><code>/speckit.checklist</code><br>(optional)<br>Generate quality checklists"]
        E --> F["**Step 6** <br><code>/speckit.tasks</code><br>Generate actionable tasks"]
        F --> G["**Step 7** <br><code>/speckit.analyze</code><br>(optional)<br>Consistency & alignment report"]
    end

    subgraph IMP["Implementation"]
        H["**Step 8** <br><code>/speckit.implement</code><br>Execute<br>implementation"]
    end

    CAS --> PAT
    PAT --> IMP

    click A "#1-constitution"
    click B "#2-specify"
    click C "#3-clarify-optional"
    click D "#4-plan"
    click E "#5-checklist-optional"
    click F "#6-tasks"
    click G "#7-analyze-optional"
    click H "#8-implement"

    style C fill:#e1f5ff
    style E fill:#e1f5ff
    style G fill:#e1f5ff
    style CAS fill:#f5f5f5,stroke:#999999
    style PAT fill:#f5f5f5,stroke:#999999
    style IMP fill:#f5f5f5,stroke:#999999

Each command in the workflow works with a number of AI agents, including GitHub Copilot, enabling AI-assisted processing through the specification-to-implementation pipeline while maintaining human control over architectural decisions and project direction.

1. Constitution

The constitution document (constitution.md) establishes the governing principles, development guidelines and project-wide constraints that every later step (spec, plan, tasks, implementation) must follow. It acts as the “North Star” for the AI agent. It ensures that fundamental requirements are always met by encoding architectural, compliance, security, coding, and operational rules.

Spec Kit uses /speckit.constitution to generate the constitution.md file. The constitution can be evolved through iterating over the constitution.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.constitution.

The constitution typically includes:

  • Required engineering standards
  • Guardrails and constraints
  • Required / discouraged patterns
  • Organizational coding standards
  • Testing expectations
  • Cloud/platform governance
  • Naming conventions, tagging rules
  • Compliance/security requirements

Following our scenario, here are some examples for what the prompt used to generate the constitution should include:

  1. Architecture & Cloud Rules

    • Infrastructure must be declarative and written in Bicep only.
    • Must use Azure Verified Modules (AVM) for all supported resource types.
    • Deployment must follow a modular architecture using AVM best practices/standards.
  2. Security & Compliance

    • Enforce NSG rules, disable all incoming traffic from the internet, require JIT.
    • OS hardening baseline must be applied via VM extension or custom script.
    • All storage must have private endpoints and be encrypted using CMK if required.
  3. Ops & Reliability

    • Centralized logging via Azure Monitor / Log Analytics workspace.
    • Diagnostics extensions required for Windows Server.
  4. Naming & Tagging

    • All resources follow Microsoft Cloud Adoption Framework naming conventions.
    • Mandatory tags: owner, costCenter, env, application.
  5. Testing Expectations

    • Deployment must succeed in at least one Azure region with a Windows Server SKU supported by AVM (e.g., 2022 Datacenter).

2. Specify

The specification document (spec.md) is used to define the baseline specification for the product, focusing on “the what and the why” rather than the low-level technical requirements. It’s detached from the implementation, meaning, the same spec can be used even if the underlying technology changes.

The specification describes WHAT you want to build and WHY - not how. It is focused on user needs, functional requirements, constraints, and success criteria.

Spec Kit uses /speckit.specify to generate the spec.md file. Specifications can be evolved through iterating over the spec.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.specify and leveraging /speckit.clarify to review and challenge the specification.

The specification typically includes:

  • Feature description
  • User/problem statements
  • Functional and non-functional requirements
  • Scenarios & their constraints
  • Input/output expectations
  • Success criteria

Following our scenario, here are some examples for what the prompt used to generate the specification should include:

  1. What we are building

    • “A deployable IaC template, based on Azure Verified Modules, that provisions a secure single-VM environment suitable for hosting a legacy Windows Server-based line-of-business application.”
  2. Functional Requirements

    • Deploy all Azure resources through referencing the corresponding AVM module (if available).
    • Deploy required network components: VNet, Subnets, NSG, Bastion, optionally NAT gateway.
    • Install app binaries via Custom Script Extension.
    • Expose the application through an internal load balancer (if needed).
  3. Non-Functional Requirements

    • Must support repeatable deployments across environments (dev/test/prod).
    • Must comply with corporate security baselines.
  4. Constraints

    • Legacy application cannot be containerized.
    • Only one VM instance supported.
    • No direct internet access allowed.
  5. User Inputs

    • VM size, OS license type, admin credentials (via Key Vault), virtual network ranges.
  6. Success Criteria

    • VM deploys successfully and application runs.
    • Diagnostics logs are set to be collected.
    • Deployment is fully reproducible and parameterized.

3. Clarify (Optional)

The clarify step is used to identify missing information, ambiguity, contradictions, or incompleteness in the specification. It runs a structured Q&A loop where the AI agent asks questions derived from the spec and your constitution, ensuring high-quality requirements before planning. This step is crucial when the initial specification is incomplete or ambiguous - which is common for infra projects.

Spec Kit uses /speckit.clarify to generate adjust information captured in spec.md. The prompt doesn’t require any specific inputs as it analyzes the existing specification for gaps.

4. Plan

This step is where the technical requirements for the project are defined. Spec Kit consults the constitution to ensure that the non-negotiable principles are respected. The plan turns the specification into a technical architecture and concrete design. It defines how the system will be built - including technical stack and architecture choices. It also outlines the execution flow, primary dependencies, testing approaches, target platforms, and architecture principles.

Spec Kit uses /speckit.plan to generate the plan.md file. The plan can be evolved through iterating over the plan.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.plan, or leveraging /speckit.checklist to review/validate and challenge the plan.

The plan typically includes:

  • Selected technical stack & components
  • Architecture diagram or description
  • Component-level (resource-level) breakdown
  • API/data contracts (if any)
  • Integration points
  • Deployment structure
  • Region & SKU validation
  • Required research (limitations, assumptions)

Following our scenario, here are some examples for what the prompt used to generate the plan should include:

  1. Chosen Technologies

    • AVM Bicep or Terraform modules
  2. Architecture

    • Hub/spoke or simple VNet depending on environment.
    • Subnet for VM with NSG enforcing inbound/outbound rules.
    • Azure Bastion for private admin access.
    • VM uses Managed Identity for Key Vault + Storage access.
  3. Research (automatically generated)

    • Supported Windows Server SKUs in chosen region.
    • VM extension support for custom script on Windows.
    • Whether AVM module supports automatic patching, monitoring, identity binding.
  4. Data Inputs & Contracts

    • Parameters JSON structure for:
      • VM name, SKU
      • Admin username
      • Key Vault secret names
      • App package URI
  5. Project Structure

    • File and folder structure:
      main.bicep
      main.bicepparam
      readme.md
  6. Quickstart

    • How to implement/deploy: Steps to deploy via Azure CLI, Bicep CLI, PowerShell or GitHub Actions.

5. Checklist (Optional)

The checklist step ensures that all required criteria from the Constitution, Spec, and Plan are met or properly addressed before generating tasks or implementing.

Spec Kit uses /speckit.checklist to generate adjustments to the plan.md file. The prompt doesn’t require any specific inputs as it analyzes the existing plan for gaps.

6. Tasks

This step breaks the project work down into manageable and actionable chunks that the agent can tackle one by one. Tasks are the blueprint for coding/automation. For example, a plan can be broken down to individual phases and tasks, such as bootstrapping, test first (tests must fail before core implementation), core implementation, integration refinement, and polish/iterate.

Spec Kit uses /speckit.tasks to generate the tasks.md file. The prompt doesn’t require any specific inputs as it analyzes the existing plan to break it down into actionable tasks.

Tasks typically include:

  • Ordered, granular, developer-ready instructions, mapping to a single logical unit
  • Acceptance criteria per task
  • Notes for dependencies between tasks

Following our scenario, here are some examples for what the prompt used to generate the tasks should include:

  1. Solution Template Creation Tasks
    • Create base Bicep structure for the template.
    • Reference the AVM VNet module with parameterized address space.
    • Reference the AVM Subnet module and associate NSG.
    • Reference the AVM NSG module with rules (deny all inbound except required).
    • Reference the AVM VM module with parameters for:
      • VM size
      • OS SKU
      • Managed Identity
      • Boot diagnostics settings
    • Add VM Extension for custom script to install legacy application.
  2. Security Tasks
    • Integrate Key Vault references for secrets.
    • Apply security baseline hardening script.
  3. Observability Tasks
    • Add Log Analytics workspace and diagnostic settings.
  4. Documentation Tasks
    • Generate README with deployment instructions.
    • Generate sample parameter files for dev/test/prod.
  5. Testing Tasks
    • Write validation tests using What-If and PSRule for Azure.
    • Deploy to sandbox environment for verification.

7. Analyze (Optional)

The analyze step runs a final check for consistency, completeness, contradictions, and coverage across all generated artifacts - specification, plan, tasks - before implementation.

Spec Kit uses /speckit.analyze to generate an analysis report. The prompt doesn’t require any specific inputs as it analyzes the existing spec, plan and tasks to produce the report.

8. Implement

The implementation step is the final stage where Spec Kit executes all tasks, building the actual software solution by generating real code, scripts, documentation, tests, and supporting assets. The implementation strictly adheres to the guidelines and requirements set forth in the earlier stages (constitution, specification, plan, and tasks) to ensure consistency, quality, and alignment with the project’s goals.

Spec Kit uses /speckit.implement to implement all defined tasks by generating all code. The prompt doesn’t require any specific inputs as Spec Kit analyzes the existing plan and tasks to perform this step.

Following our scenario, here are some examples for what the output of the implementation step should include. Note that GitHub Copilot can actually deploy the generated main.bicep file to Azure, and validate whether the deployment was successful.

  1. Generated Code and Files

    • Bicep template (e.g., main.bicep)
    • Parameter file(s) (e.g., main.bicepparam)
    • Deployment scripts (if required)
    • Any additionally required configuration files
  2. Test Artifacts

    • Unit tests, integration tests, IaC linting results
    • What-If validation JSON
    • PSRule for Azure test configs
    • Regression tests (if in constitution)
  3. Documentation

    • README files
    • Architecture diagrams
    • Quick-start and deployment instructions
    • Known limitations and future improvements
  4. Tooling Outputs

    • Auto-created scaffolding (directories and files)
    • Code generation aligned to constraints

Summary

Spec Kit transforms the traditionally ambiguous process of translating requirements into code by establishing a structured, AI-assisted workflow. By following the steps - from Constitution through Implementation - you create a clear chain of traceability where every line of code can be linked back to a specific requirement.

Key takeaways:

  • Start with principles: The constitution ensures that non-negotiable constraints (security, compliance, architecture patterns) are embedded from the beginning.
  • Iterate on specifications: Use the clarify step to challenge assumptions and refine requirements before committing to implementation.
  • Plan before coding: A well-defined plan prevents costly rework and ensures alignment with AVM best practices.
  • Break down complexity: Tasks make large projects manageable and provide clear acceptance criteria for each unit of work.
  • Validate continuously: Use analyze and checklist steps to catch inconsistencies early in the process.

When combined with Azure Verified Modules, Spec Kit helps ensure that your infrastructure-as-code solutions are not only functional but also secure, maintainable, and aligned with Azure best practices.

For hands-on examples of using Spec Kit with AVM, see the AVM Example guide.

Subsections of Spec Kit

AVM Example for Spec Kit

Prerequisites

​

You will need the following tools and components to complete this guide:

You will need the following tools and components to complete this guide:

Before you begin, make sure you have these tools installed in your development environment!

Solution Architecture

Before we begin coding, it is important to have details about what the infrastructure architecture will include. For our example, using AVM modules, we will be building a solution that will host a legacy business application running as a single Windows Server 2016 virtual machine (VM) with at least 2 CPU cores, 8GB RAM, Standard HDD OS disk, and a 500GB data disk.

The VM is accessible via Azure Bastion using secure RDP access (no public IP exposure). The solution needs an Azure Storage Account with an HDD-backed file share connected via private endpoint, and an Azure Key Vault to securely store the VM administrator password generated at deployment time. The VM must not be accessible from the internet, and all diagnostic logs will be captured in a Log Analytics workspace with critical alerts configured for VM availability, disk utilization, and Key Vault access failures.

Azure VM Solution Architecture

Bootstrapping

Tip

On a Windows PC, to get the uv package manager CLI tool required for locally installing the Specify CLI, run the following command:

winget install astral-sh.uv
  1. To install Spec Kit locally, run the following command in an elevated terminal:

    uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
  2. Create a new directory for your Spec Kit project and navigate into it - this folder ideally already exists as a git repository:

    mkdir avm-workload
    cd avm-workload
  3. Set your default branch to main (initializing Spec Kit will configure this working folder as a git repository if it isn’t one already):

    git config --global init.defaultBranch main
  4. Initialize a new Spec Kit project:

    specify init .

    If the folder has already been set up as a repository, the specify tool will warn you that the folder is not empty. Just confirm that you want to proceed.

    As we haven’t defined the AI assistant in our init command, specify will prompt us to choose one. Select copilot (GitHub Copilot) from the list. Similarly, as we haven’t defined the script type, specify will prompt us to choose one. Select ps (PowerShell) from the list.

    Alternatively, you can provide these parameters directly in the init command, like so:

    specify init . --ai copilot --script ps
    ➕ Expand to see the results

    Note: As Spec Kit evolves, the output may change over time. This example is meant to give you an idea of what the user interface looks like.

    Click through the tabs to see the details!

    ​

    You should see something like this:
    Specify Bootstrap

    In your project folder, you should now see the following files and folders created by the specify tool:

    <!-- markdownlint-disable -->
    avm-workload
    │
    ├───.github
    │   ├───agents
    │   │       speckit.analyze.agent.md
    │   │       speckit.checklist.agent.md
    │   │       speckit.clarify.agent.md
    │   │       speckit.constitution.agent.md
    │   │       speckit.implement.agent.md
    │   │       speckit.plan.agent.md
    │   │       speckit.specify.agent.md
    │   │       speckit.tasks.agent.md
    │   │       speckit.taskstoissues.agent.md
    │   │
    │   └───prompts
    │           speckit.analyze.prompt.md
    │           speckit.checklist.prompt.md
    │           speckit.clarify.prompt.md
    │           speckit.constitution.prompt.md
    │           speckit.implement.prompt.md
    │           speckit.plan.prompt.md
    │           speckit.specify.prompt.md
    │           speckit.tasks.prompt.md
    │           speckit.taskstoissues.prompt.md
    │
    ├───.specify
    │   ├───memory
    │   │       constitution.md
    │   │
    │   ├───scripts
    │   │   └───powershell
    │   │           check-prerequisites.ps1
    │   │           common.ps1
    │   │           create-new-feature.ps1
    │   │           setup-plan.ps1
    │   │           update-agent-context.ps1
    │   │
    │   └───templates
    │           agent-file-template.md
    │           checklist-template.md
    │           plan-template.md
    │           spec-template.md
    │           tasks-template.md
    │
    ├───.vscode
    │       settings.json
    │
    └───infra
        ├───modules
        │   ├───compute
        │   ├───monitoring
        │   ├───networking
        │   ├───security
        │   ├───shared
        │   └───storage
        └───tests
            └───compliance
  5. Spec Kit automatically commits this step to the git repository with following comment: Initial commit from Specify template.

  6. The rest of the steps will be performed using GitHub Copilot Chat in VS Code: Start your VS Code environment, open or add the newly created folder to your workspace, and navigate to GitHub Copilot Chat using the dialog icon on the top of the window or by hitting CTRL+ALT+I.

Making it real

Spec Kit follows a structured workflow that guides you through each phase of solution development, from establishing foundational principles to implementing the final code. To learn more about Spec Kit, see the Spec Kit overview section.

flowchart LR
    A[1\. Constitution] --> B[2\. Specify]
    B --> C["3\. Clarify<br>(Optional)"]
    C --> D[4\. Plan]
    D --> E["5\. Checklist<br>(Optional)"]
    E --> F[6\. Tasks]
    F --> G["7\. Analyze<br>(Optional)"]
    G --> H[8\. Implement]

    click A "#1-constitution"
    click B "#2-specify"
    click C "#3-clarify-optional"
    click D "#4-plan"
    click E "#5checklist-optional"
    click F "#6-tasks"
    click G "#7-analyze-optional"
    click H "#8-implement"

    style C fill:#e1f5ff
    style E fill:#e1f5ff
    style G fill:#e1f5ff

To implement our example solution using AVM modules, we will walk through each of these steps in detail.

Each of the below steps will typically take 3-8 minutes to complete, depending on the complexity of your specification, the performance of the AI model you are using, and your reaction time to answer any outstanding questions and review and approve the generated content.

Choose your LLM

Changing the LLM does make a difference. We highly encourage you test different models to see which one works best for your needs.

Note: At the time of writing this article, we tested our prompts with Claude Sonnet 4.5 for Bicep and Claude Sonnet 4.6 for Terraform. In our experience, using Claude Opus 4.6 for Terraform typically leads to better, more accurate results, but also costs more tokens.

Know before you go
  1. As Spec Kit uses a set of built-in and system tools and scripts, you will need to approve the execution of each of these steps. Make sure you understand the impact of these commands before approving and proceeding!
    Here’s an example:
Specify Approve Scripts
  1. In some cases, your account might exceed GitHub’s API rate limits when using GitHub Copilot with Spec Kit. If that happens, please wait for a while (usually an hour or so) and try again.

1. Constitution

Spec Kit uses /speckit.constitution to generate the constitution.md file. The constitution can be evolved through iterating over the constitution.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.constitution.

Info

To learn more about what the constitution should include, see the Constitution chapter in the Spec Kit article.

➕ Before running /speckit.constitution (Expand)
​

Notice what the constitution.md file looks like before running the related prompt. It is just a template with placeholders, defining the structure:

Note: As Spec Kit evolves, the content of this template may change over time. This example is meant to give you an idea of what the starting point looks like.

<!-- markdownlint-disable -->
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->

## Core Principles

### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->

### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->

### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->

### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->

### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->

## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->

[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->

## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->

[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->

## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->

[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->

**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
  1. Run the following prompt to generate the constitution for our example:
​
/speckit.constitution Fill the constitution with the typical requirements of a legacy Azure workload (needed to be retained for compliance reasons; no high-availability requirements; no disaster recovery requirements; no scalability requirements), defined as infrastructure-as-code, in Bicep language, built only with Azure Verified Modules (AVM). Always try to implement every feature with Bicep first (using Infra-as-code), and only use custom scripts when it's not possible otherwise. Follow IaC best practices: define everything in a single template, and let ARM manage dependencies and the order of deployment for each Azure resource.

Security and reliability best practices must be followed under all circumstances.

The naming convention is to use just enough random characters to make the name unique and have the Azure resource type reflected in the name. Resource type specific character and length limitations must be respected.

Before running a deployment, always run a validation.

Deploy everything to the US West 3 datacenter region.
➕ Expand to see the results

The most important artifact created in this phase is the constitution.md file. Based on your inputs, additional files may also be created or updated, such as spec-template.md , plan-template.md, and tasks-template.md. These would further improve the quality of the generated artifacts in later phases.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

Once Copilot finished running the prompt, you should see something like this in the Copilot chat area:

Constitution Chat Results

In the constitution.md file, notice, how changes implemented by Copilot are not final yet, until you review, and approve them by clicking on the “Keep” button. You can either do this on a paragraph-by-paragraph basis, or for the entire document at once by clicking on the “Keep” button in the main Copilot chat window.:

Constitution Before Approvals
<!-- markdownlint-disable -->
<!--
SYNC IMPACT REPORT
==================
Version Change: N/A → 1.0.0 (Initial constitution ratification)

Modified Principles:
- N/A (Initial version)

Added Sections:
- Core Principles (6 principles defined)
- Infrastructure Standards
- Security & Compliance Requirements
- Development Workflow
- Governance

Removed Sections:
- N/A (Initial version)

Templates Requiring Updates:
- ✅ plan-template.md: Constitution Check section aligns with principles
- ✅ spec-template.md: Requirements structure aligns with security principles
- ✅ tasks-template.md: Task categorization aligns with validation-first workflow

Follow-up TODOs:
- None (all placeholders filled)

Change Rationale:
- MAJOR version (1.0.0) because this is the initial constitution establishing governance framework
- Principles focused on legacy workload characteristics: compliance retention, IaC-first with Bicep, AVM-only modules, validation-before-deployment
-->

# Legacy Azure Workload Constitution

## Core Principles

### I. Infrastructure-as-Code First (NON-NEGOTIABLE)
All Azure resources MUST be defined in Bicep templates. Manual Azure Portal configurations are STRICTLY PROHIBITED.

**Rationale**: Ensures compliance auditability, repeatability, and version control for regulatory requirements. Manual changes create configuration drift that violates compliance mandates.

**Rules**:
- Every feature starts with Bicep code defining the infrastructure
- Custom scripts are permitted ONLY when Bicep/ARM capabilities are insufficient
- All infrastructure changes MUST go through version control
- Single-template approach: define everything in one main.bicep, let ARM handle dependencies

### II. AVM-Only Modules
All Bicep infrastructure MUST use Azure Verified Modules (AVM). Direct resource declarations are permitted only when no AVM module exists.

**Rationale**: AVM modules are officially maintained, follow security best practices, and are compliance-tested by Microsoft.

**Rules**:
- Search for AVM module first (using `#list_avm_metadata` tool)
- Use latest stable version of AVM modules
- Document justification when direct resource declaration is necessary
- Follow AVM module documentation for parameter configuration

### III. Validation Before Deployment (NON-NEGOTIABLE)
Every deployment MUST be preceded by ARM validation. Deployments without prior validation are STRICTLY PROHIBITED.

**Rationale**: Prevents configuration errors that could impact compliance-required systems. Validation catches issues before they affect production.

**Rules**:
- Run `az deployment group validate` before every deployment
- Run `az deployment group what-if` to preview changes
- Document validation results in deployment logs
- Address all validation errors before proceeding

### IV. Security & Reliability First
Security and reliability best practices MUST be followed under all circumstances, even for legacy workloads.

**Rationale**: Compliance requirements mandate security controls regardless of workload age. Legacy status does not exempt from security obligations.

**Rules**:
- Enable Azure Monitor and diagnostic logs for all resources
- Apply network security groups and private endpoints where applicable
- Use managed identities instead of connection strings/keys
- Follow principle of least privilege for all access
- Enable Azure Security Center recommendations

### V. Minimal Naming with Type Identification
Resource names MUST be concise: minimal random characters for uniqueness + resource type identifier.

**Rationale**: Improves resource identification while respecting Azure naming limitations. Avoids verbose names that exceed character limits.

**Rules**:
- Format: `{resourceType}-{purpose}-{randomSuffix}`
- Example: `st-legacyvm-k7m3p` for storage account
- Respect Azure resource-specific length limits (e.g., storage: 24 chars, lowercase/numbers only)
- Random suffix: 4-6 alphanumeric characters
- Document naming pattern in infrastructure documentation

### VI. Region Standardization
All resources MUST deploy to US West 3 (westus3) region unless technically impossible.

**Rationale**: Centralizes resources for simplified management and cost tracking. Reduces complexity for legacy workloads with no multi-region requirements.

**Rules**:
- Default region parameter: `westus3`
- Document exceptions with technical justification
- Global resources (e.g., Azure Front Door) exempted by nature

## Infrastructure Standards

### Bicep Template Requirements
- Single main.bicep file as deployment entry point
- Use main.bicepparam for environment-specific parameters
- Leverage ARM dependency management (avoid explicit dependsOn unless necessary)
- Include detailed parameter descriptions and constraints
- Use Bicep decorators for validation (`@minLength`, `@maxLength`, `@allowed`)

### Module Management
- Reference AVM modules via Bicep Registry (br/public:avm/...)
- Pin to specific module versions (never use 'latest')
- Document module selection rationale in comments
- Review AVM module documentation for breaking changes during updates

### Documentation Requirements
- Maintain README.md with deployment instructions
- Document all parameters in main.bicepparam
- Include architecture diagram showing resource relationships
- Record compliance justifications for resource configurations

## Security & Compliance Requirements

### Mandatory Controls
- **Logging**: Enable diagnostic settings for all resources supporting it
- **Access Control**: Use Azure RBAC, no shared keys in parameters
- **Network Security**: Apply NSGs to subnet/NIC resources
- **Encryption**: Use Azure-managed encryption (minimum); customer-managed keys where compliance requires
- **Secrets Management**: Store sensitive values in Azure Key Vault, reference via Bicep getSecret()

### Compliance Documentation
- Tag all resources with compliance identifiers (e.g., `compliance: "legacy-retention"`)
- Document retention policies for data resources
- Record security exceptions with business justification
- Maintain audit trail of all infrastructure changes

### Prohibited Practices
- Hardcoded secrets or connection strings in Bicep files
- Public IP addresses without business justification
- Unrestricted network access (0.0.0.0/0 rules)
- Disabled diagnostic logging

## Development Workflow

### Pre-Deployment Phase
1. Research and select appropriate AVM modules
2. Draft Bicep templates with parameter documentation
3. Run local Bicep linting (`bicep build`)
4. Commit code to version control

### Validation Phase (MANDATORY GATE)
1. Run `az deployment group validate` and resolve all errors
2. Run `az deployment group what-if` and review changes
3. Document validation results
4. Obtain approval for resource changes (if required by organization)

### Deployment Phase
1. Deploy using validated parameters
2. Monitor deployment progress
3. Verify resource creation via Azure Portal/CLI
4. Test resource functionality
5. Document deployment outcomes

### Post-Deployment Phase
1. Verify diagnostic settings are active
2. Confirm tags applied correctly
3. Review security recommendations in Azure Security Center
4. Update documentation with deployed resource details

## Governance

This constitution supersedes all other development practices and guidelines. All infrastructure changes MUST comply with these principles.

### Amendment Process
1. Propose amendment with business/technical justification
2. Document impact on existing infrastructure
3. Update constitution with version increment following semantic versioning
4. Update all dependent templates and documentation
5. Communicate changes to all stakeholders

### Versioning Policy
- **MAJOR**: Breaking changes to core principles (e.g., removing AVM-only requirement)
- **MINOR**: New principle added or existing principle materially expanded
- **PATCH**: Clarifications, wording improvements, non-semantic fixes

### Compliance Review
All pull requests MUST verify compliance with this constitution. Constitution violations require explicit justification and approval exception.

Complexity that deviates from simplicity principles MUST be documented and justified with business or technical rationale.

**Version**: 1.0.0 | **Ratified**: 2026-01-27 | **Last Amended**: 2026-01-27
/speckit.constitution Fill the constitution with the typical requirements of a legacy Azure workload (needed to be retained for compliance reasons; no high-availability requirements; no disaster recovery requirements; no scalability requirements), defined as infrastructure-as-code, in Terraform language, built only with Azure Verified Modules (AVM). Always use Terraform, and never use custom scripts. Security and reliability best practices must be followed under all circumstances. Before running a deployment, always run a validation. Deploy everything to the US West 3 datacenter region.
➕ Expand to see the results

The most important artifact created in this phase is the constitution.md file. Based on your inputs, additional files may also be created or updated, such as spec-template.md , plan-template.md, and tasks-template.md. These would further improve the quality of the generated artifacts in later phases.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

Once Copilot finished running the prompt, you should see something like this in the Copilot chat area:

Constitution Chat Results

In the constitution.md file, notice, how changes implemented by Copilot are not final yet, until you review, and approve them by clicking on the “Keep” button. You can either do this on a paragraph-by-paragraph basis, or for the entire document at once by clicking on the “Keep” button in the main Copilot chat window.:

Constitution Before Approvals
<!-- markdownlint-disable -->
<!--
Sync Impact Report - Version 1.0.0 (Initial Release)
Version: N/A → 1.0.0 (MAJOR - Initial constitution establishment)
Change Type: Initial Release

Principles Defined:
  ✅ I. Terraform-First Infrastructure
  ✅ II. AVM-Only Modules (Terraform Registry)
  ✅ III. Security & Reliability (NON-NEGOTIABLE)
  ✅ IV. Single-Template Pattern (Terraform root module)
  ✅ V. Validation-First Deployment (terraform validate + plan)

Sections Added:
  ✅ Deployment Standards
  ✅ Project Constraints
  ✅ Naming Convention

Templates Status:
  ⚠ plan-template.md - Update Constitution Check for Terraform workflow (init → validate → plan → apply)
  ⚠ spec-template.md - Update infrastructure requirements for .tf file patterns and state management
  ⚠ tasks-template.md - Update task patterns for Terraform validation, AVM module sourcing, and security scanning

Follow-up Actions:
  - Update plan-template.md Technical Context to include Terraform version and required providers
  - Ensure spec-template.md reflects Terraform state management and backend configuration requirements
  - Update tasks-template.md to include terraform init, validate, fmt, plan task patterns
  - Add Terraform-specific linting (tflint, tfsec, checkov) to setup phase tasks
  - Document Terraform backend configuration (Azure Storage for remote state) in project README
  - Add .terraform, .tfstate, .tfvars patterns to .gitignore
-->

# Azure Verified Modules Legacy Workload Constitution

## Core Principles

### I. Terraform-First Infrastructure
Every Azure resource MUST be defined as Infrastructure-as-Code in Terraform configuration files before any alternative approach is considered. Custom scripts (PowerShell, Azure CLI, Bash) are permitted ONLY when Terraform providers cannot accomplish the requirement.

**Rationale**: Declarative infrastructure ensures repeatability, version control, audit trails, and compliance documentation. Terraform's state management provides idempotent operations and built-in dependency resolution through resource graph analysis.

**Requirements**:
- All Azure resources declared in Terraform `.tf` files in project root or modules
- Single root module paradigm - let Terraform manage orchestration and dependencies via implicit resource references
- Custom scripts require explicit justification documenting why neither an AVM module nor AzAPI can solve the need; AzureRM is not a fallback
- All infrastructure changes tracked in version control
- Terraform state stored remotely in Azure Storage Account with state locking enabled (blob container + lease)
- Use Terraform workspaces or separate state files for environment separation (dev, prod)
- `.terraform/`, `*.tfstate`, `*.tfstate.backup`, `*.tfvars` (except example files) excluded from version control

### II. AVM-Only Modules
All Terraform modules MUST be sourced exclusively from Azure Verified Modules (AVM) for Terraform. No custom or third-party Terraform modules are permitted unless an AVM module does not exist for the required resource type.

**Rationale**: AVM Terraform modules are Microsoft-maintained, tested against Azure best practices, include security hardening, follow consistent interfaces, and receive ongoing updates for provider changes and new Azure features.

**Requirements**:
- Use AVM resource modules (`Azure/avm-res-*`) from Terraform Registry for all supported Azure resources
- Use AVM pattern modules (`Azure/avm-ptn-*`) from Terraform Registry for multi-resource patterns
- Reference modules from official Terraform Registry source: `registry.terraform.io/Azure/avm-*`
- Pin module versions explicitly using pessimistic constraint (e.g., `version = "~> 0.1.0"`) - no floating latest versions
- If AVM module unavailable, document gap in ADR (Architecture Decision Record) and follow AVM authoring standards for local module
- Review and update AVM module versions quarterly minimum - document breaking changes in release notes review

**Example AVM Module Reference**:
```hcl
module "storage_account" {
  source  = "Azure/avm-res-storage-storageaccount/azurerm"
  version = "~> 0.1.0"

  # Module inputs per AVM interface
}
```

### III. Security & Reliability (NON-NEGOTIABLE)
Security and reliability best practices MUST be followed under all circumstances. This principle supersedes convenience, development velocity, and cost optimization.

**Rationale**: Legacy workloads retained for compliance reasons carry regulatory and legal obligations. Security breaches or reliability failures create compliance violations with potential legal ramifications.

**Requirements**:
- Managed identities required - no service principal credentials in Terraform code or variable files
- All secrets stored in Azure Key Vault - no plaintext secrets in `.tf`, `.tfvars`, or state files
- Use `sensitive = true` attribute for all secret outputs and variables
- Network security groups (NSGs) with explicit deny-by-default rules
- Azure Policy compliance validated before deployment (`terraform plan` must show policy compliance)
- Diagnostic settings and logging enabled on all supported resources (Activity Logs, Resource Logs)
- Resource locks (`CanNotDelete`) applied to prevent accidental deletion of compliance-critical resources
- Encryption at rest enabled (Microsoft-managed or customer-managed keys as appropriate)
- TLS 1.2+ required for all network communication (enforce via Azure Policy or resource properties)
- Principle of least privilege for all RBAC assignments (use built-in roles, no custom roles without justification)
- Static security analysis with `tfsec` or `checkov` in CI/CD pipeline - HIGH/CRITICAL findings block merge
- No hardcoded resource IDs or subscription IDs - use `data` sources or variables

### IV. Single-Template Pattern
All infrastructure for the workload MUST be defined in a single Terraform root module with Terraform managing dependencies and deployment order. Multi-stage deployments are permitted only when Terraform constraints (circular dependencies, provider limitations) make single-root infeasible.

**Rationale**: Single root module ensures atomic deployment, eliminates manual orchestration errors, simplifies rollback, and provides complete infrastructure visibility in one artifact. Terraform's dependency graph automatically determines execution order.

**Requirements**:
- One root module with `main.tf`, `variables.tf`, `outputs.tf`, and `versions.tf` (or combined files by preference)
- Use `depends_on` sparingly - rely on Terraform implicit dependency resolution via resource attribute references
- Child module composition for organizational clarity - all child modules instantiated in root module
- Separate `.tfvars` files for environment-specific values (e.g., `dev.tfvars`, `prod.tfvars`)
- If multi-stage required, document Terraform limitation necessitating split (rare - most circular dependencies solvable with proper design)
- No imperative orchestration scripts chaining multiple `terraform apply` commands

**Example Root Module Structure**:
```
terraform/
├── main.tf           # Primary resource declarations and module calls
├── variables.tf      # Input variable definitions
├── outputs.tf        # Output value definitions
├── versions.tf       # Terraform and provider version constraints
├── backend.tf        # Remote state backend configuration
├── dev.tfvars        # Development environment values
├── prod.tfvars       # Production environment values
└── modules/          # Local child modules (only if AVM unavailable)
    └── custom/
```

### V. Validation-First Deployment
Every deployment MUST execute Terraform validation (`terraform validate`) and plan review (`terraform plan`) before actual apply. Deployments without successful validation and plan approval are prohibited.

**Rationale**: Validation catches syntax errors, type mismatches, and configuration issues. Plan preview catches logical errors, permission issues, policy violations, and unintended changes, preventing destructive actions and partial deployments.

**Requirements**:
- `terraform init` executed to initialize providers and backend
- `terraform fmt -check` executed to enforce code formatting (must pass before deployment)
- `terraform validate` executed and must return success before plan
- `terraform plan -out=plan.tfplan` executed for every deployment - output must be reviewed and approved
- Plan shows no unexpected resource deletions or replacements (unless explicitly intended and documented)
- Validation failures or unexpected plan changes block deployment pipeline - no manual override without incident review
- Plan file (`plan.tfplan`) stored as artifact in CI/CD for audit trail (encrypted if sensitive data present)
- Apply MUST use plan file: `terraform apply plan.tfplan` (no ad-hoc apply without plan)
- Plan diff documented in deployment logs for compliance audit trail

**Deployment Workflow**:
1. `terraform init -backend-config=backend-prod.hcl`
2. `terraform fmt -check -recursive`
3. `terraform validate`
4. `terraform plan -var-file=prod.tfvars -out=plan.tfplan`
5. **GATE**: Human review and approval of plan output
6. `terraform apply plan.tfplan`

## Deployment Standards

### Region & Availability
- **Target Region**: US West 3 (`westus3`) for all resources
- **High Availability**: Not required (legacy workload, compliance retention only)
- **Disaster Recovery**: Not required
- **Scalability**: Not required (fixed capacity sufficient)

**Rationale**: This is a legacy workload retained for compliance and legal record-keeping. Active user workloads have migrated to modern platforms. Fixed-region, single-instance deployments are appropriate and cost-effective.

**Terraform Implementation**:
- Use `location = "westus3"` for all resources (or variable `var.location` with default `"westus3"`)
- No zone redundancy, geo-replication, or auto-scaling configurations
- Accept default SKUs optimized for cost over high availability (e.g., Standard vs Premium)

### Naming Convention
Resource names MUST follow this pattern: `<resourceTypeAbbreviation>-<workloadName>-<randomSuffix>`

**Requirements**:
- Resource type abbreviation per [Azure naming best practices](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations) (e.g., `st` for Storage Account, `kv` for Key Vault, `vm` for Virtual Machine)
- Workload name: `avmlegacy` (or feature-specific descriptor)
- Random suffix: minimum characters needed for global uniqueness (e.g., 6-character alphanumeric generated via `random_string` resource)
- Respect Azure resource type character limits and restrictions:
  - Storage Account: 24 chars max, lowercase alphanumeric only, globally unique
  - Key Vault: 3-24 chars, alphanumeric and hyphens, globally unique
  - Resource Group: 1-90 chars, alphanumeric, underscores, hyphens, periods
- Use Terraform `random_string` or `random_id` resource to generate suffix consistently across deployments
- Document abbreviations in README or add comments in code

**Terraform Implementation Example**:
```hcl
resource "random_string" "unique_suffix" {
  length  = 6
  special = false
  upper   = false
}

locals {
  workload_name = "avmlegacy"
  location_abbr = "wus3"  # westus3 abbreviation

  # Resource names following convention
  storage_account_name = "st${local.workload_name}${random_string.unique_suffix.result}"  # max 24 chars
  key_vault_name       = "kv-${local.workload_name}-${random_string.unique_suffix.result}"
  resource_group_name  = "rg-${local.workload_name}-${local.location_abbr}"
}

module "resource_group" {
  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
  version = "~> 0.2"

  name     = local.resource_group_name
  location = "westus3"
}
```

**Examples**:
- `stavmlegacy8k3m9x` (Storage Account - 18 chars)
- `kv-avmlegacy-8k3m9x` (Key Vault - 20 chars)
- `rg-avmlegacy-wus3` (Resource Group)
- `vm-avmlegacy-001` (Virtual Machine - numeric suffix for multiple instances)

## Project Constraints

### Legacy Workload Context
This infrastructure supports a **legacy Azure workload** retained exclusively for compliance, regulatory, and legal record-keeping purposes. Active business operations have migrated off this system.

**Implications**:
- Cost optimization prioritized (single instances, no redundancy beyond Azure platform defaults)
- Change frequency low (quarterly patches and security updates only)
- User activity minimal (compliance audits, occasional data retrieval by legal/audit teams)
- Retention period defined by legal/compliance requirements (document separately in project README or ADR)
- Decommissioning planned when retention period expires (add sunset date if known)

**Terraform Impact**:
- Use smaller SKUs and tiers (Standard vs Premium) where compliance allows
- No auto-scaling configurations needed
- Simplified networking (single VNet, minimal subnets)
- Backup retention aligned with compliance requirements (not business continuity requirements)

### Non-Functional Requirements
- **Performance**: Adequate for infrequent access (no SLA requirements, no performance testing needed)
- **Availability**: Standard Azure platform availability (no custom HA configurations, 99.9% acceptable)
- **Capacity**: Fixed sizing (no auto-scaling, no capacity planning for growth)
- **Compliance**: MUST maintain audit logs (Azure Monitor Logs minimum 90 days), access controls (RBAC), data integrity (checksums, immutability where required)
- **Cost**: Target <$X/month (specify budget if known) - optimize for minimal operational cost
- **Maintenance Window**: Changes allowed during business hours (no 24/7 operations requirement)

## Governance

This constitution is the ultimate authority for all infrastructure decisions, architecture choices, and development practices for this workload. All team members, code reviews, and deployment pipelines MUST verify compliance.

### Amendment Process
1. Proposed changes documented with rationale and impact analysis (create ADR in `docs/decisions/` if significant)
2. Review by infrastructure lead and compliance officer
3. Approval required before amendment merge
4. Version incremented per semantic versioning:
  - **MAJOR**: Principle removal, redefinition, or backward-incompatible governance changes (e.g., switching IaC tools)
  - **MINOR**: New principle added or materially expanded guidance (e.g., adding new security requirement)
  - **PATCH**: Clarifications, corrections, non-semantic improvements (e.g., fixing typos, adding examples)
5. Migration plan required for MAJOR/MINOR changes affecting existing infrastructure (document in amendment PR)
6. All dependent templates and documentation updated atomically with constitution

### Compliance Review
- All pull requests MUST include constitution compliance checklist (see `.github/PULL_REQUEST_TEMPLATE.md`)
- Deployment pipelines MUST validate against principles where automatable:
  - Terraform fmt check (Principle I)
  - AVM module source validation (Principle II)
  - tfsec/checkov security scan (Principle III)
  - Plan approval gate (Principle V)
- Quarterly compliance audit against all principles with findings documented in team wiki/issue tracker
- Violations require remediation plan within 30 days or justified exception with expiration date (document in issue)

### Compliance Checklist (for PRs):
- [ ] All resources defined in Terraform (Principle I)
- [ ] All modules sourced from AVM Terraform Registry (Principle II)
- [ ] Security requirements met: managed identities, Key Vault, NSGs, logging (Principle III)
- [ ] Single root module pattern followed (Principle IV)
- [ ] Deployment includes terraform validate and plan review (Principle V)
- [ ] Naming convention followed (Deployment Standards)
- [ ] Resources deployed to westus3 region (Deployment Standards)
- [ ] No high-availability or disaster recovery features added unnecessarily (Project Constraints)

### Runtime Guidance
For day-to-day development guidance, coding standards, and tooling setup, refer to:
- `.specify/templates/` for specification and planning workflows
- Project `README.md` for Terraform setup, backend configuration, and deployment instructions
- `docs/` directory for additional Terraform patterns, troubleshooting, and Azure-specific guidance

**Version**: 1.0.0 | **Ratified**: 2026-02-18 | **Last Amended**: 2026-02-18
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture the new constitution of your project, with a comment of something like Constitution added.

2. Specify

Spec Kit uses /speckit.specify to generate the spec.md file. Specifications can be evolved through iterating over the spec.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.specify and leveraging /speckit.clarify to review and challenge the specification.

Info

To learn more about what the specification should include, see the Specification chapter in the Spec Kit article.

  1. Run the following prompt to generate the specification for our example:
​
/speckit.specify Create specification, called "01-my-legacy-workload" for a legacy business application, running as a single virtual machine connected to a virtual network. The VM must run Windows Server 2016, needs to have at least 2 CPU cores, 8 GB of RAM, a standard HDD, and a 500 GB HDD-based data disk attached. It must be remotely accessible via a bastion host and needs to have access to an HDD-backed file share in a storage account connected via a private endpoint. The VM must access the internet via a NAT gateway. Network Security Groups (NSGs) must be created for each subnet, configured and assigned as applicable, restricting traffic to only what's necessary. VM subnet NSG must allow inbound RDP (port 3389) from Bastion subnet to enable bastion connectivity.

The VM's administrator password (created at the time of deployment) must be stored in a Key Vault, also deployed as part of this solution. The VM's administrator account must be called "vmadmin". The VM's computer name (netbios name) must be 15 or fewer characters long.

Always rely on parameters from the main.bicepparam file only. Have the name of the secret used for the admin password captured as a parameter. Include rich comments in both the main.bicep and main.bicepparam files to explain the purpose of each resource and parameter.

When a decision needs to be made on availability zones, always choose a number between 1 and 3 (never choose -1, that explicitly disables this feature).

Create everything in a single resource group, standing for a production environment. Do not create any additional environments (such as dev, test, staging, etc.).

Read the documentation (readme.md file) of each module you need to use to find out what parameters and complex parameter objects you can use. Don't guess the allowed parameters.

The VM must not be accessible from the internet and its logs should be captured in Log Analytics, included with the solution. Configure diagnostic logging plus critical-only alerts (VM stopped, disk full, Key Vault access failures).
➕ Expand to see the results

Notice that the execution of the /speckit.specify created a new file called requirements.md and a file called spec.md in the specs/001-legacy-vm-workload/ folder.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

Once Copilot finished running the prompt, you should see something like this in the Copilot chat area:

Specify Bootstrap Specify Bootstrap
<!-- markdownlint-disable -->
# Specification Quality Checklist: Legacy VM Workload Infrastructure

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-27
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Validation Results

**Status**: ✅ PASSED - All checklist items completed successfully

**Validation Date**: 2026-01-27

**Details**:
- Specification includes 5 user stories, properly prioritized (P1-P3)
- 16 functional requirements defined with concrete Azure resources
- 8 security & compliance requirements aligned with constitution
- 6 infrastructure constraints following project standards
- 5 monitoring & alerting requirements for operational visibility
- 11 success criteria, all measurable and deployment-focused
- Edge cases identified (6 scenarios)
- Assumptions documented (13 items)
- Out of scope clearly defined (13 items)
- No [NEEDS CLARIFICATION] markers - all requirements are concrete and actionable

**Readiness**: ✅ Specification is ready for `/speckit.plan` phase
<!-- markdownlint-disable -->
# Feature Specification: Legacy VM Workload Infrastructure

**Feature Branch**: `001-legacy-vm-workload`
**Created**: 2026-01-27
**Status**: Draft
**Input**: User description: "legacy business application, running as a single virtual machine connected to a virtual network with Windows Server 2016, 2 CPU cores, 8 GB RAM, standard HDD, 500 GB data disk, bastion access, file share via private endpoint, NAT gateway internet access, NSGs, Key Vault for VM password, Log Analytics with diagnostic logging and critical alerts"

## User Scenarios & Testing *(mandatory)*

<!--
  IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
  Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
  you should still have a viable MVP (Minimum Viable Product) that delivers value.

  Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
  Think of each story as a standalone slice of functionality that can be:
  - Developed independently
  - Tested independently
  - Deployed independently
  - Demonstrated to users independently
-->

### User Story 1 - Core VM Infrastructure Deployment (Priority: P1)

Deploy the fundamental infrastructure including virtual network, VM with required specifications, and basic connectivity. This establishes the baseline workload environment.

**Why this priority**: Without the VM and network infrastructure, no other components can function. This is the foundation for the entire workload.

**Independent Test**: Can be fully tested by deploying the infrastructure and verifying VM is created with correct specifications (Windows Server 2016, 2 cores, 8GB RAM, standard HDD) and can communicate within the VNet.

**Acceptance Scenarios**:

1. **Given** no existing infrastructure, **When** deployment is executed, **Then** VM is created with Windows Server 2016, 2 CPU cores, 8GB RAM
2. **Given** deployment is complete, **When** checking VM configuration, **Then** VM has standard HDD OS disk and is placed in correct VNet
3. **Given** VM is deployed, **When** checking computer name, **Then** NetBIOS name is 15 characters or fewer

---

### User Story 2 - Secure Storage and Data Disk (Priority: P2)

Provision the 500GB data disk for the VM and configure the storage account with file share accessible via private endpoint. This provides the data storage layer for the application.

**Why this priority**: Data storage is critical for application functionality but depends on the VM infrastructure being in place first.

**Independent Test**: Can be tested by verifying the 500GB HDD data disk is attached to the VM and the file share is accessible from the VM through the private endpoint.

**Acceptance Scenarios**:

1. **Given** VM infrastructure exists, **When** data disk deployment executes, **Then** 500GB HDD-based managed disk is attached to the VM
2. **Given** storage account is deployed, **When** checking storage configuration, **Then** HDD-backed file share is created
3. **Given** private endpoint is deployed, **When** VM attempts to access file share, **Then** connection succeeds through private network without traversing internet

---

### User Story 3 - Secure Access and Secrets Management (Priority: P2)

Implement bastion host for secure remote access and Key Vault for storing the VM administrator password. This ensures secure access patterns for operations teams.

**Why this priority**: Secure access is essential for ongoing operations but the infrastructure must exist before access can be configured.

**Independent Test**: Can be tested by connecting to the VM through bastion host using credentials retrieved from Key Vault.

**Acceptance Scenarios**:

1. **Given** bastion and Key Vault are deployed, **When** VM administrator password is generated at deployment, **Then** password is stored in Key Vault secret
2. **Given** bastion host is deployed, **When** operator attempts to connect to VM, **Then** connection succeeds through bastion without public IP on VM
3. **Given** Key Vault access is configured, **When** retrieving VM password, **Then** secret can be accessed only by authorized identities
4. **Given** VM subnet NSG is configured, **When** bastion attempts RDP connection to VM, **Then** traffic is allowed through NSG rule (port 3389 from Bastion subnet)

---

### User Story 4 - Internet Connectivity and Network Security (Priority: P3)

Configure NAT gateway for outbound internet access and implement Network Security Groups for all subnets with least-privilege rules.

**Why this priority**: Network security controls are important but the workload can function for testing without full NSG configuration initially.

**Independent Test**: Can be tested by verifying VM can reach internet through NAT gateway and that NSG rules block unauthorized traffic.

**Acceptance Scenarios**:

1. **Given** NAT gateway is deployed, **When** VM initiates outbound internet connection, **Then** traffic routes through NAT gateway
2. **Given** NSGs are configured, **When** unauthorized traffic attempts to reach VM, **Then** traffic is blocked by NSG rules
3. **Given** NSGs are deployed, **When** checking subnet associations, **Then** each subnet has appropriate NSG assigned

---

### User Story 5 - Monitoring and Alerting (Priority: P3)

Deploy Log Analytics workspace, configure diagnostic settings for all resources, and set up critical alerts (VM stopped, disk full, Key Vault access failures).

**Why this priority**: Monitoring is important for operations but the workload can function without it. It provides operational visibility rather than core functionality.

**Independent Test**: Can be tested by verifying diagnostic logs are flowing to Log Analytics and triggering test scenarios that generate alerts.

**Acceptance Scenarios**:

1. **Given** Log Analytics workspace is deployed, **When** resources are created, **Then** diagnostic settings send logs to workspace
2. **Given** alerting is configured, **When** VM is stopped, **Then** critical alert is triggered
3. **Given** alerting is configured, **When** Key Vault access fails, **Then** critical alert is triggered
4. **Given** diagnostic logging is active, **When** querying Log Analytics, **Then** logs are available within 5 minutes

---

## Clarifications

### Session 2026-01-27

- Q: VNet address space and subnet sizing for VM, bastion, and private endpoint subnets? → A: VNet: 10.0.0.0/24, VM subnet: 10.0.0.0/27, Bastion subnet: 10.0.0.64/26, Private endpoint subnet: 10.0.0.128/27
- Q: Storage file share quota size? → A: 1024 GiB (1 TiB)
- Q: Disk space alert threshold percentage? → A: 85% full
- Q: Alert notification method for critical alerts? → A: Azure Portal notifications only
- Q: VM size SKU for 2 cores and 8GB RAM requirement? → A: Standard_D2s_v3

### Edge Cases

- What happens when VM computer name parameter would exceed 15 characters? (NetBIOS limit must be enforced)
- How does deployment handle when Key Vault secret name parameter is not provided or is invalid?
- What happens when storage account name would exceed 24 characters or contains invalid characters?
- How does system handle when no availability zone is specified for resources requiring zone selection?
- What happens when private endpoint deployment fails but storage account succeeds?
- How does deployment handle if bastion subnet already exists in the VNet from a previous deployment?

## Requirements *(mandatory)*

<!--
  ACTION REQUIRED: The content in this section represents placeholders.
  Fill them out with the right functional requirements.
-->

### Functional Requirements

<!-- NOTE: "Standard HDD" refers to Azure Standard_LRS disk SKU (magnetic disk storage) -->

- **FR-001**: Infrastructure MUST provision a Windows Server 2016 Virtual Machine with size Standard_D2s_v3 (2 vCPUs, 8 GiB RAM) using Standard HDD for OS disk
- **FR-002**: Infrastructure MUST attach a 500GB HDD-based managed disk to the VM as a data disk
- **FR-003**: Infrastructure MUST create Virtual Network with address space 10.0.0.0/24 containing three subnets: VM subnet (10.0.0.0/27), Bastion subnet (10.0.0.64/26), and private endpoint subnet (10.0.0.128/27)
- **FR-004**: Infrastructure MUST deploy Azure Bastion for secure remote access to the VM without public IP
- **FR-005**: Infrastructure MUST provision Storage Account with HDD-backed file share (1024 GiB quota) accessible via private endpoint
- **FR-006**: Infrastructure MUST deploy NAT Gateway for VM outbound internet connectivity
- **FR-007**: Infrastructure MUST create Network Security Groups for each subnet with least-privilege rules
- **FR-008**: Infrastructure MUST deploy Azure Key Vault to store VM administrator password
- **FR-009**: Infrastructure MUST set VM administrator account name to "vmadmin"
- **FR-010**: Infrastructure MUST ensure VM computer name (NetBIOS name) is 15 characters or fewer
- **FR-011**: Infrastructure MUST generate and store VM administrator password in Key Vault at deployment time
- **FR-012**: Infrastructure MUST accept Key Vault secret name as a parameter from main.bicepparam
- **FR-013**: Infrastructure MUST deploy all resources to a single resource group representing production environment
- **FR-014**: Infrastructure MUST select availability zone between 1-3 for zone-capable resources (never use -1)
- **FR-015**: Infrastructure MUST include rich comments in both main.bicep and main.bicepparam explaining resource purpose and parameters
- **FR-016**: Infrastructure MUST rely exclusively on parameters defined in main.bicepparam file

### Security & Compliance Requirements (Mandatory for all features)

- **SEC-001**: All resources MUST enable diagnostic settings and send logs to Log Analytics Workspace
- **SEC-002**: VM MUST use managed identity for Azure resource authentication (no connection strings/keys in configuration)
- **SEC-003**: Network Security Groups MUST restrict traffic to only necessary ports and protocols per subnet
- **SEC-003a**: VM subnet NSG MUST allow inbound RDP (port 3389) from Bastion subnet (10.0.0.64/26) to enable bastion connectivity
- **SEC-004**: All resources MUST be tagged with compliance identifier "legacy-retention"
- **SEC-005**: VM administrator password MUST be stored in Azure Key Vault, never in code or parameters
- **SEC-006**: VM MUST NOT have public IP address assigned (access only through bastion)
- **SEC-007**: Storage account file share MUST be accessible only through private endpoint, not public endpoint
- **SEC-008**: Key Vault MUST restrict access to only authorized identities using RBAC

### Infrastructure Constraints

- **IC-001**: MUST deploy to westus3 region (US West 3)
- **IC-002**: MUST use Azure Verified Modules (AVM) exclusively (read module readme.md for parameter documentation)
- **IC-003**: MUST validate deployment with `az deployment group validate` before applying
- **IC-004**: MUST run `az deployment group what-if` to preview changes
- **IC-005**: Resource names MUST follow pattern: {resourceType}-{purpose}-{random4-6chars}
- **IC-006**: MUST NOT create additional environments (dev, test, staging) - production only

### Monitoring & Alerting Requirements

- **MON-001**: Infrastructure MUST deploy Log Analytics workspace for centralized logging
- **MON-002**: Infrastructure MUST configure diagnostic logging for VM, Key Vault, Storage Account, and network resources
- **MON-003**: Infrastructure MUST create critical alert for VM stopped/deallocated condition (Portal notifications)
- **MON-004**: Infrastructure MUST create critical alert for disk space exceeding 85% threshold (Portal notifications)
- **MON-005**: Infrastructure MUST create critical alert for Key Vault access failures (Portal notifications)

### Key Azure Resources

- **Virtual Machine**: Windows Server 2016 VM with size Standard_D2s_v3 (2 vCPUs, 8 GiB RAM), Standard HDD OS disk, managed identity enabled
- **Managed Disk**: 500GB HDD-based data disk attached to VM
- **Virtual Network**: VNet with subnets for VM, bastion, and private endpoints
- **Azure Bastion**: Secure RDP access to VM without public IP
- **Storage Account**: Standard HDD storage with file share
- **Private Endpoint**: Secure connectivity between VM and storage account file share
- **NAT Gateway**: Outbound internet connectivity for VM subnet
- **Network Security Groups**: One per subnet with least-privilege rules
- **Key Vault**: Stores VM administrator password as secret
- **Log Analytics Workspace**: Centralized logging for all resources
- **Azure Monitor Alerts**: Critical alerts for VM stopped, disk full, Key Vault access failures

## Success Criteria *(mandatory)*

<!--
  ACTION REQUIRED: Define measurable success criteria.
  These must be technology-agnostic and measurable.
-->

### Measurable Outcomes

- **SC-001**: Infrastructure deploys successfully within 20 minutes including all resources
- **SC-002**: ARM validation (`az deployment group validate`) passes without errors
- **SC-003**: ARM what-if analysis shows all expected resources will be created
- **SC-004**: VM is accessible via bastion host within 5 minutes of deployment completion
- **SC-005**: VM can access file share through private endpoint connection
- **SC-006**: VM can reach internet through NAT gateway for outbound connections
- **SC-007**: Diagnostic logs from all resources appear in Log Analytics within 5 minutes
- **SC-008**: All resources pass Azure Security Center baseline compliance checks
- **SC-009**: NSG rules successfully block unauthorized traffic in test scenarios
- **SC-010**: VM administrator password can be retrieved from Key Vault by authorized identities
- **SC-011**: Critical alerts can be triggered and verified (VM stop, simulated disk full warning, Key Vault access attempt)

## Assumptions

- Azure subscription has sufficient quota for Standard_D2s_v3 VM size
- Azure Bastion service is available in westus3 region
- Windows Server 2016 image is available in Azure Marketplace for westus3 region
- Log Analytics workspace can be deployed in westus3 region
- Private endpoint feature is available for storage accounts in westus3 region
- NAT Gateway is available in westus3 region
- Deployment is executed by identity with sufficient permissions to create all resource types
- Resource group name will be provided as parameter in main.bicepparam
- Random suffix for resource names will be generated or provided as parameter
- Default log retention period of 30 days is acceptable for Log Analytics (compliance requirement may differ)
- Standard_LRS storage redundancy is acceptable for this legacy workload
- VM will be deployed without availability sets or scale sets (single instance acceptable)
- Availability zone selection (1, 2, or 3) will be provided as parameter

## Out of Scope

- Multi-region deployment or disaster recovery configuration
- High availability (availability sets, load balancers, multiple VMs)
- Auto-scaling capabilities
- Backup and restore automation (Azure Backup configuration)
- Additional environments (development, test, staging)
- Application installation or configuration on the VM
- Custom monitoring dashboards or complex alerting logic beyond critical alerts
- Network connectivity to on-premises networks (VPN or ExpressRoute)
- Azure Active Directory domain join
- Additional data disks beyond the single 500GB disk specified
- Storage account configuration beyond file share (no blob containers, tables, or queues)
- Advanced network features (Azure Firewall, Application Gateway, Traffic Manager)
- Cost optimization recommendations or reserved instance planning
/speckit.specify Create specification, called "01-my-legacy-workload" for a legacy business application, running as a single virtual machine connected to a virtual network. The VM must run Windows Server 2016, needs to have at least 2 CPU cores, 8 GB of RAM, a standard HDD, and a 500 GB HDD-based data disk attached. It must be remotely accessible via a bastion host and needs to have access to an HDD-backed file share in a storage account connected via a private endpoint. The VM must access the internet via a NAT gateway. Network Security Groups (NSGs) must be created for each subnet, configured and assigned as applicable, restricting traffic to only what's necessary. VM subnet NSG must allow inbound RDP (port 3389) from Bastion subnet to enable bastion connectivity.

The VM's administrator password (created at the time of deployment) must be stored in a Key Vault, also deployed as part of this solution. The VM's administrator account must be called "vmadmin". The VM's computer name (netbios name) must be 15 or fewer characters long.

Always rely on values from the `terraform.tfvars` file only. Have the name of the secret used for the admin password captured as a variable. Include rich comments in both the `main.tf` and `terraform.tfvars` files to explain the purpose of each resource and variable.

When a decision needs to be made on availability zones, always choose a number between 1 and 3 (never choose -1, that explicitly disables this feature).

Create everything in a single resource group, standing for a production environment. Do not create any additional environments (such as dev, test, staging, etc.).

Read the documentation (readme.md file) of each module you need to use to find out what variables and complex variable objects you can use. Don't guess the allowed variables.

The VM must not be accessible from the internet and its logs should be captured in Log Analytics, included with the solution. Configure diagnostic logging plus critical-only alerts (VM stopped, disk full, Key Vault access failures).

The Azure resource naming convention should follow Azure Cloud Adoption Framework guidance. Resource type specific character and length limitations must be respected. Random character should only be added to resources that must be globally unique like storage accounts. All resource names should be kebab case unless the hyphen is not supported for that resource.
➕ Expand to see the results

Notice that the execution of the /speckit.specify created a new file called requirements.md and a file called spec.md in the specs/001-legacy-vm-workload/ folder.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

Once Copilot finished running the prompt, you should see something like this in the Copilot chat area:

Specify Bootstrap Specify Bootstrap
# Specification Quality Checklist: Legacy Business Application Infrastructure

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-18
**Feature**: [001-my-legacy-workload/spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Validation Notes

### Content Quality Assessment
- **✅ Pass**: Specification focuses on infrastructure requirements (WHAT) without specifying HOW to implement (e.g., specific Terraform syntax, module parameters beyond identifying which AVM modules to use)
- **✅ Pass**: Written for infrastructure stakeholders and operations teams - describes Azure resources, security controls, and operational requirements
- **✅ Pass**: All mandatory sections present: User Scenarios & Testing, Requirements, Success Criteria, Assumptions, Dependencies, Out of Scope

### Requirement Completeness Assessment
- **✅ Pass**: Zero [NEEDS CLARIFICATION] markers - all requirements fully specified based on user input
- **✅ Pass**: Requirements are testable - each FR and SEC item can be verified (e.g., "VM MUST have 2+ CPU cores" - check VM properties; "NSG MUST allow RDP from Bastion only" - check NSG rules)
- **✅ Pass**: Success criteria are measurable with specific metrics (e.g., "deployment completes within 30 minutes", "RDP connection established within 2 minutes", "cost under $200/month")
- **✅ Pass**: Success criteria avoid implementation details - focus on outcomes (e.g., "VM can mount file share" not "private endpoint DNS configuration works")
- **✅ Pass**: Acceptance scenarios defined for all 4 user stories with Given/When/Then format
- **✅ Pass**: Edge cases identified (8 scenarios covering naming limits, zone availability, DNS resolution, secret conflicts, NSG rules, storage naming, subnet sizing, disk attachment)
- **✅ Pass**: Scope clearly bounded with detailed "Out of Scope" section (15 items explicitly excluded like HA config, DR, multiple environments, domain join, etc.)
- **✅ Pass**: Dependencies section lists all prerequisites (Terraform version, Azure CLI, AVM modules, Azure subscription,state backend, etc.)
- **✅ Pass**: Assumptions section documents all implicit decisions (15 assumptions covering quotas, state backend existence, permissions, naming conflicts, zone support, application compatibility, etc.)

### Feature Readiness Assessment
- **✅ Pass**: All 25 functional requirements map to user stories and have testable acceptance criteria
- **✅ Pass**: 4 user stories cover complete infrastructure lifecycle: P1 (core compute/network), P2 (secure access), P3 (storage), P4 (internet/monitoring)
- **✅ Pass**: Each user story independently testable and deliverable
- **✅ Pass**: Success criteria define 13 measurable outcomes aligned with requirements
- **✅ Pass**: No Terraform syntax or module-specific parameters in spec (appropriate - those belong in plan/implementation phase)

### Specification Completeness: READY FOR PLANNING ✅

**Summary**: Specification passes all quality gates. No clarifications needed. Ready to proceed with `/speckit.plan` to generate implementation plan.

**Recommended Next Steps**:
1. Run `/speckit.plan` to generate implementation plan with Terraform architecture
2. During planning phase, research AVM module documentation for each identified resource
3. Define Technical Context (Terraform version, provider versions, AVM module versions)
4. Create Project Structure (terraform/ directory layout)
5. Generate tasks.md with phased implementation (Setup → Foundational → US1-US4 → Polish)
# Feature Specification: Legacy Business Application Infrastructure

**Feature Branch**: `001-my-legacy-workload`
**Created**: 2026-02-18
**Status**: Draft
**Input**: User description: "Legacy business application running as a single virtual machine with Windows Server 2016, networking (VNet, Bastion, NAT Gateway, NSGs), storage (file share via private endpoint), Key Vault for secrets, and Log Analytics for monitoring"

## User Scenarios & Testing *(mandatory)*

### User Story 1 - Core Compute and Network Infrastructure (Priority: P1)

Deploy a Windows Server 2016 virtual machine within an isolated virtual network with proper subnet segmentation and network security controls. This provides the foundational compute and network infrastructure required for the legacy application.

**Why this priority**: Without the VM and basic networking, the application cannot run. This is the minimum viable infrastructure that delivers compute capability in an isolated, secure network environment.

**Independent Test**: Deploy Terraform configuration, verify VM is created and running in the specified VNet with proper subnets. Confirm NSGs are attached to subnets and default deny rules are in place. VM should be isolated with no internet or external access at this stage.

**Acceptance Scenarios**:

1. **Given** Terraform configuration with VM and VNet resources, **When** `terraform apply` is executed, **Then** a Windows Server 2016 VM is created with 2+ CPU cores, 8GB RAM, standard HDD OS disk, and 500GB HDD data disk
2. **Given** the VM is deployed, **When** checking the virtual network, **Then** VNet contains at least 3 subnets (VM subnet, Bastion subnet, Private Endpoint subnet) with appropriate CIDR ranges
3. **Given** subnets are created, **When** checking NSG assignments, **Then** each subnet has an NSG attached with deny-by-default rules configured
4. **Given** VM is running, **When** checking VM properties, **Then** computer name is 15 characters or fewer, administrator account is "vmadmin", and password is stored in Key Vault (secret name configurable)
5. **Given** VM subnet NSG rules, **When** checking inbound rules, **Then** NSG allows RDP (port 3389) from Bastion subnet only

---

### User Story 2 - Secure Remote Access (Priority: P2)

Enable secure remote access to the virtual machine through Azure Bastion and store the VM administrator password securely in Azure Key Vault. This allows administrators to manage the VM without exposing it to the internet.

**Why this priority**: Secure access is critical for managing the VM and performing administrative tasks. Without Bastion, the VM would need a public IP (violating security requirements) or would be completely inaccessible.

**Independent Test**: After deploying US1, deploy Bastion and Key Vault infrastructure. Verify administrators can connect to the VM via Azure Bastion using credentials retrieved from Key Vault. Confirm VM has no public IP address.

**Acceptance Scenarios**:

1. **Given** Bastion host and Key Vault are deployed, **When** administrator navigates to VM in Azure Portal, **Then** "Connect via Bastion" option is available
2. **Given** Bastion connection initiated, **When** using "vmadmin" username and password from Key Vault, **Then** RDP session to VM is successfully established
3. **Given** Key Vault is deployed, **When** checking secrets, **Then** VM administrator password is stored as a secret with configurable name (defined in terraform.tfvars)
4. **Given** VM networking configuration, **When** checking VM properties, **Then** VM has no public IP address and is not directly accessible from internet
5. **Given** Key Vault access policies, **When** Terraform deploys the infrastructure, **Then** Key Vault uses managed identity for authentication (no service principal credentials in code)

---

### User Story 3 - Application Storage Integration (Priority: P3)

Provide secure access to an Azure Files share for application data storage, connected via private endpoint to ensure data does not traverse the public internet.

**Why this priority**: The legacy application requires access to a file share for data persistence. This enables the application's core functionality while maintaining security through private connectivity.

**Independent Test**: After deploying US1 and US2, deploy storage account with file share and private endpoint. From the VM, mount the Azure Files share using private endpoint IP. Verify data can be written to and read from the share without public internet connectivity.

**Acceptance Scenarios**:

1. **Given** storage account is deployed, **When** checking storage configuration, **Then** storage account has HDD-backed file share created (Standard tier, not Premium)
2. **Given** private endpoint is deployed, **When** checking network connectivity, **Then** private endpoint is connected to the Private Endpoint subnet in the VNet
3. **Given** private endpoint exists, **When** checking DNS resolution from VM, **Then** storage account FQDN resolves to private endpoint IP address (not public IP)
4. **Given** VM is running, **When** attempting to mount file share, **Then** file share is accessible from VM using private IP and SMB protocol
5. **Given** storage NSG rules, **When** checking Private Endpoint subnet NSG, **Then** NSG allows SMB traffic (port 445) from VM subnet only

---

### User Story 4 - Internet Access and Observability (Priority: P4)

Enable outbound internet access via NAT Gateway for Windows Updates and patches, and implement comprehensive monitoring through Log Analytics with diagnostic logging and critical alerts.

**Why this priority**: While not required for basic application functionality, internet access enables the VM to download security updates. Monitoring and alerting provide operational visibility and compliance evidence.

**Independent Test**: After deploying US1-US3, deploy NAT Gateway and Log Analytics. From VM, verify outbound internet connectivity (e.g., download Windows Update). Confirm diagnostic logs are flowing to Log Analytics and test alerts trigger correctly.

**Acceptance Scenarios**:

1. **Given** NAT Gateway is deployed and associated with VM subnet, **When** VM attempts outbound HTTP/HTTPS connection, **Then** connection succeeds with traffic routed through NAT Gateway
2. **Given** VM attempts inbound connection from internet, **When** traffic reaches VM subnet, **Then** connection is blocked (VM remains inaccessible from internet)
3. **Given** Log Analytics workspace is deployed, **When** checking diagnostic settings, **Then** VM, Key Vault, and Storage Account have diagnostic logging enabled sending logs to Log Analytics
4. **Given** alerts are configured, **When** VM is stopped, **Then** critical alert notification is triggered
5. **Given** alerts are configured, **When** VM disk reaches 90% capacity, **Then** critical alert notification is triggered
6. **Given** alerts are configured, **When** Key Vault access failure occurs (e.g., permission denied), **Then** critical alert notification is triggered

---

### Edge Cases

- **VM naming constraints**: What happens when generated VM computer name exceeds 15 characters (NetBIOS limit)? Truncate or error during validation.
- **Availability zone selection**: If availability zones 1-3 are unavailable in westus3 region, how does deployment handle this? Fail with clear error or fall back to no-zone deployment?
- **Private endpoint DNS**: What happens when private DNS zone for storage account doesn't exist or isn't linked to VNet? File share mount will fail without proper DNS resolution.
- **Key Vault secret naming**: What happens when the secret name specified in terraform.tfvars already exists in Key Vault? Overwrite or error?
- **NSG rule conflicts**: What happens when custom NSG rules conflict with required rules (e.g., accidentally blocking RDP from Bastion)? Terraform should fail validation.
- **Storage account naming**: What happens when randomly generated storage account name conflicts with existing global namespace? Terraform apply will fail - require retry with new random suffix.
- **Bastion subnet size**: What happens when VNet address space is too small for required subnets including /26 for Bastion? Deployment fails with clear CIDR allocation error.
- **Managed disk attachment**: What happens when 500GB data disk fails to attach to VM? Deployment should fail atomically (VM should not be left in inconsistent state).

## Requirements *(mandatory)*

### Functional Requirements

- **FR-001**: Infrastructure MUST deploy a Windows Server 2016 virtual machine with minimum 2 CPU cores and 8GB RAM
- **FR-002**: VM MUST use standard HDD for OS disk (not SSD) for cost optimization
- **FR-003**: VM MUST have a 500GB HDD-based managed disk attached as data disk
- **FR-004**: VM computer name (NetBIOS name) MUST be 15 characters or fewer to comply with Windows naming limits
- **FR-005**: VM administrator account MUST be named "vmadmin"
- **FR-006**: VM administrator password MUST be generated at deployment time and stored in Key Vault
- **FR-007**: Infrastructure MUST deploy a virtual network with at least 3 subnets (VM subnet, Bastion subnet, Private Endpoint subnet)
- **FR-008**: Infrastructure MUST deploy Network Security Groups for each subnet with deny-by-default posture
- **FR-009**: VM subnet NSG MUST allow inbound RDP (port 3389) from Bastion subnet ONLY
- **FR-010**: Infrastructure MUST deploy Azure Bastion for secure RDP access to VM (no public IP on VM)
- **FR-011**: VM MUST NOT be accessible directly from the internet (no public IP address on VM)
- **FR-012**: Infrastructure MUST deploy NAT Gateway associated with VM subnet for outbound internet access
- **FR-013**: Infrastructure MUST deploy storage account with HDD-backed (Standard tier) Azure Files share
- **FR-014**: Storage account file share MUST be accessible from VM via private endpoint (no public access)
- **FR-015**: Infrastructure MUST deploy Key Vault for storing VM administrator password securely
- **FR-016**: Key Vault secret name for VM password MUST be configurable via terraform.tfvars variable
- **FR-017**: Infrastructure MUST use managed identities for authentication (no service principal credentials in Terraform code)
- **FR-018**: Infrastructure MUST deploy Log Analytics workspace for centralized logging
- **FR-019**: VM, Key Vault, and Storage Account MUST have diagnostic settings enabled sending logs to Log Analytics
- **FR-020**: Infrastructure MUST configure critical alerts: VM stopped, VM disk usage >90%, Key Vault access failures
- **FR-021**: All configurable values (VM size, disk sizes, subnet CIDRs, secret names, etc.) MUST be defined in terraform.tfvars (not hardcoded in main.tf)
- **FR-022**: All Terraform files (main.tf, terraform.tfvars) MUST include rich comments explaining purpose of each resource, variable, and configuration block
- **FR-023**: When selecting availability zones, MUST choose zone 1, 2, or 3 (NEVER use -1 or no-zone unless region doesn't support zones)
- **FR-024**: All resources MUST be deployed in a single resource group representing production environment
- **FR-025**: Infrastructure MUST reference AVM module documentation (readme.md) to determine correct variable names and object structures (no guessing)

### Infrastructure Requirements *(for Terraform IaC projects)*

**Azure Resources Required**:
- **Resource 1**: Resource Group for all resources - AVM Module: `Azure/avm-res-resources-resourcegroup/azurerm`
- **Resource 2**: Virtual Network with 3+ subnets - AVM Module: `Azure/avm-res-network-virtualnetwork/azurerm`
- **Resource 3**: Network Security Groups (3 minimum) - AVM Module: `Azure/avm-res-network-networksecuritygroup/azurerm`
- **Resource 4**: Windows Server 2016 Virtual Machine - AVM Module: `Azure/avm-res-compute-virtualmachine/azurerm`
- **Resource 5**: Azure Bastion Host - AVM Module: `Azure/avm-res-network-bastionhost/azurerm`
- **Resource 6**: Key Vault for password storage - AVM Module: `Azure/avm-res-keyvault-vault/azurerm`
- **Resource 7**: Storage Account with file share - AVM Module: `Azure/avm-res-storage-storageaccount/azurerm`
- **Resource 8**: Private Endpoint for storage - AVM Module: `Azure/avm-res-network-privateendpoint/azurerm` (or part of storage module)
- **Resource 9**: NAT Gateway - AVM Module: `Azure/avm-res-network-natgateway/azurerm`
- **Resource 10**: Log Analytics Workspace - AVM Module: `Azure/avm-res-operationalinsights-workspace/azurerm`
- **Resource 11**: Alerts/Action Groups - use AzAPI `Microsoft.Insights/actionGroups` and `Microsoft.Insights/metricAlerts` resources
- **Resource 12**: Random string for naming suffix - Built-in: `random_string` from random provider

**Infrastructure Constraints**:
- **IC-001**: All resources MUST be deployed to `westus3` region (per constitution)
- **IC-002**: Terraform state MUST be stored in Azure Storage backend with state locking enabled
- **IC-003**: Resource naming MUST follow `<type>-<workload>-<suffix>` pattern (per constitution, e.g., `vm-avmlegacy-8k3m9x`)
- **IC-004**: No high-availability or geo-redundancy configurations (legacy workload constraint, single VM acceptable)
- **IC-005**: All resources MUST be deployed in single Resource Group (named per convention: `rg-my-legacy-workload-prod-wus3`)
- **IC-006**: Availability zone MUST be selected (zone 1, 2, or 3) - NEVER use -1 or disable zones explicitly
- **IC-007**: Use HDD/Standard tier storage for cost optimization (OS disk, data disk, storage account)
- **IC-008**: VNet MUST have sufficient address space for minimum 3 subnets (recommend /23 or larger for VNet, /26 for Bastion per Azure requirements)
- **IC-009**: Private Endpoint subnet MUST have `privateEndpointNetworkPolicies` disabled per Azure requirements
- **IC-010**: Computer name generation MUST enforce 15-character limit (Windows NetBIOS constraint)

**Security & Compliance**:
- **SEC-001**: VM MUST use managed identity (system-assigned) for Azure service authentication
- **SEC-002**: VM administrator password MUST be stored in Key Vault with secret name defined in terraform.tfvars
- **SEC-003**: NO service principal credentials or secrets in Terraform files (.tf or .tfvars)
- **SEC-004**: Network Security Groups MUST implement deny-by-default posture with explicit allow rules only
- **SEC-005**: VM subnet NSG MUST allow RDP (3389) ONLY from Bastion subnet CIDR (source IP restricted)
- **SEC-006**: Bastion subnet NSG MUST allow inbound 443 from internet (Azure Bastion requirement) and outbound RDP to VM subnet
- **SEC-007**: Private Endpoint subnet NSG MUST allow SMB (445) from VM subnet for file share access
- **SEC-008**: VM MUST NOT have public IP address (internet inaccessible)
- **SEC-009**: Storage account MUST have public network access disabled (private endpoint only)
- **SEC-010**: Diagnostic logging MUST be enabled on VM, Key Vault, and Storage Account sending logs to Log Analytics
- **SEC-011**: Resource locks (CanNotDelete) MUST be applied to Resource Group, VM, Key Vault, and Storage Account (compliance-critical resources)
- **SEC-012**: Key Vault MUST have soft-delete and purge protection enabled
- **SEC-013**: Storage account MUST use encryption at rest with Microsoft-managed keys (minimum)
- **SEC-014**: All network traffic between VM and storage MUST traverse private endpoint (verified via NSG flow logs or connection test)

**State Management**:
- **State Backend**: Azure Storage Account in separate resource group (pre-existing, not created by this Terraform)
- **State File**: `my-legacy-workload-prod.tfstate`
- **State Locking**: Enabled via blob lease mechanism
- **Workspaces/Key Prefix**: Single production environment only - use `prod.tfvars` for variable values

### Key Entities *(include if feature involves data)*

- **Virtual Machine**: Windows Server 2016 compute instance running legacy business application. Attributes: computer name (≤15 chars), size (Standard_D2s_v3 or similar with 2+ cores, 8GB RAM), OS disk (Standard HDD), data disk (500GB Standard HDD), administrator credentials (username: vmadmin, password in Key Vault).

- **Virtual Network**: Isolated network containing all infrastructure. Attributes: address space (e.g., 10.0.0.0/23), subnets (VM subnet, Bastion subnet /26, Private Endpoint subnet).

- **Network Security Group**: Firewall rules for subnet-level traffic control. Attributes: associated subnet, inbound rules (RDP from Bastion, SMB to private endpoint), outbound rules (allow NAT Gateway for internet, deny all else).

- **Azure Bastion**: Managed PaaS service providing secure RDP/SSH access. Attributes: Bastion subnet (/26 minimum), public IP (managed by Bastion), SKU (Basic or Standard).

- **Key Vault**: Secure secrets store for VM password. Attributes: soft-delete enabled, purge protection enabled, access policies (Terraform managed identity for deployment, VM managed identity for runtime access if needed).

- **Storage Account**: Azure Files storage for application data. Attributes: Standard performance tier (HDD), LRS replication, file share (e.g., 100GB quota), private endpoint connection.

- **Private Endpoint**: Network interface in VNet providing private IP for storage account. Attributes: subnet (Private Endpoint subnet), private DNS integration (optional but recommended).

- **NAT Gateway**: Managed outbound internet gateway. Attributes: public IP address, associated with VM subnet for outbound traffic.

- **Log Analytics Workspace**: Centralized log repository. Attributes: retention period (e.g., 30-90 days per compliance requirements), diagnostic settings (VM, Key Vault, Storage Account).

- **Alerts**: Monitoring rules triggering on critical conditions. Attributes: VM stopped alert, disk space alert (>90%), Key Vault access failure alert, action group for notifications.

## Success Criteria *(mandatory)*

### Measurable Outcomes

- **SC-001**: Infrastructure deployment completes successfully via `terraform apply` within 30 minutes with all resources in "healthy" state
- **SC-002**: Administrator can establish RDP connection to VM via Azure Bastion within 2 minutes of infrastructure deployment completion
- **SC-003**: VM administrator password retrieved from Key Vault successfully authenticates RDP session via Bastion (100% success rate)
- **SC-004**: VM can mount Azure Files share via private endpoint and read/write files without errors (verified via test file operations)
- **SC-005**: VM can download content from internet via NAT Gateway (e.g., successful Windows Update check or HTTP GET to microsoft.com)
- **SC-006**: VM is NOT reachable via direct internet connection (verified via external port scan showing no open ports)
- **SC-007**: Diagnostic logs from VM, Key Vault, and Storage Account appear in Log Analytics within 15 minutes of deployment
- **SC-008**: Critical alerts (VM stopped, disk >90%, Key Vault access failure) trigger notifications within 5 minutes of condition occurring (tested via controlled failure scenarios)
- **SC-009**: All Terraform validation steps pass (`terraform fmt -check`, `terraform validate`, `tfsec` with no HIGH/CRITICAL findings)
- **SC-010**: Infrastructure deployment uses ONLY values from terraform.tfvars (no hardcoded values in main.tf) - verified via code review
- **SC-011**: All AVM module variables are correctly structured per module documentation (verified by successful deployment without Terraform errors)
- **SC-012**: Computer name is 15 characters or fewer (verified via VM properties after deployment)
- **SC-013**: Total monthly cost of deployed infrastructure is under $200/month (estimated based on Azure pricing calculator)

## Assumptions

- **A-001**: Azure subscription has sufficient quota for VM size, NAT Gateway, and Bastion in westus3 region
- **A-002**: Terraform state backend (Azure Storage Account) already exists and is configured prior to deployment (not managed by this Terraform code)
- **A-003**: Deployment identity (user, service principal, or managed identity running Terraform) has Contributor access to target subscription or resource group scope
- **A-004**: No existing resources with conflicting names in the subscription (e.g., duplicate storage account name, Key Vault name)
- **A-005**: Azure region westus3 supports availability zones (deployment assumes zone selection 1-3 is valid)
- **A-006**: Legacy application compatibility with Windows Server 2016 has been validated (not part of infrastructure scope)
- **A-007**: Legacy application does not require specific VM size beyond minimum 2 cores, 8GB RAM (actual VM SKU chosen for cost-performance balance)
- **A-008**: 500GB data disk is sufficient for application data storage requirements (not dynamically scaled)
- **A-009**: Standard HDD performance is adequate for legacy application workload (no IOPS/throughput requirements specified)
- **A-010**: File share quota (e.g., 100GB) is sufficient for application needs - configurable in terraform.tfvars if different
- **A-011**: Alert notifications can use email or webhook action group (specific notification target configured separately or in variables)
- **A-012**: VNet address space 10.0.0.0/23 (512 IPs) is sufficient and does not conflict with on-premises networks or VPN peering requirements
- **A-013**: No ExpressRoute or VPN gateway integration required (VM internet access is outbound only via NAT Gateway)
- **A-014**: VM does not require domain join (workgroup/standalone configuration acceptable)
- **A-015**: No existing Azure Policy assignments block required configuration (e.g., policy preventing public IP on Bastion, policy requiring specific encryption)

## Dependencies

- **D-001**: Terraform >= 1.5.0 installed on deployment machine
- **D-002**: Azure CLI authenticated with sufficient permissions (`az login` completed)
- **D-003**: AVM modules available from Terraform Registry (internet connectivity required during `terraform init`)
- **D-004**: Azure subscription with active valid payment method and sufficient credits
- **D-005**: Pre-existing Azure Storage Account and container for Terraform state backend
- **D-006**: Pre-existing Resource Group for Terraform state backend (separate from workload resource group)
- **D-007**: Azure region westus3 supports all required resource types (VM, Bastion, NAT Gateway, availability zones)
- **D-008**: AVM module documentation (readme.md) accessible for each module used (refer to Terraform Registry or GitHub)
- **D-009**: Terraform providers: AzAPI (~> 2.12) for direct resources, AzureRM (~> 4.0) as required by published AVM dependencies, and random (~> 3.5)
- **D-010**: Security scanning tools (tfsec or checkov) installed if enforcing constitution security requirements
- **D-011**: Windows Server 2016 image available in Azure Marketplace (standard Microsoft image)
- **D-012**: Understanding of Terraform module composition (reading AVM module documentation to determine input variables and complex objects)

## Out of Scope

- **OS-001**: Installation or configuration of legacy business application on the VM (infrastructure only, app deployment separate)
- **OS-002**: Domain join or Active Directory integration (standalone/workgroup VM)
- **OS-003**: VPN gateway or ExpressRoute connectivity to on-premises networks
- **OS-004**: Multiple environments (dev, test, staging) - ONLY production environment deployed
- **OS-005**: High availability configuration (multiple VMs, load balancer, availability set) - single VM by design per legacy constraint
- **OS-006**: Disaster recovery or geo-replication configuration (no backup policies, no secondary region)
- **OS-007**: Auto-scaling or dynamic resource sizing (fixed VM size, fixed disk sizes)
- **OS-008**: Custom VM extensions or DSC configuration (beyond basic deployment)
- **OS-009**: Application-level monitoring or APM (only infrastructure-level diagnostics in Log Analytics)
- **OS-010**: Database deployment (if legacy app uses database, assumed to be on separate server or managed service)
- **OS-011**: DNS records in public or private DNS zones (beyond private endpoint DNS if AVM module handles it)
- **OS-012**: Certificate management or SSL/TLS termination (application responsibility if needed)
- **OS-013**: Cost management tags beyond basic workload identification (detailed cost center, project, owner tags)
- **OS-014**: Compliance frameworks implementation (HIPAA, PCI-DSS, SOC2) - basic security controls only per constitution
- **OS-015**: Terraform module development (using existing AVM modules only, no custom module authoring)
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture the clarified specification of your project, with a comment of something like Specification created.

3. Clarify (Optional)

Spec Kit uses /speckit.clarify to generate adjust information captured in spec.md. The prompt doesn’t require any specific inputs as it analyzes the existing specification for gaps.

Info

To learn more about the clarify step, see the Clarify chapter in the Spec Kit article.

  1. Run the following prompt to generate clarification questions for our example:
​
/speckit.clarify
➕ Expand to see example questions

The clarify phase iterates on the spec.md file by asking questions, making suggestions and capturing the user’s feedback.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

When running the clarify prompt, Copilot may ask you a number of depth questions to clarify certain aspects of the plan. Here’s an example of what that looks like. You can answer in the following format, e.g.: Q1: E, Q2:A, Q3:A
Specify Bootstrap

In the Copilot chat window, you will likely see some questions raised, similar to these. You can answer these just like in a normal chat conversation - e.g., by typing the letter standing for the option provided for each question, or by elaborating further if needed.

Specify Bootstrap Specify Bootstrap Specify Bootstrap

See a few examples of clarifying questions Copilot may ask. Copilot typically suggests a few options, but you can always deviate from them as needed, just use the chat to provide your answers.

## Clarifications

- Q: How should the Azure file share be mounted on the Windows VM? → A: Post-deployment manual mount by administrator following documented procedure (no automation, aligns with IaC-first principle)
- Q: What level of monitoring and alerting should be configured for this legacy workload? → A: Diagnostic logging plus critical-only alerts (VM stopped, disk full, Key Vault access failures)
- Q: If the initial deployment partially fails (e.g., VM creates but Bastion fails), what should the recovery procedure be? → A: Keep existing resources, fix errors in template/parameters, redeploy entire template (ARM incremental mode handles already-deployed resources)
- Q: File share initial quota and growth strategy? → A: 1TB initial quota with documented growth monitoring procedure
- Q: VM administrator username? → A: vmadmin
- Q: VNet address space and subnet sizing for VM, bastion, and private endpoint subnets? → A: VNet: 10.0.0.0/24, VM subnet: 10.0.0.0/27, Bastion subnet: 10.0.0.64/26, Private endpoint subnet: 10.0.0.128/27
- Q: Storage file share quota size? → A: 1024 GiB (1 TiB)
- Q: Disk space alert threshold percentage? → A: 85% full
- Q: Alert notification method for critical alerts? → A: Azure Portal notifications only
- Q: VM size SKU for 2 cores and 8GB RAM requirement? → A: Standard_D2s_v3
/speckit.clarify
➕ Expand to see example questions

The clarify phase iterates on the spec.md file by asking questions, making suggestions and capturing the user’s feedback.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

When running the clarify prompt, Copilot may ask you a number of depth questions to clarify certain aspects of the plan. Here’s an example of what that looks like. You can answer in the following format, e.g.: Q1: E, Q2:A, Q3:A
Specify Bootstrap

In the Copilot chat window, you will likely see some questions raised, similar to these. You can answer these just like in a normal chat conversation - e.g., by typing the letter standing for the option provided for each question, or by elaborating further if needed.

Specify Bootstrap Specify Bootstrap Specify Bootstrap Specify Bootstrap Specify Bootstrap

See a few examples of clarifying questions Copilot may ask. Copilot typically suggests a few options, but you can always deviate from them as needed, just use the chat to provide your answers.

## Clarifications

- Q: Backup & Recovery Strategy - Does the legacy workload require Azure Backup for VM and data disk? → A: No backups needed - VM and data disk are disposable, can be recreated from Terraform
- Q: VNet Address Space and Subnet Sizing - What specific CIDR allocations should be used for the 3 required subnets? → A: Minimal: 10.0.0.0/24 (VM: /27, Bastion: /26, PrivateEndpoint: /28) - tight fit, no growth
- Q: VM Size SKU Selection - Which specific Azure VM SKU should be used for the 2-core/8GB requirement? → A: Standard_D2s_v3 (General Purpose) - balanced, widely used, predictable performance
- Q: Azure Files Share Quota and Performance Tier - What provisioned capacity should the file share have? → A: 1TB Standard tier (LRS) - large capacity for growth
- Q: Log Analytics Workspace Retention Period - How long should diagnostic logs be retained? → A: 180 days retention - extended compliance coverage, moderate cost increase
- Q: How should the Azure file share be mounted on the Windows VM? → A: Post-deployment manual mount by administrator following documented procedure (no automation, aligns with IaC-first principle)
- Q: What level of monitoring and alerting should be configured for this legacy workload? → A: Diagnostic logging plus critical-only alerts (VM stopped, disk full, Key Vault access failures)
- Q: If the initial deployment partially fails (e.g., VM creates but Bastion fails), what should the recovery procedure be? → A: Keep existing resources, fix errors in template/parameters, redeploy entire template (ARM incremental mode handles already-deployed resources)
- Q: VM administrator username? → A: vmadmin
- Q: VNet address space and subnet sizing for VM, bastion, and private endpoint subnets? → A: VNet: 10.0.0.0/24, VM subnet: 10.0.0.0/27, Bastion subnet: 10.0.0.64/26, Private endpoint subnet: 10.0.0.128/27
- Q: Disk space alert threshold percentage? → A: 85% full
- Q: Alert notification method for critical alerts? → A: Azure Portal notifications only
  1. Review and approve the changes suggested by Copilot by clicking on the “Keep” button!
  2. It is recommended to make a commit now to capture the updated specification of your project, with a comment of something like Specification clarified.

4. Plan

Spec Kit uses /speckit.plan to generate the plan.md file. The plan can be evolved through iterating over the plan.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.plan, or leveraging /speckit.checklist to review/validate and challenge the plan.

Info

To learn more about what the plan should include, see the Plan chapter in the Spec Kit article.

Click through the tabs to see the details!

  1. Run the following prompt to generate the plan for our example:
​
/speckit.plan Create a detailed plan for the spec. Build with the latest version of Bicep and the latest available version of each AVM module. Use the "Bicep/list_avm_metadata" MCP tool to find out what's the latest version of each module. Only include direct resource references in the Bicep template if no related AVM resource modules are available. Similarly, for diagnostic settings, role assignments, resource locks, tags, managed identities, private endpoints, customer manged keys, etc., always use the related "interface" built-in to each resource module when available. Do not create and reference local modules, or any other bicep files. If a subset of the deployments fail, don't delete anything, just attempt redeploying the whole solution after fixing any bugs. Create a single main.bicep file, with direct references to AVM modules and leverage a single *.bicepparam file for all input parameters.

When generating the admin password for the VM, use the secret feature built into the AVM Key Vault module. Leverage the uniqueString function to generate a new random password and do not use any external helper script (including deployment scripts) for generating the password. Provide this password to the VM module by referencing the Key vault secret that stores it. The template must first generate this password including a random, complex string, using the uniqueString Bicep function, store it in Key Vault and then reference it for the VM to use it as admin password at deployment time.

Don't connect the file share to the VM just yet - i.e., no need to extract storage keys or shared access signatures - we will do this later.

If implementing resource level locks, always use the built-in AVM "interface" for resource locks, instead of directly deploying the "Microsoft.Authorization/locks" resource.

Bicep template must compile without warnings or errors using the latest stable Bicep CLI version. Create a bicepconfig.json file to generate a warning when not the latest version of an AVM module is used. You can configure this by making sure in the bicepconfig.json file, there is a node under analyzers/core/rules/use-recent-module-versions/level" with the value of "warning". Before validating the template or attempting the first deployment, always fix all warnings or errors related to the AVM module versioning by updating to the latest available version of each module.
➕ Expand to see the results

Notice how the plan step creates the plan.md file and a number of additional helper files. These may very depending on your prompts, the solution you are building, the version of Spec Kit and the LLM used. These typically include: data-model.md, research.md, quickstart.md and optional files in the contracts folder, such as outputs.md and parameters.md.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

In the Copilot chat window, you should see results, similar to this:
Specify Bootstrap

<!-- markdownlint-disable -->
# Implementation Plan: Legacy VM Workload

**Branch**: `001-legacy-vm-workload` | **Date**: 2026-01-27 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-legacy-vm-workload/spec.md`

**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.

## Summary

Deploy a legacy Windows Server 2016 virtual machine workload to Azure using Infrastructure-as-Code (Bicep) with the following capabilities:

- **Core Infrastructure**: Windows Server 2016 VM (Standard_D2s_v3) in availability zone 1, with system-assigned managed identity, 500GB HDD data disk, no public IP, deployed in dedicated VNet (10.0.0.0/24)
- **Secure Access**: Azure Bastion for RDP access, password stored in Key Vault, no direct internet exposure
- **Storage**: 1TB Azure Files share accessible via private endpoint from VM subnet
- **Network Security**: NAT Gateway for outbound internet, NSGs on all subnets with least-privilege rules, private endpoint for storage account
- **Monitoring**: Log Analytics workspace with diagnostic settings on all resources, three critical alerts (VM stopped, disk space >85%, Key Vault access failures), portal-only notifications
- **Naming**: Minimal naming convention with resource type prefix and 4-6 character random suffix
- **Region**: All resources in westus3 (US West 3)

**Technical Approach**: Single-template Bicep deployment using 12 Azure Verified Modules (latest stable versions), ARM-managed dependencies, parameter-driven configuration with uniqueString() for password generation.

## Technical Context

**IaC Language**: Bicep v0.33.0 or later (latest stable)
**Module Framework**: Azure Verified Modules (AVM) - 12 modules identified
**Target Region**: westus3 (US West 3)
**Deployment Tool**: Azure CLI v2.65.0+ (`az deployment group create`)
**Validation Required**: `bicep build` + `az deployment group validate` + `what-if` analysis
**Workload Type**: Legacy compliance-retained workload (Windows Server 2016)
**High Availability**: Single-zone deployment (availability zone parameter: 1, 2, or 3)
**Disaster Recovery**: Not required
**Scalability Requirements**: Static single VM, no auto-scaling
**Security Baseline**: Diagnostic logging to Log Analytics, managed identities, NSGs, private endpoints, Key Vault for secrets
**Naming Convention**: `{resourceType}-{purpose}-{random4-6chars}` (e.g., `vm-legacyvm-k7m3p`)
**Compliance Tags**: `workload: legacy-vm`, `environment: production`, `compliance: legacy-retention`

### AVM Modules Selected (Latest Versions)

| Module | Version | Purpose |
|--------|---------|---------|
| avm/res/network/virtual-network | 0.7.2 | VNet with 3 subnets (VM, Bastion, PE) |
| avm/res/compute/virtual-machine | 0.21.0 | Windows Server 2016 VM with data disk |
| avm/res/network/bastion-host | 0.8.2 | Secure RDP access |
| avm/res/storage/storage-account | 0.31.0 | File share with private endpoint |
| avm/res/network/nat-gateway | 2.0.1 | Outbound internet connectivity |
| avm/res/network/network-security-group | 0.5.2 | Subnet-level network security (3 NSGs) |
| avm/res/key-vault/vault | 0.13.3 | Store VM admin password |
| avm/res/operational-insights/workspace | 0.15.0 | Centralized logging |
| avm/res/network/private-endpoint | 0.11.1 | Private storage access |
| avm/res/insights/metric-alert | 0.4.1 | Monitoring alerts (3 alerts) |
| avm/res/network/private-dns-zone | 0.8.0 | DNS for private endpoints |

**Module Documentation**: See [research.md](./research.md) for detailed module analysis and alternatives considered.

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

- [x] **I. Infrastructure-as-Code First**: ✅ All resources defined in single `main.bicep` template, no manual Portal configurations, deployment via Azure CLI only
- [x] **II. AVM-Only Modules**: ✅ Using 12 AVM modules with latest stable versions (0.7.2 to 2.0.1), no direct resource declarations except where AVM unavailable (none identified)
- [x] **III. Validation Before Deployment**: ✅ Quickstart guide includes `bicep build`, `az deployment group validate`, and `what-if` analysis steps before deployment
- [x] **IV. Security & Reliability First**: ✅ Diagnostic logs to Log Analytics for all resources, managed identities (VM system-assigned), NSGs on all subnets, private endpoint for storage, Key Vault for password, least-privilege RBAC
- [x] **V. Minimal Naming with Type ID**: ✅ Naming pattern: `{type}-{purpose}-{random}` (e.g., `vm-legacyvm-k7m3p`, `kv-legacyvm-k7m3p`), random suffix via `uniqueString(resourceGroup().id)` - 6 chars
- [x] **VI. Region Standardization**: ✅ All resources deploy to westus3, parameter default set to `'westus3'`, no exceptions needed

**Constitution Compliance Status**: **PASSED** ✅

All 6 constitution principles satisfied. No violations requiring justification.

## Project Structure

### Documentation (this feature)

## Project Structure

### Documentation (this feature)

```text
specs/001-legacy-vm-workload/
├── spec.md              # Feature specification (completed)
├── plan.md              # This file - implementation plan (in progress)
├── research.md          # AVM module research (Phase 0 - completed)
├── data-model.md        # Network topology & resource model (Phase 1 - completed)
├── quickstart.md        # Deployment guide (Phase 1 - completed)
├── checklists/
│   └── requirements.md  # Quality validation checklist (passed)
└── tasks.md             # Task decomposition (Phase 2 - not yet created)
```

### Infrastructure Code Structure

```text
avm-workload/
├── infra/
│   ├── main.bicep           # Main deployment template (to be created)
│   ├── main.bicepparam      # Deployment parameters (to be created)
│   └── bicepconfig.json     # Bicep analyzer configuration (to be created)
├── specs/
│   └── 001-legacy-vm-workload/  # This feature documentation
├── .specify/
│   ├── memory/
│   │   └── constitution.md  # Governance framework
│   ├── templates/           # SpecKit templates
│   └── scripts/             # SpecKit automation scripts
└── README.md                # Project overview (to be created)
```

**Structure Decision**: Single-template Infrastructure-as-Code approach using Bicep with Azure Verified Modules. All 20+ resources (VM, VNet, Bastion, Storage, Key Vault, NSGs, NAT Gateway, Log Analytics, Alerts, Private Endpoint, DNS Zone, diagnostic settings, RBAC assignments) defined in one `main.bicep` file with ARM handling dependency ordering automatically. No custom modules needed - all functionality provided by AVM modules.

## Complexity Tracking

**No Constitution Violations** - This section is intentionally empty.

All 6 constitution principles are satisfied with no exceptions required. See Constitution Check section above for detailed compliance status.

---

## Implementation Phases

### Phase 0: Research & Architecture ✅ COMPLETED

**Objective**: Identify required AVM modules, resolve technical unknowns, document architectural decisions.

**Artifacts Created**:
- ✅ [research.md](./research.md) - AVM module inventory with latest versions, alternatives considered, implementation patterns
- ✅ [data-model.md](./data-model.md) - Network topology, resource dependencies, configuration model, security model
- ✅ [quickstart.md](./quickstart.md) - Step-by-step deployment guide with validation commands

**Key Decisions**:
1. **VM Password Generation**: Use Bicep `uniqueString()` function with multiple seeds (resourceGroup().id, deployment().name) - no external scripts needed
2. **Network Addressing**: VNet 10.0.0.0/24 with VM subnet /27, Bastion subnet /26, PE subnet /27
3. **Storage Access**: Private endpoint with private DNS zone integration (privatelink.file.core.windows.net)
4. **Naming Pattern**: `{type}-{purpose}-{uniqueString(6)}` for all resources
5. **Dependency Management**: Single-template approach, let ARM handle resource ordering automatically
6. **Diagnostic Settings**: All resources send logs/metrics to centralized Log Analytics workspace

**Unknowns Resolved**:
- ✅ VNet sizing: 10.0.0.0/24 confirmed sufficient (256 IPs)
- ✅ File share quota: 1TB (1024 GiB)
- ✅ Disk alert threshold: 85%
- ✅ Notification method: Azure Portal only (no Action Groups)
- ✅ VM size: Standard_D2s_v3
- ✅ AVM modules exist for all 11 required Azure resource types

### Phase 1: Infrastructure Code Implementation 🔄 IN PROGRESS

**Objective**: Create Bicep templates with AVM module references, parameter file, and configuration.

#### Task 1.1: Create bicepconfig.json ⏳ PENDING

**File**: `infra/bicepconfig.json`

**Purpose**: Configure Bicep analyzer to enforce AVM best practices and warn on outdated module versions.

**Configuration**:
```json
{
  "analyzers": {
    "core": {
      "enabled": true,
      "rules": {
        "use-recent-module-versions": {
          "level": "warning"
        }
      }
    }
  },
  "moduleAliases": {
    "br": {
      "public": {
        "registry": "mcr.microsoft.com",
        "modulePath": "bicep"
      }
    }
  }
}
```

**Validation**: Run `bicep build main.bicep` and verify no analyzer warnings.

#### Task 1.2: Create main.bicep ⏳ PENDING

**File**: `infra/main.bicep`

**Purpose**: Single deployment template referencing 12 AVM modules with proper parameters.

**Structure** (700-900 lines estimated):

1. **Header** (lines 1-30):
  - Metadata: name, description, owner
  - Target scope: `targetScope = 'resourceGroup'`
  - Parameters: vmSize, vmAdminUsername, availabilityZone, fileShareQuotaGiB, logAnalyticsRetentionDays

2. **Variables** (lines 31-80):
  - Random suffix: `var suffix = uniqueString(resourceGroup().id)`
  - Resource names: all following `{type}-{purpose}-${suffix}` pattern
  - VM password: `var vmPassword = 'P@ssw0rd!${uniqueString(resourceGroup().id, deployment().name)}'`
  - Network configuration: subnet CIDR blocks, NSG rules
  - Tags: workload, environment, compliance, managedBy, deploymentDate

3. **Log Analytics Workspace** (lines 81-110):
  ```bicep
  module logAnalytics 'br/public:avm/res/operational-insights/workspace:0.15.0' = {
    name: 'deploy-log-analytics'
    params: {
      name: 'law-legacyvm-${suffix}'
      location: location
      retentionInDays: logAnalyticsRetentionDays
      tags: tags
    }
  }
  ```

4. **Virtual Network** (lines 111-200):
  - Module: `avm/res/network/virtual-network:0.7.2`
  - 3 subnets: VM (10.0.0.0/27), Bastion (10.0.0.64/26), PE (10.0.0.128/27)
  - Diagnostic settings to Log Analytics

5. **Network Security Groups** (lines 201-350):
  - Module: `avm/res/network/network-security-group:0.5.2` (3 instances)
  - NSG 1: VM subnet (deny all inbound, allow internet + VNet outbound)
  - NSG 2: Bastion subnet (standard Azure Bastion rules)
  - NSG 3: PE subnet (allow VM subnet inbound on 445, allow all outbound)
  - Associate each NSG with its subnet
  - Diagnostic settings to Log Analytics

6. **NAT Gateway** (lines 351-380):
  - Module: `avm/res/network/nat-gateway:2.0.1`
  - Public IP auto-created
  - Associate with VM subnet
  - Diagnostic settings to Log Analytics

7. **Azure Bastion** (lines 381-410):
  - Module: `avm/res/network/bastion-host:0.8.2`
  - Depends on VNet and Bastion NSG
  - Public IP auto-created
  - Diagnostic settings to Log Analytics

8. **Key Vault** (lines 411-470):
  - Module: `avm/res/key-vault/vault:0.13.3`
  - SKU: Standard
  - Access model: RBAC
  - Secret: VM admin password (generated variable)
  - RBAC assignment: VM managed identity → Key Vault Secrets User role
  - Diagnostic settings to Log Analytics

9. **Private DNS Zone** (lines 471-500):
  - Module: `avm/res/network/private-dns-zone:0.8.0`
  - Zone name: `privatelink.file.core.windows.net`
  - VNet link to main VNet
  - Depends on VNet

10. **Storage Account** (lines 501-580):
    - Module: `avm/res/storage/storage-account:0.31.0`
    - Kind: StorageV2, SKU: Standard_LRS
    - Public network access: Disabled
    - File share: 1024 GiB quota
    - Diagnostic settings to Log Analytics

11. **Private Endpoint** (lines 581-620):
    - Module: `avm/res/network/private-endpoint:0.11.1`
    - Service: file
    - Subnet: Private Endpoint subnet
    - DNS integration: Private DNS zone
    - Depends on Storage Account, VNet, Private DNS Zone

12. **Virtual Machine** (lines 621-730):
    - Module: `avm/res/compute/virtual-machine:0.21.0`
    - OS: Windows Server 2016
    - Size: Standard_D2s_v3
    - Admin username: parameter
    - Admin password: Key Vault secret reference
    - System-assigned managed identity
    - OS disk: Standard HDD
    - Data disk: 500GB Standard HDD, LUN 0
    - NIC: VM subnet, dynamic private IP, no public IP
    - Availability zone: parameter (1, 2, or 3)
    - Diagnostic settings to Log Analytics
    - Depends on VNet, Key Vault

13. **Metric Alerts** (lines 731-850):
    - Module: `avm/res/insights/metric-alert:0.4.1` (3 instances)
    - Alert 1: VM stopped (CPU < 1% for 15 min, Sev 0)
    - Alert 2: Disk space >85% (OS disk used %, Sev 0)
    - Alert 3: Key Vault access failures (SecretGet failures, Sev 0)
    - No action groups (portal-only notifications)
    - Depends on VM and Key Vault

14. **Outputs** (lines 851-900):
    - VM name and resource ID
    - Key Vault name and resource ID
    - Storage account name and file share name
    - Bastion name
    - Log Analytics workspace ID
    - VNet name and resource ID

**Key Implementation Notes**:
- Use AVM module built-in interfaces for diagnostic settings (NOT direct `Microsoft.Insights/diagnosticSettings` resources)
- Use AVM module built-in interfaces for RBAC role assignments (NOT direct `Microsoft.Authorization/roleAssignments` resources)
- Use AVM module built-in interfaces for locks if needed (NOT direct `Microsoft.Authorization/locks` resources)
- Reference Key Vault secret for VM password using AVM module's secret reference parameter
- ARM will automatically determine deployment order based on dependencies
- All resource names use `uniqueString(resourceGroup().id)` for suffix (6 chars, consistent across all resources)
- Storage account name: no hyphens (Azure requirement), format: `st${replace(suffix, '-', '')}` (max 24 chars)
- VM computer name: max 15 chars, format: `vm-${substring(suffix, 0, 10)}`

#### Task 1.3: Create main.bicepparam ⏳ PENDING

**File**: `infra/main.bicepparam`

**Purpose**: Parameter file for deployment with sensible defaults.

**Content**:
```bicep
using './main.bicep'

// VM Configuration
param vmSize = 'Standard_D2s_v3'
param vmAdminUsername = 'vmadmin'
param availabilityZone = 1

// Storage Configuration
param fileShareQuotaGiB = 1024

// Monitoring Configuration
param logAnalyticsRetentionDays = 30

// Optional overrides (uncomment to customize)
// param vmName = 'vm-custom-name'
// param keyVaultName = 'kv-custom-name'
```

**Validation**: Ensure all parameters match those defined in `main.bicep`.

#### Task 1.4: Create project README.md ⏳ PENDING

**File**: `README.md` (root of repository)

**Purpose**: Project overview with quickstart and links to detailed documentation.

**Sections**:
1. Project Overview
2. Architecture Summary (link to data-model.md)
3. Prerequisites (link to quickstart.md)
4. Quick Deployment (3-step process)
5. Documentation Links (spec.md, plan.md, research.md, quickstart.md)
6. Governance (link to constitution.md)
7. Support and Contributing

### Phase 2: Validation & Testing 🔄 NEXT PHASE

**Objective**: Validate Bicep templates and perform what-if analysis before deployment.

#### Task 2.1: Bicep Build Validation

```powershell
cd infra
bicep build main.bicep
# Expected: main.json created, zero warnings
```

**Success Criteria**:
- No compilation errors
- No analyzer warnings
- Output ARM JSON file created successfully

#### Task 2.2: ARM Template Validation

```powershell
az group create --name rg-legacyvm-test --location westus3

az deployment group validate `
  --resource-group rg-legacyvm-test `
  --template-file main.bicep `
  --parameters main.bicepparam `
  --verbose
```

**Success Criteria**:
- Validation passes with `provisioningState: Succeeded`
- No errors related to missing resource providers
- No errors related to invalid parameters

#### Task 2.3: What-If Analysis

```powershell
az deployment group what-if `
  --resource-group rg-legacyvm-test `
  --template-file main.bicep `
  --parameters main.bicepparam `
  --verbose
```

**Success Criteria**:
- All expected resources show as "Create" (green +)
- No unexpected deletions or modifications
- Resource count: 20-25 resources total
- No warnings about deprecated API versions

#### Task 2.4: Code Review Checklist

Manual review of `main.bicep`:

- [ ] All 12 AVM modules referenced with exact versions (no floating versions)
- [ ] All module versions match research.md documentation
- [ ] uniqueString() used consistently for resource name suffixes
- [ ] Storage account name respects 24-char limit and no-hyphen requirement
- [ ] VM computer name respects 15-char limit
- [ ] All subnets have NSG associations
- [ ] NAT Gateway associated with VM subnet only
- [ ] Bastion deployed to AzureBastionSubnet (exact name required)
- [ ] **VM subnet NSG allows inbound RDP (port 3389) from Bastion subnet (10.0.0.64/26) - CRITICAL for bastion connectivity**
- [ ] Private endpoint connects to correct storage service (file)
- [ ] Private DNS zone has VNet link
- [ ] Key Vault secret created with generated password
- [ ] VM references Key Vault secret for password (not plaintext)
- [ ] VM has system-assigned managed identity
- [ ] RBAC assignment: VM identity → Key Vault (Key Vault Secrets User role)
- [ ] All resources have diagnostic settings to Log Analytics
- [ ] All resources have tags applied
- [ ] All resources deploy to westus3 region
- [ ] VM data disk configured: 500GB, Standard HDD, LUN 0
- [ ] File share quota: 1024 GiB
- [ ] 3 metric alerts configured with correct thresholds
- [ ] No action groups on alerts (portal-only requirement)
- [ ] Comments explain each major section

### Phase 3: Deployment 🔄 FUTURE PHASE

**Objective**: Deploy infrastructure to Azure and verify all resources operational.

#### Task 3.1: Initial Deployment

**Pre-deployment**:
- Create resource group: `rg-legacyvm-prod`
- Ensure subscription quotas sufficient (VMs, Public IPs, etc.)
- Authenticate to Azure CLI with sufficient permissions

**Deployment Command**:
```powershell
az deployment group create `
  --name "legacyvm-$(Get-Date -Format 'yyyyMMdd-HHmmss')" `
  --resource-group rg-legacyvm-prod `
  --template-file infra/main.bicep `
  --parameters infra/main.bicepparam `
  --verbose
```

**Expected Duration**: 15-20 minutes

**Monitoring**: Track deployment progress in Azure Portal → Resource Groups → rg-legacyvm-prod → Deployments

#### Task 3.2: Post-Deployment Verification

Follow checklist in [quickstart.md](./quickstart.md):

1. **Resource Count Verification**:
  ```powershell
  az resource list --resource-group rg-legacyvm-prod --output table
  # Expected: 20-25 resources
  ```

2. **Bastion Connectivity Test**:
  - Retrieve VM password from Key Vault
  - Connect to VM via Azure Portal Bastion
  - Verify Windows Server 2016 desktop loads

3. **Log Analytics Verification**:
  - Run sample Kusto queries
  - Verify logs appearing for all resources
  - Check for any error logs

4. **Network Connectivity Tests** (from VM):
  - Test internet access via NAT Gateway
  - Verify private endpoint DNS resolution
  - Ping storage account private IP

5. **Alert Verification**:
  - Trigger test alert (Key Vault access failure)
  - Verify alert visible in Azure Portal within 5-10 minutes
  - Confirm alert severity (Sev 0)

#### Task 3.3: Documentation Updates

- Update README.md with actual deployed resource names
- Record deployment timestamp and duration
- Document any deployment issues encountered and resolutions
- Create CHANGELOG.md entry for initial deployment

### Phase 4: Operational Handoff 🔄 FUTURE PHASE

**Objective**: Provide operational documentation and ensure supportability.

#### Task 4.1: Operational Runbooks

Create runbooks for common operations:
- VM start/stop procedures
- File share quota increase
- Bastion troubleshooting
- Alert acknowledgment workflow
- Disaster recovery procedure (VM rebuild)

#### Task 4.2: Cost Monitoring Setup

Document estimated monthly costs:
- VM (Standard_D2s_v3): ~$70/month
- Storage (1TB + disks): ~$50/month
- Bastion: ~$140/month
- Other services: ~$10/month
- **Total**: ~$270/month (westus3 region)

Set up Azure Cost Management alerts:
- Budget: $300/month
- Alert threshold: 80% ($240)

#### Task 4.3: Security Review

Complete post-deployment security checklist:
- [ ] All resources have diagnostic logging enabled
- [ ] VM has no public IP address
- [ ] Storage account public access disabled
- [ ] Key Vault access restricted to VM managed identity
- [ ] NSG rules follow least-privilege principle
- [ ] Azure Security Center recommendations reviewed
- [ ] No high-severity vulnerabilities identified

#### Task 4.4: Compliance Documentation

Document compliance controls met:
- Infrastructure-as-Code: All resources in version control
- Audit logging: All activity logged to Log Analytics
- Secret management: Passwords in Key Vault, not plaintext
- Network isolation: Private endpoints, no public exposure
- Change management: Deployments require validation gate

### Phase 5: Future Enhancements 🔄 OUT OF SCOPE (DOCUMENTED FOR REFERENCE)

Items explicitly out of scope for initial deployment but may be added later:

1. **File Share VM Integration**:
  - Map file share as network drive in VM
  - Configure persistent drive mapping via Group Policy or startup script
  - Document in operational runbooks

2. **Advanced Monitoring**:
  - Custom Log Analytics queries and workbooks
  - Action Groups for email/SMS notifications
  - Integration with external monitoring systems

3. **Backup Configuration**:
  - Azure Backup for VM
  - Azure Files snapshot/backup policies
  - Backup retention policy aligned with compliance requirements

4. **High Availability** (if requirements change):
  - Availability Set or multiple VMs across zones
  - Load Balancer for multi-VM scenarios
  - Azure Site Recovery for disaster recovery

5. **Security Enhancements**:
  - Just-In-Time VM Access
  - Azure Policy assignments
  - Microsoft Defender for Cloud integration
  - Network Watcher flow logs

---

## Risk Assessment & Mitigation

### Technical Risks

| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| AVM module breaking changes | Low | Medium | Pin exact module versions, monitor AVM changelogs |
| Bastion deployment timeout | Medium | Low | Allow 20-30 min deployment window, retry if needed |
| Key Vault access issues | Low | High | Thorough RBAC testing, fallback manual password retrieval |
| Private endpoint DNS resolution failures | Low | Medium | Verify Private DNS Zone VNet link, wait for propagation |
| VM extension failures (Windows diagnostics) | Medium | Low | Monitor deployment, acceptable to complete manually |
| Storage account naming conflicts | Low | Low | uniqueString() ensures uniqueness per resource group |

### Operational Risks

| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Lost VM password | Low | High | Key Vault provides secure retrieval, document procedure |
| Excessive costs (Bastion always-on) | Medium | Medium | Document monthly costs, set budget alerts at 80% |
| Alert fatigue (too many alerts) | Low | Medium | Start with 3 critical alerts, expand based on operations feedback |
| Insufficient disk space (500GB data disk) | Medium | Low | Alert at 85%, expansion procedure documented |
| Network connectivity issues | Low | High | NAT Gateway provides reliable outbound, private endpoint for inbound |
| Manual Portal changes breaking IaC | Medium | High | Enforce constitution principle I, document prohibition clearly |

### Compliance Risks

| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Configuration drift from manual changes | Medium | High | Constitution principle I (IaC-First) prohibits manual changes |
| Audit log gaps | Low | High | All resources send logs to Log Analytics, monitor for gaps |
| Secret exposure (VM password) | Low | Critical | Password generated in Bicep variable, stored only in Key Vault |
| Unauthorized access attempts | Low | High | No public IPs, Bastion-only access, NSG least-privilege rules |
| Non-compliance with retention policies | Low | Medium | Log Analytics retention set to 30+ days, configurable parameter |

---

## Success Criteria

### Deployment Success

- [x] All Bicep templates compile without warnings
- [ ] ARM validation passes successfully
- [ ] What-if analysis shows expected resource creation only
- [ ] Deployment completes in <30 minutes
- [ ] All 20-25 resources created successfully
- [ ] No failed resources or partial deployments

### Functional Success

- [ ] VM accessible via Azure Bastion RDP
- [ ] VM admin password retrievable from Key Vault
- [ ] VM has internet connectivity via NAT Gateway
- [ ] File share accessible from VM via private endpoint
- [ ] Storage account private endpoint resolves to internal IP (10.0.0.128/27 range)
- [ ] All resources logging to Log Analytics
- [ ] 3 metric alerts visible in Azure Portal
- [ ] Test alert fires successfully within 10 minutes

### Compliance Success

- [x] All 6 constitution principles satisfied (see Constitution Check)
- [ ] All resources deployed to westus3 region
- [ ] All resources follow naming convention: {type}-{purpose}-{random}
- [ ] No manual Portal configurations required post-deployment
- [ ] Deployment process documented in quickstart.md
- [ ] All secrets stored in Key Vault, none in version control

### Operational Success

- [ ] Quickstart guide executed successfully by independent tester
- [ ] VM operational for 24 hours with no errors
- [ ] Monitoring dashboards show healthy resource state
- [ ] No high-severity Security Center alerts
- [ ] Total monthly cost projection: $250-$300 USD
- [ ] Documentation sufficient for operational handoff

---

## Appendices

### A. Resource Naming Reference

| Resource Type | Name Pattern | Example |
|---------------|-------------|---------|
| Virtual Machine | `vm-legacyvm-{random}` | `vm-legacyvm-k7m3p` |
| Virtual Network | `vnet-legacyvm-{random}` | `vnet-legacyvm-k7m3p` |
| Subnets | `snet-{purpose}-legacyvm-{random}` | `snet-vm-legacyvm-k7m3p` |
| Network Security Group | `nsg-{purpose}-legacyvm-{random}` | `nsg-vm-legacyvm-k7m3p` |
| NAT Gateway | `nat-legacyvm-{random}` | `nat-legacyvm-k7m3p` |
| Azure Bastion | `bas-legacyvm-{random}` | `bas-legacyvm-k7m3p` |
| Key Vault | `kv-legacyvm-{random}` | `kv-legacyvm-k7m3p` |
| Storage Account | `st{random-no-hyphens}` | `stk7m3p2a` |
| Log Analytics Workspace | `law-legacyvm-{random}` | `law-legacyvm-k7m3p` |
| Metric Alert | `alert-{purpose}-legacyvm-{random}` | `alert-disk-space-legacyvm-k7m3p` |
| Private Endpoint | `pe-{service}-legacyvm-{random}` | `pe-file-legacyvm-k7m3p` |
| Private DNS Zone | `privatelink.{service}.core.windows.net` | `privatelink.file.core.windows.net` |

**Random Suffix**: Generated using `uniqueString(resourceGroup().id)` - produces 13 characters, take first 6: `k7m3p2`

### B. Network Address Allocation

| Component | CIDR | Usable IPs | Purpose |
|-----------|------|------------|---------|
| VNet | 10.0.0.0/24 | 256 | Overall network |
| VM Subnet | 10.0.0.0/27 | 27 usable | Virtual machine NICs |
| Bastion Subnet | 10.0.0.64/26 | 59 usable | Azure Bastion (requires /26) |
| Private Endpoint Subnet | 10.0.0.128/27 | 27 usable | Storage private endpoint |
| Reserved | 10.0.0.160/27 | 27 usable | Future expansion |
| Reserved | 10.0.0.192/26 | 59 usable | Future expansion |

### C. Required Azure Permissions

**Subscription-Level**:
- `Contributor` role OR specific resource provider permissions
- `User Access Administrator` role (for RBAC assignments to Key Vault)

**Resource Providers** (must be registered):
- `Microsoft.Compute`
- `Microsoft.Network`
- `Microsoft.Storage`
- `Microsoft.KeyVault`
- `Microsoft.Insights`
- `Microsoft.OperationalInsights`

### D. Tool Version Requirements

| Tool | Minimum Version | Recommended Version | Check Command |
|------|----------------|---------------------|---------------|
| Azure CLI | 2.65.0 | Latest | `az --version` |
| Bicep CLI | 0.33.0 | Latest | `az bicep version` |
| PowerShell | 7.4 | Latest | `$PSVersionTable.PSVersion` |

### E. Related Documentation

- **Feature Specification**: [spec.md](./spec.md) - Detailed requirements and user stories
- **Module Research**: [research.md](./research.md) - AVM module analysis and alternatives
- **Data Model**: [data-model.md](./data-model.md) - Network topology and resource relationships
- **Deployment Guide**: [quickstart.md](./quickstart.md) - Step-by-step deployment instructions
- **Quality Checklist**: [checklists/requirements.md](./checklists/requirements.md) - Validation results
- **Governance**: [../../.specify/memory/constitution.md](../../.specify/memory/constitution.md) - Project constitution

### F. Change Log

| Date | Version | Author | Changes |
|------|---------|--------|---------|
| 2026-01-27 | 1.0.0 | SpecKit | Initial implementation plan created |

---

**Plan Status**: Phase 1 (Infrastructure Code Implementation) - Ready for bicepconfig.json and main.bicep creation

**Next Command**: `/speckit.tasks` to decompose Phase 1 tasks into granular implementation steps
<!-- markdownlint-disable -->
# Data Model: Legacy VM Workload Infrastructure

**Date**: 2026-01-27
**Feature**: [spec.md](../spec.md)
**Purpose**: Document network topology, resource relationships, and configuration data model

## Network Topology

### Virtual Network Structure

```
VNet: 10.0.0.0/24 (vnet-legacyvm-{random})
│
├── VM Subnet: 10.0.0.0/27 (snet-vm-legacyvm-{random})
│   ├── NAT Gateway attached
│   ├── NSG attached (nsg-vm-legacyvm-{random})
│   ├── VM Network Interface
│   └── Hosts: Virtual Machine
│
├── Bastion Subnet: 10.0.0.64/26 (AzureBastionSubnet - required name)
│   ├── NSG attached (nsg-bastion-legacyvm-{random})
│   └── Hosts: Azure Bastion
│
└── Private Endpoint Subnet: 10.0.0.128/27 (snet-pe-legacyvm-{random})
    ├── NSG attached (nsg-pe-legacyvm-{random})
    └── Hosts: Storage Account Private Endpoint
```

### Address Space Allocation

| Resource | CIDR | Usable IPs | Purpose |
|----------|------|------------|---------|
| VNet | 10.0.0.0/24 | 256 | Overall network |
| VM Subnet | 10.0.0.0/27 | 32 (27 usable) | Virtual machine network interfaces |
| Bastion Subnet | 10.0.0.64/26 | 64 (59 usable) | Azure Bastion (requires /26 minimum) |
| Private Endpoint Subnet | 10.0.0.128/27 | 32 (27 usable) | Storage account private endpoints |
| Reserved | 10.0.0.160/27 | 32 | Future expansion |
| Reserved | 10.0.0.192/26 | 64 | Future expansion |

## Resource Dependency Graph

```
Resource Group
│
├── Log Analytics Workspace
│   └── (Used by all diagnostic settings)
│
├── Virtual Network
│   ├── Depends on: None
│   └── Used by: Bastion, VM NIC, Private Endpoint
│
├── Network Security Groups (3)
│   ├── nsg-vm-legacyvm-{random}
│   ├── nsg-bastion-legacyvm-{random}
│   └── nsg-pe-legacyvm-{random}
│   ├── Depends on: VNet (for subnet association)
│   └── Diagnostic settings → Log Analytics
│
├── NAT Gateway
│   ├── Public IP (auto-created)
│   ├── Depends on: None
│   ├── Associated with: VM Subnet
│   └── Diagnostic settings → Log Analytics
│
├── Azure Bastion
│   ├── Public IP (auto-created)
│   ├── Depends on: VNet (Bastion subnet)
│   ├── Depends on: NSG (bastion subnet)
│   └── Diagnostic settings → Log Analytics
│
├── Key Vault
│   ├── Depends on: None (deployed early)
│   ├── Secret: VM admin password (generated)
│   ├── RBAC: VM managed identity (Key Vault Secrets User)
│   └── Diagnostic settings → Log Analytics
│
├── Private DNS Zone
│   ├── Name: privatelink.file.core.windows.net
│   ├── VNet Link: Main VNet
│   └── Depends on: VNet
│
├── Storage Account
│   ├── File Share (1024 GiB)
│   ├── Depends on: None
│   ├── Public access: Disabled
│   ├── Diagnostic settings → Log Analytics
│   └── Private Endpoint
│       ├── Depends on: Storage Account, VNet, Private DNS Zone
│       ├── Subnet: Private Endpoint Subnet
│       └── DNS integration: Private DNS Zone
│
└── Virtual Machine
    ├── Depends on: VNet, Key Vault (for password)
    ├── Managed Identity: System-assigned
    ├── OS Disk: Standard HDD
    ├── Data Disk: 500GB Standard HDD
    ├── Network Interface
    │   ├── Depends on: VM Subnet
    │   └── No Public IP
    ├── Password: Retrieved from Key Vault secret
    └── Diagnostic settings → Log Analytics

├── Azure Monitor Alerts (3)
    ├── VM Stopped Alert
    │   └── Depends on: VM
    ├── Disk Space Alert
    │   └── Depends on: VM
    └── Key Vault Access Failures Alert
        └── Depends on: Key Vault
```

## Resource Configuration Model

### Virtual Machine
```yaml
Name Pattern: vm-legacyvm-{random}
Computer Name: vm-{random} (≤15 chars total)
Configuration:
  Size: Standard_D2s_v3
  OS: Windows Server 2016
  OS Disk:
    Type: Standard_LRS (HDD performance tier)
    Size: Default (127 GB or OS default)
  Data Disks:
    - Name: datadisk-01
      Size: 500 GB
      Type: Standard_LRS (HDD performance tier)
      LUN: 0
  Admin:
    Username: vmadmin
    Password: {From Key Vault secret}
  Identity:
    Type: SystemAssigned
  Zone: {Parameter: 1, 2, or 3}
  Network:
    NIC:
      Subnet: VM Subnet
      Public IP: None
      Private IP: Dynamic
  Diagnostics:
    Boot Diagnostics: Enabled (Managed)
    Guest Diagnostics: Windows (via Log Analytics agent)
```

### Key Vault
```yaml
Name Pattern: kv-legacyvm-{random}
Configuration:
  SKU: Standard
  Access Model: RBAC (Azure role-based access control)
  Public Network Access: Enabled (simplified for legacy workload)
  Soft Delete: Enabled (90 days)
  Purge Protection: Disabled (not required for legacy workload)
  Secrets:
    - Name: {Parameter: vmAdminPasswordSecretName}
      Value: {Generated: uniqueString-based password}
      Content Type: text/plain
  RBAC Assignments:
    - Principal: VM Managed Identity
      Role: Key Vault Secrets User
      Scope: Key Vault
  Diagnostics:
    Logs: All categories
    Metrics: All metrics
    Destination: Log Analytics
```

### Storage Account
```yaml
Name Pattern: st{random-no-hyphens} (≤24 chars)
Configuration:
  Kind: StorageV2
  SKU: Standard_LRS (HDD-based)
  Access Tier: Hot
  Public Network Access: Disabled
  Minimum TLS: 1.2
  File Services:
    Shares:
      - Name: fileshare
        Quota: 1024 GiB
        Access Tier: TransactionOptimized
  Private Endpoints:
    - Service: file
      Subnet: Private Endpoint Subnet
      DNS Integration: privatelink.file.core.windows.net
  Diagnostics:
    Logs: All categories (StorageRead, StorageWrite, StorageDelete)
    Metrics: All metrics
    Destination: Log Analytics
```

### Network Security Groups

#### VM Subnet NSG
```yaml
Name: nsg-vm-legacyvm-{random}
Security Rules:
  Inbound:
    - Name: DenyAllInbound
      Priority: 4096
      Direction: Inbound
      Access: Deny
      Protocol: *
      Source: *
      Destination: *
      SourcePort: *
      DestinationPort: *
  Outbound:
    - Name: AllowInternetOutbound
      Priority: 100
      Direction: Outbound
      Access: Allow
      Protocol: *
      Source: *
      Destination: Internet
      SourcePort: *
      DestinationPort: *
    - Name: AllowVnetOutbound
      Priority: 200
      Direction: Outbound
      Access: Allow
      Protocol: *
      Source: *
      Destination: VirtualNetwork
      SourcePort: *
      DestinationPort: *
    - Name: DenyAllOutbound
      Priority: 4096
      Direction: Outbound
      Access: Deny
      Protocol: *
      Source: *
      Destination: *
      SourcePort: *
      DestinationPort: *
```

#### Bastion Subnet NSG
```yaml
Name: nsg-bastion-legacyvm-{random}
Security Rules:
  # Standard Azure Bastion required rules
  Inbound:
    - Name: AllowHttpsInbound
      Priority: 100
      Direction: Inbound
      Access: Allow
      Protocol: Tcp
      Source: Internet
      Destination: *
      SourcePort: *
      DestinationPort: 443
    - Name: AllowGatewayManagerInbound
      Priority: 110
      Direction: Inbound
      Access: Allow
      Protocol: Tcp
      Source: GatewayManager
      Destination: *
      SourcePort: *
      DestinationPort: 443
    - Name: AllowAzureLoadBalancerInbound
      Priority: 120
      Direction: Inbound
      Access: Allow
      Protocol: Tcp
      Source: AzureLoadBalancer
      Destination: *
      SourcePort: *
      DestinationPort: 443
    - Name: AllowBastionHostCommunication
      Priority: 130
      Direction: Inbound
      Access: Allow
      Protocol: *
      Source: VirtualNetwork
      Destination: VirtualNetwork
      SourcePort: *
      DestinationPort: 8080,5701
  Outbound:
    - Name: AllowSshRdpOutbound
      Priority: 100
      Direction: Outbound
      Access: Allow
      Protocol: *
      Source: *
      Destination: VirtualNetwork
      SourcePort: *
      DestinationPort: 22,3389
    - Name: AllowAzureCloudOutbound
      Priority: 110
      Direction: Outbound
      Access: Allow
      Protocol: Tcp
      Source: *
      Destination: AzureCloud
      SourcePort: *
      DestinationPort: 443
    - Name: AllowBastionCommunication
      Priority: 120
      Direction: Outbound
      Access: Allow
      Protocol: *
      Source: VirtualNetwork
      Destination: VirtualNetwork
      SourcePort: *
      DestinationPort: 8080,5701
    - Name: AllowGetSessionInformation
      Priority: 130
      Direction: Outbound
      Access: Allow
      Protocol: *
      Source: *
      Destination: Internet
      SourcePort: *
      DestinationPort: 80
```

#### Private Endpoint Subnet NSG
```yaml
Name: nsg-pe-legacyvm-{random}
Security Rules:
  Inbound:
    - Name: AllowVMSubnetInbound
      Priority: 100
      Direction: Inbound
      Access: Allow
      Protocol: Tcp
      Source: 10.0.0.0/27
      Destination: *
      SourcePort: *
      DestinationPort: 445
    - Name: DenyAllInbound
      Priority: 4096
      Direction: Inbound
      Access: Deny
      Protocol: *
      Source: *
      Destination: *
      SourcePort: *
      DestinationPort: *
  Outbound:
    - Name: AllowAllOutbound
      Priority: 100
      Direction: Outbound
      Access: Allow
      Protocol: *
      Source: *
      Destination: *
      SourcePort: *
      DestinationPort: *
```

### Azure Monitor Alerts

#### Alert 1: VM Stopped/Deallocated
```yaml
Name: alert-vm-stopped-legacyvm-{random}
Configuration:
  Type: Metric
  Target: Virtual Machine
  Metric:
    Namespace: Microsoft.Compute/virtualMachines
    Name: Percentage CPU
  Condition:
    Operator: LessThan
    Threshold: 1
    Aggregation: Average
    Window: 15 minutes
  Severity: Critical (Sev 0)
  Auto-Mitigate: false
  Description: "Critical: VM appears to be stopped or deallocated"
```

#### Alert 2: Disk Space Exceeded
```yaml
Name: alert-disk-space-legacyvm-{random}
Configuration:
  Type: Metric
  Target: Virtual Machine
  Metric:
    Namespace: Microsoft.Compute/virtualMachines
    Name: OS Disk Used Percentage
  Condition:
    Operator: GreaterThan
    Threshold: 85
    Aggregation: Average
    Window: 5 minutes
  Severity: Critical (Sev 0)
  Auto-Mitigate: false
  Description: "Critical: Disk space exceeded 85% threshold"
```

#### Alert 3: Key Vault Access Failures
```yaml
Name: alert-kv-access-fail-legacyvm-{random}
Configuration:
  Type: Metric
  Target: Key Vault
  Metric:
    Namespace: Microsoft.KeyVault/vaults
    Name: ServiceApiHit
  Filter:
    Dimension: ActivityName
    Values: SecretGet
    Result: Failed
  Condition:
    Operator: GreaterThan
    Threshold: 0
    Aggregation: Count
    Window: 5 minutes
  Severity: Critical (Sev 0)
  Auto-Mitigate: false
  Description: "Critical: Key Vault secret access failures detected"
```

## Deployment Sequence

Based on ARM dependency analysis, resources will deploy in this approximate order:

1. **Phase 1: Foundation** (Parallel)
  - Log Analytics Workspace
  - Virtual Network (with subnets)
  - Network Security Groups

2. **Phase 2: Network & Security** (Depends on Phase 1)
  - NAT Gateway (associates with VM subnet)
  - Azure Bastion (requires subnet and NSG)
  - Private DNS Zone (requires VNet)
  - Key Vault (generates and stores password)

3. **Phase 3: Storage** (Depends on Phase 2)
  - Storage Account (with file share)
  - Private Endpoint (requires storage account, VNet, DNS zone)

4. **Phase 4: Compute** (Depends on Phases 1-3)
  - Virtual Machine (requires VNet, Key Vault secret, zone assignment)

5. **Phase 5: Monitoring** (Depends on Phase 4)
  - Azure Monitor Alerts (require VM and Key Vault to be deployed)

## Parameter Data Model

```yaml
# Required Parameters
parameters:
  resourceGroupName: string
    description: Name of the resource group for deployment
    example: rg-legacyvm-prod

  location: string
    description: Azure region for deployment
    default: westus3
    validation: Must be valid Azure region

  vmSize: string
    description: Virtual machine size
    default: Standard_D2s_v3
    validation: Must support Windows Server 2016

  vmAdminUsername: string
    description: VM administrator username
    default: vmadmin
    minLength: 1
    maxLength: 20

  vmAdminPasswordSecretName: string
    description: Name of Key Vault secret for VM admin password
    default: vm-admin-password
    minLength: 1
    maxLength: 127

  availabilityZone: int
    description: Availability zone for zone-capable resources
    allowed: [1, 2, 3]
    default: 1

  fileShareQuotaGiB: int
    description: File share quota in GiB
    default: 1024
    minValue: 100
    maxValue: 102400

  logAnalyticsRetentionDays: int
    description: Log Analytics data retention in days
    default: 30
    minValue: 30
    maxValue: 730

# Generated Values (not parameters)
variables:
  randomSuffix: uniqueString(resourceGroup().id)
  vmPassword: P@ssw0rd!{uniqueString(resourceGroup().id, deployment().name)}

  # Resource Names
  vnetName: vnet-legacyvm-{randomSuffix}
  vmName: vm-legacyvm-{randomSuffix}
  storageAccountName: st{replace(randomSuffix, '-', '')}
  keyVaultName: kv-legacyvm-{randomSuffix}
  logAnalyticsName: law-legacyvm-{randomSuffix}
```

## Tags Model

All resources will be tagged with:

```yaml
tags:
  workload: legacy-vm
  environment: production
  compliance: legacy-retention
  managedBy: bicep-avm
  deploymentDate: {deployment().timestamp}
```

## Security Model

### RBAC Assignments

| Principal | Role | Scope | Purpose |
|-----------|------|-------|---------|
| VM Managed Identity | Key Vault Secrets User | Key Vault | Read VM admin password |
| VM Managed Identity | Storage Blob Data Contributor | Storage Account | Access file share (future) |

### Network Security

| Source | Destination | Protocol/Port | Action | Purpose |
|--------|-------------|---------------|--------|---------|
| Internet | Bastion (443) | TCP/443 | Allow | Admin RDP access |
| Bastion | VM (3389) | TCP/3389 | Allow | RDP to VM |
| VM | Internet | Any | Allow | Outbound via NAT Gateway |
| VM | Storage PE (445) | TCP/445 | Allow | File share access |
| Any | VM | Any | Deny | No direct access to VM |

## Monitoring Data Model

### Diagnostic Settings Targets

All resources with diagnostic settings send to:
- **Primary**: Log Analytics Workspace
- **Categories**: All available log categories
- **Metrics**: All available metrics

### Alert Notification Model

- **Channel**: Azure Portal only
- **No Action Groups**: Alerts visible in portal alerts blade
- **Severity**: All set to Critical (Sev 0)
- **Auto-Mitigation**: Disabled (require manual acknowledgment)
<!-- markdownlint-disable -->
# Research: Legacy VM Workload AVM Modules

**Date**: 2026-01-27
**Feature**: [spec.md](../spec.md)
**Purpose**: Research and document AVM module selections, versions, and configuration approaches

## AVM Module Inventory

### Primary Infrastructure Modules

#### 1. Virtual Network
- **Module**: `avm/res/network/virtual-network`
- **Latest Version**: 0.7.2
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/network/virtual-network/0.7.2/avm/res/network/virtual-network/README.md
- **Decision**: Use this module for VNet and subnet deployment
- **Rationale**: Official AVM module with built-in support for subnets, NSG assignments, NAT gateway association, and diagnostic settings
- **Key Parameters Needed**:
  - Address space: 10.0.0.0/24
  - Subnets: VM (10.0.0.0/27), Bastion (10.0.0.64/26), Private endpoint (10.0.0.128/27)
  - NSG associations per subnet
  - NAT gateway assignment to VM subnet

#### 2. Virtual Machine
- **Module**: `avm/res/compute/virtual-machine`
- **Latest Version**: 0.21.0
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/compute/virtual-machine/0.21.0/avm/res/compute/virtual-machine/README.md
- **Decision**: Use this module for VM deployment
- **Rationale**: Comprehensive AVM module with built-in support for managed disks, managed identity, diagnostic settings, and guest configuration
- **Key Parameters Needed**:
  - VM size: Standard_D2s_v3
  - OS: Windows Server 2016
  - Computer name: ≤15 characters
  - Admin username: vmadmin
  - Admin password: Reference to Key Vault secret
  - Managed identity: System-assigned
  - Data disks: 500GB HDD
  - Availability zone: 1-3 (parameter-driven)
  - No public IP

#### 3. Azure Bastion
- **Module**: `avm/res/network/bastion-host`
- **Latest Version**: 0.8.2
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/network/bastion-host/0.8.2/avm/res/network/bastion-host/README.md
- **Decision**: Use this module for bastion deployment
- **Rationale**: AVM module with built-in diagnostic settings and public IP creation
- **Key Parameters Needed**:
  - Subnet: Bastion subnet (10.0.0.64/26)
  - SKU: Basic (cost-effective for legacy workload)
  - Diagnostic settings to Log Analytics

#### 4. Storage Account
- **Module**: `avm/res/storage/storage-account`
- **Latest Version**: 0.31.0
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/storage/storage-account/0.31.0/avm/res/storage/storage-account/README.md
- **Decision**: Use this module for storage account and file share
- **Rationale**: Comprehensive AVM module with built-in file share, private endpoint, diagnostic settings, and network rules support
- **Key Parameters Needed**:
  - SKU: Standard_LRS (HDD-based)
  - File share quota: 1024 GiB
  - Private endpoint enabled
  - Public network access disabled
  - Diagnostic settings to Log Analytics

#### 5. NAT Gateway
- **Module**: `avm/res/network/nat-gateway`
- **Latest Version**: 2.0.1
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/network/nat-gateway/2.0.1/avm/res/network/nat-gateway/README.md
- **Decision**: Use this module for NAT gateway
- **Rationale**: AVM module with public IP creation and zone support
- **Key Parameters Needed**:
  - Zone: parameter-driven (1-3)
  - Public IP: Auto-created by module

#### 6. Network Security Group
- **Module**: `avm/res/network/network-security-group`
- **Latest Version**: 0.5.2
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/network/network-security-group/0.5.2/avm/res/network/network-security-group/README.md
- **Decision**: Use this module for all three NSGs (VM subnet, bastion subnet, private endpoint subnet)
- **Rationale**: AVM module with built-in diagnostic settings and security rule definitions
- **Key Parameters Needed**:
  - VM subnet NSG: Allow outbound to internet via NAT gateway, deny other traffic
  - Bastion subnet NSG: Standard bastion rules (inbound 443, outbound to VM subnet)
  - Private endpoint subnet NSG: Allow traffic from VM subnet only

#### 7. Key Vault
- **Module**: `avm/res/key-vault/vault`
- **Latest Version**: 0.13.3
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/key-vault/vault/0.13.3/avm/res/key-vault/vault/README.md
- **Decision**: Use this module for Key Vault and secret storage
- **Rationale**: AVM module with built-in secret creation using `secrets` parameter array, RBAC support, diagnostic settings, and network rules
- **Key Features**:
  - Supports `secrets` parameter for creating secrets at deployment time
  - Can generate password using `uniqueString()` and store in secret
  - Built-in RBAC assignments
  - Private endpoint support (optional for this scenario)
  - Diagnostic settings interface
- **Password Generation Approach**:
  - Use Bicep `uniqueString()` function to generate complex password
  - Combine multiple seed values for randomness
  - Store in Key Vault secret via module's `secrets` parameter
  - Reference secret in VM module

#### 8. Log Analytics Workspace
- **Module**: `avm/res/operational-insights/workspace`
- **Latest Version**: 0.15.0
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/operational-insights/workspace/0.15.0/avm/res/operational-insights/workspace/README.md
- **Decision**: Use this module for Log Analytics
- **Rationale**: AVM module with retention configuration and solution deployment support
- **Key Parameters Needed**:
  - Retention days: 30 (default assumption)
  - SKU: PerGB2018

#### 9. Private Endpoint
- **Module**: `avm/res/network/private-endpoint`
- **Latest Version**: 0.11.1
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/network/private-endpoint/0.11.1/avm/res/network/private-endpoint/README.md
- **Decision**: Use this module for storage account file share private endpoint
- **Rationale**: AVM module with built-in private DNS zone group configuration
- **Key Parameters Needed**:
  - Service connection: Storage account file service
  - Subnet: Private endpoint subnet
  - Private DNS zone: privatelink.file.core.windows.net (manual creation)

#### 10. Azure Monitor Alerts
- **Module**: `avm/res/insights/metric-alert`
- **Latest Version**: 0.4.1
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/insights/metric-alert/0.4.1/avm/res/insights/metric-alert/README.md
- **Decision**: Use this module for all three critical alerts
- **Rationale**: AVM module supporting metric-based alerts for VM and Key Vault
- **Alerts to Create**:
  1. VM stopped/deallocated
  2. Disk space > 85%
  3. Key Vault access failures
- **Note**: Portal-only notifications (no action groups needed for this scenario)

### Supporting Modules

#### 11. Managed Disk
- **Included in VM Module**: The Virtual Machine module handles data disk creation inline
- **No separate module needed**: Data disks are specified as parameters to the VM module

#### 12. Private DNS Zone
- **Module**: `avm/res/network/private-dns-zone`
- **Latest Version**: 0.8.0
- **Documentation**: https://github.com/Azure/bicep-registry-modules/tree/avm/res/network/private-dns-zone/0.8.0/avm/res/network/private-dns-zone/README.md
- **Decision**: Use this module for private DNS zone for file share private endpoint
- **Rationale**: Required for DNS resolution of storage account file share through private endpoint
- **Key Parameters Needed**:
  - Zone name: privatelink.file.core.windows.net
  - VNet link to main VNet

## Alternative Approaches Considered

### Alternative 1: Direct Resource Declarations
- **Approach**: Use direct Bicep resource declarations instead of AVM modules
- **Rejected**: Violates constitution principle II (AVM-Only Modules)
- **Trade-offs**: Would provide more control but lose benefits of tested, maintained, secure-by-default configurations

### Alternative 2: Pattern Module for VM Workloads
- **Approach**: Search for existing AVM pattern module combining VM, networking, and storage
- **Evaluated**: No suitable pattern module exists for this specific legacy VM scenario
- **Decision**: Compose solution from resource modules per constitution

### Alternative 3: Deployment Scripts for Password Generation
- **Approach**: Use Azure Deployment Scripts to generate and store VM password
- **Rejected**: User requirement specifies using uniqueString() and avoiding external helper scripts
- **Decision**: Generate password inline using Bicep uniqueString() function and store via Key Vault module's secrets parameter

### Alternative 4: Azure Backup Integration
- **Approach**: Include Azure Backup configuration for VM
- **Rejected**: Explicitly out of scope per specification
- **Note**: Can be added later if requirements change

## Bicep Language Features Required

### Password Generation Pattern
```bicep
// Generate complex password using uniqueString with multiple seeds
var generatedPassword = 'P@ssw0rd!${uniqueString(resourceGroup().id, deployment().name, utcNow('u'))}'
```

### Resource Dependency Management
- Let ARM manage dependencies automatically
- Explicit `dependsOn` only when implicit dependency isn't detected
- Use resource symbolic names for references

### Parameter Validation
- Use decorators: `@minLength()`, `@maxLength()`, `@allowed()`
- Validate VM computer name length (≤15 chars)
- Validate storage account name (≤24 chars, lowercase, alphanumeric)

## Key Configuration Decisions

### Resource Naming
- **Pattern**: {resourceType}-{purpose}-{randomSuffix}
- **Random Suffix**: Use uniqueString() with 6 characters
- **Examples**:
  - VNet: `vnet-legacyvm-${uniqueString(resourceGroup().id)}`
  - VM: `vm-legacyvm-${uniqueString(resourceGroup().id)}`
  - Storage: `st${replace(uniqueString(resourceGroup().id), '-', '')}` (no hyphens, ≤24 chars)
  - Key Vault: `kv-legacyvm-${uniqueString(resourceGroup().id)}`

### Network Security
- **VM Subnet NSG**: Allow outbound internet (via NAT), deny all inbound except from bastion
- **Bastion Subnet NSG**: Follow Azure Bastion NSG requirements
- **Private Endpoint Subnet NSG**: Allow inbound from VM subnet on port 445 (SMB)

### Diagnostic Settings
- **Target**: Log Analytics Workspace (centralized)
- **Resources to Monitor**: VM, Key Vault, Storage Account, NSGs, Bastion
- **Log Categories**: All available categories
- **Metrics**: All available metrics

### Availability Zones
- **VM**: Deploy to zone specified by parameter (1, 2, or 3)
- **NAT Gateway**: Deploy to same zone as VM
- **Managed Disks**: Automatically zone-aligned with VM

## Implementation Notes

### Single Template Approach
- All resources in main.bicep
- No nested modules or separate Bicep files
- ARM dependency management handles deployment order

### Parameter Management
- All configurable values in main.bicepparam
- Rich comments explaining each parameter
- Default values where appropriate
- No hardcoded values in template

### Bicep CLI Version
- **Minimum**: Latest stable version (0.33.0 or higher at time of writing)
- **Recommendation**: Always use latest for newest AVM module support
- **Verification**: Run `bicep --version` before deployment

### Module Version Pinning
- **Required**: Always pin to specific versions (never 'latest' tag)
- **Format**: `br/public:avm/res/network/virtual-network:0.7.2`
- **Maintenance**: Update versions explicitly when needed

## Open Questions Resolved

1. **How to generate VM password without external scripts?**
  - **Resolution**: Use Bicep `uniqueString()` function with multiple seeds
  - **Implementation**: Store generated password in Key Vault using module's `secrets` parameter

2. **How to connect file share to VM?**
  - **Resolution**: Out of scope for initial deployment per user guidance
  - **Future**: Will require VM extension or post-deployment script

3. **Should we use private endpoint for Key Vault?**
  - **Resolution**: No, not required for this legacy workload
  - **Justification**: Adds complexity without clear benefit for single VM scenario

4. **What alert notification channels?**
  - **Resolution**: Portal notifications only (clarified during specification)
  - **Implementation**: Create metric alerts without action groups

5. **Module version for optimal features?**
  - **Resolution**: Always use latest stable version listed in AVM metadata
  - **Verification**: Confirmed all required features available in latest versions

## Next Steps

1. Create data-model.md with network topology and resource relationships
2. Write deployment quickstart guide
3. Fill implementation plan template
4. Create bicepconfig.json with module version analyzer
<!-- markdownlint-disable -->
# Quickstart: Deploy Legacy VM Workload

**Date**: 2026-01-27
**Feature**: [spec.md](../spec.md)
**Purpose**: Step-by-step deployment guide with validation and troubleshooting

## Prerequisites

### Required Tools

1. **Azure CLI** (v2.65.0 or later)
  ```powershell
  # Check version
  az --version

  # Install/upgrade if needed
  # Windows: Download from https://aka.ms/installazurecliwindows
  # Or use winget
  winget install -e --id Microsoft.AzureCLI
  ```

2. **Bicep CLI** (v0.33.0 or later)
  ```powershell
  # Check version
  az bicep version

  # Install/upgrade
  az bicep install
  az bicep upgrade
  ```

3. **PowerShell** (v7.4 or later recommended)
  ```powershell
  # Check version
  $PSVersionTable.PSVersion

  # Install if needed
  winget install --id Microsoft.Powershell --source winget
  ```

### Azure Permissions

You need the following permissions on the target subscription:

- **Owner** or **Contributor** role at subscription or resource group level
- **User Access Administrator** role (if deploying RBAC assignments)
- Permissions to create resources in **westus3** region

### Authentication

```powershell
# Login to Azure
az login

# Set the target subscription
az account set --subscription "<subscription-id-or-name>"

# Verify current context
az account show --output table
```

## Repository Structure

```
avm-workload/
├── infra/
│   ├── main.bicep            # Main infrastructure template
│   ├── main.bicepparam       # Deployment parameters
│   └── bicepconfig.json      # Bicep configuration
├── specs/
│   └── 001-legacy-vm-workload/
│       ├── spec.md           # Feature specification
│       ├── plan.md           # Implementation plan
│       ├── data-model.md     # Architecture documentation
│       └── quickstart.md     # This file
└── .specify/
    └── memory/
        └── constitution.md   # Governance framework
```

## Deployment Workflow

### Step 1: Review Parameters

Edit `infra/main.bicepparam` to customize deployment:

```bicep
using './main.bicep'

// Required parameters
param vmSize = 'Standard_D2s_v3'
param vmAdminUsername = 'vmadmin'
param availabilityZone = 1
param fileShareQuotaGiB = 1024
param logAnalyticsRetentionDays = 30

// Optional: Override resource names
// param vmName = 'vm-custom-name'
// param vnetName = 'vnet-custom-name'
```

**Key Parameters**:
- `vmSize`: Virtual machine SKU (must support Windows Server 2016)
- `vmAdminUsername`: Administrator username for the VM
- `availabilityZone`: Availability zone (1, 2, or 3)
- `fileShareQuotaGiB`: Storage file share quota (default 1024 GiB)
- `logAnalyticsRetentionDays`: Log retention period (30-730 days)

### Step 2: Pre-Deployment Validation

#### 2.1 Bicep Compilation

Verify the template compiles without errors:

```powershell
# Navigate to infrastructure directory
cd C:\SOURCE\avm-workload\infra

# Build Bicep template
bicep build main.bicep

# Check for warnings
# Fix any warnings reported by the analyzer
```

**Expected Output**: `main.json` file created with no errors or warnings.

#### 2.2 Template Validation

Validate deployment against Azure:

```powershell
# Create resource group (if it doesn't exist)
az group create `
  --name rg-legacyvm-prod `
  --location westus3

# Validate deployment
az deployment group validate `
  --resource-group rg-legacyvm-prod `
  --template-file main.bicep `
  --parameters main.bicepparam `
  --verbose

# Check validation result
if ($LASTEXITCODE -eq 0) {
    Write-Host "✅ Validation passed" -ForegroundColor Green
} else {
    Write-Host "❌ Validation failed - review errors above" -ForegroundColor Red
    exit 1
}
```

**Expected Output**: `provisioningState: Succeeded`

#### 2.3 What-If Analysis

Preview what resources will be created:

```powershell
# Run what-if analysis
az deployment group what-if `
  --resource-group rg-legacyvm-prod `
  --template-file main.bicep `
  --parameters main.bicepparam `
  --verbose

# Review output:
# - Green (+): Resources to be created
# - Yellow (~): Resources to be modified
# - Red (x): Resources to be deleted
# - White (=): No change
```

**Review Checklist**:
- [ ] 1 Virtual Network with 3 subnets
- [ ] 3 Network Security Groups
- [ ] 1 NAT Gateway with Public IP
- [ ] 1 Azure Bastion with Public IP
- [ ] 1 Key Vault with 1 secret
- [ ] 1 Storage Account with 1 file share
- [ ] 1 Private Endpoint
- [ ] 1 Private DNS Zone with VNet link
- [ ] 1 Virtual Machine with NIC, OS disk, data disk
- [ ] 1 Log Analytics Workspace
- [ ] 3 Metric Alerts
- [ ] Multiple diagnostic settings
- [ ] RBAC role assignments

**STOP**: Do not proceed if what-if shows unexpected resource deletions or modifications.

### Step 3: Deploy Infrastructure

#### 3.1 Execute Deployment

```powershell
# Deploy infrastructure
az deployment group create `
  --name "legacyvm-$(Get-Date -Format 'yyyyMMdd-HHmmss')" `
  --resource-group rg-legacyvm-prod `
  --template-file main.bicep `
  --parameters main.bicepparam `
  --verbose

# Deployment typically takes 15-20 minutes
# Monitor progress in Azure Portal: Resource Groups > rg-legacyvm-prod > Deployments
```

**Expected Duration**: 15-20 minutes

**Deployment Phases**:
1. **0-2 min**: Log Analytics, VNet, NSGs
2. **2-8 min**: NAT Gateway, Bastion, Private DNS Zone, Key Vault
3. **8-12 min**: Storage Account, Private Endpoint
4. **12-18 min**: Virtual Machine (longest phase)
5. **18-20 min**: Monitor Alerts

#### 3.2 Monitor Deployment

**Option A: Azure CLI**
```powershell
# Watch deployment status
az deployment group show `
  --name "legacyvm-<timestamp>" `
  --resource-group rg-legacyvm-prod `
  --query "{State:properties.provisioningState, Duration:properties.duration}" `
  --output table
```

**Option B: Azure Portal**
1. Navigate to: [Azure Portal](https://portal.azure.com)
2. Go to: **Resource Groups** > **rg-legacyvm-prod** > **Deployments**
3. Click on the active deployment to see detailed progress
4. Monitor each resource deployment status

### Step 4: Post-Deployment Verification

#### 4.1 Verify Resources

```powershell
# List all resources in the resource group
az resource list `
  --resource-group rg-legacyvm-prod `
  --output table

# Expected count: 20-25 resources
# Key resources to verify:
# - Virtual Machine
# - Virtual Network
# - Storage Account
# - Key Vault
# - Azure Bastion
# - Log Analytics Workspace
```

#### 4.2 Test Bastion Connectivity

**Via Azure Portal**:
1. Go to: **Virtual Machines** > **vm-legacyvm-{random}**
2. Click: **Connect** > **Bastion**
3. Enter credentials:
  - **Username**: `vmadmin`
  - **Password**: Get from Key Vault (see below)
4. Click: **Connect**

**Retrieve VM Password**:
```powershell
# Get Key Vault name
$kvName = az keyvault list `
  --resource-group rg-legacyvm-prod `
  --query "[0].name" `
  --output tsv

# Get VM admin password from Key Vault
az keyvault secret show `
  --name vm-admin-password `
  --vault-name $kvName `
  --query "value" `
  --output tsv
```

**Expected Result**: Successful RDP connection to Windows Server 2016 VM.

#### 4.3 Verify Logs in Log Analytics

```powershell
# Get Log Analytics workspace ID
$workspaceId = az monitor log-analytics workspace show `
  --resource-group rg-legacyvm-prod `
  --workspace-name law-legacyvm-{random} `
  --query "customerId" `
  --output tsv

Write-Host "Log Analytics Workspace ID: $workspaceId"
Write-Host "Portal: https://portal.azure.com#blade/Microsoft_Azure_Monitoring_Logs/LogsBlade/resourceId/%2Fsubscriptions%2F{subscription-id}%2FresourceGroups%2Frg-legacyvm-prod%2Fproviders%2FMicrosoft.OperationalInsights%2Fworkspaces%2Flaw-legacyvm-{random}"
```

**Via Azure Portal**:
1. Go to: **Log Analytics Workspaces** > **law-legacyvm-{random}**
2. Click: **Logs**
3. Run query to verify diagnostic logs:

```kusto
// Query 1: Verify VM activity logs
AzureActivity
| where ResourceGroup == "rg-legacyvm-prod"
| where ResourceType == "Microsoft.Compute/virtualMachines"
| summarize count() by OperationName
| order by count_ desc

// Query 2: Verify Key Vault audit logs
AzureDiagnostics
| where ResourceType == "VAULTS"
| where ResourceGroup == "rg-legacyvm-prod"
| summarize count() by OperationName, ResultType
| order by count_ desc

// Query 3: Verify Storage Account logs
StorageFileLogs
| where AccountName startswith "st"
| summarize count() by OperationName
| order by count_ desc

// Query 4: Check for any errors
AzureDiagnostics
| where ResourceGroup == "rg-legacyvm-prod"
| where Level == "Error"
| project TimeGenerated, ResourceType, OperationName, ResultDescription
| order by TimeGenerated desc
```

**Expected Results**: Logs appearing for all resources within 5-10 minutes of deployment.

#### 4.4 Test Alerts

**Test 1: Disk Space Alert** (Optional - requires VM modification)
```powershell
# WARNING: This will consume disk space on the VM
# Only run if you want to test alert firing

# Connect to VM via Bastion, then run in VM:
# fsutil file createnew C:\testfile.tmp 100000000000  # 100GB file

# Wait 5-10 minutes for alert to fire
# Check: Azure Portal > Monitor > Alerts
```

**Test 2: Key Vault Access Failure** (Safe test)
```powershell
# Attempt to get a non-existent secret (should generate access failure log)
az keyvault secret show `
  --name "non-existent-secret" `
  --vault-name $kvName 2>$null

# Wait 5 minutes, then check:
# Azure Portal > Monitor > Alerts > alert-kv-access-fail-legacyvm-{random}
```

**Expected Behavior**: Alerts visible in Azure Portal within 5-10 minutes of trigger condition.

#### 4.5 Verify Network Connectivity

**From VM (via Bastion RDP session)**:

```powershell
# Test internet connectivity via NAT Gateway
Test-NetConnection -ComputerName google.com -Port 443

# Test Azure DNS resolution
nslookup st{random}.file.core.windows.net

# Verify private endpoint resolution
nslookup st{random}.privatelink.file.core.windows.net

# Expected: Private IP from 10.0.0.128/27 range

# Test file share access (future - after mapping)
# net use Z: \\st{random}.file.core.windows.net\fileshare
```

**Expected Results**:
- Internet access works (NAT Gateway)
- Private endpoint resolves to internal IP (10.0.0.128/27)
- File share accessible from VM

## Troubleshooting

### Issue: Bicep Build Fails

**Symptoms**: `bicep build` reports errors or warnings

**Solutions**:
1. **Check Bicep CLI version**:
  ```powershell
  az bicep version
  # Should be v0.33.0 or later
  az bicep upgrade
  ```

2. **Review analyzer warnings**:
  - Open `main.bicep` in VS Code with Bicep extension
  - Fix any red/yellow squiggles
  - Common issues: outdated module versions, missing required parameters

3. **Validate bicepconfig.json**:
  ```powershell
  # Ensure file exists and is valid JSON
  Get-Content infra/bicepconfig.json | ConvertFrom-Json
  ```

### Issue: Validation Fails

**Symptoms**: `az deployment group validate` returns errors

**Common Errors**:

1. **"Resource provider not registered"**
  ```powershell
  # Register required providers
  az provider register --namespace Microsoft.Compute
  az provider register --namespace Microsoft.Network
  az provider register --namespace Microsoft.Storage
  az provider register --namespace Microsoft.KeyVault
  az provider register --namespace Microsoft.Insights

  # Wait for registration to complete (2-5 minutes)
  az provider show --namespace Microsoft.Compute --query "registrationState"
  ```

2. **"Quota exceeded"**
  - Check Azure subscription quotas
  - Request quota increase if needed: Portal > Subscriptions > Usage + quotas

3. **"Invalid parameter value"**
  - Review `main.bicepparam` for typos
  - Ensure `vmSize` is valid for westus3 region
  - Verify availability zone is 1, 2, or 3

### Issue: Deployment Hangs or Times Out

**Symptoms**: Deployment runs longer than 30 minutes

**Diagnosis**:
```powershell
# Check deployment status
az deployment group show `
  --name "legacyvm-<timestamp>" `
  --resource-group rg-legacyvm-prod `
  --query "properties.{State:provisioningState, SubState:provisioningDetails}" `
  --output json

# View deployment operations
az deployment operation group list `
  --resource-group rg-legacyvm-prod `
  --name "legacyvm-<timestamp>" `
  --query "[?properties.provisioningState=='Failed' || properties.provisioningState=='Running']" `
  --output table
```

**Solutions**:
1. **VM creation timeout**: May indicate VM extension failures
  - Check: Portal > VM > Extensions and applications
  - Solution: Redeploy with `--no-wait` flag, monitor separately

2. **Bastion timeout**: Check Public IP allocation
  - Verify Public IP quota not exceeded
  - Check NSG rules on Bastion subnet

3. **Private Endpoint timeout**: DNS propagation delay
  - Wait additional 5-10 minutes
  - Verify Private DNS Zone linked to VNet

### Issue: VM Password Not Working

**Symptoms**: Cannot connect to VM via Bastion with retrieved password

**Solutions**:
1. **Re-retrieve password from Key Vault**:
  ```powershell
  $kvName = az keyvault list --resource-group rg-legacyvm-prod --query "[0].name" -o tsv
  $password = az keyvault secret show --name vm-admin-password --vault-name $kvName --query "value" -o tsv
  Write-Host "Password: $password"
  ```

2. **Check Key Vault access**:
  ```powershell
  # Ensure you have Key Vault Secrets User role
  az role assignment list `
    --scope /subscriptions/{subscription-id}/resourceGroups/rg-legacyvm-prod/providers/Microsoft.KeyVault/vaults/$kvName `
    --query "[?principalName=='<your-user-email>']" `
    --output table
  ```

3. **Reset VM password** (if secret retrieval works but password is wrong):
  ```powershell
  # This should not be necessary if deployment succeeded
  # Only use as last resort
  az vm user update `
    --resource-group rg-legacyvm-prod `
    --name vm-legacyvm-{random} `
    --username vmadmin `
    --password "NewP@ssw0rd!123"
  ```

### Issue: Bastion Connection Fails

**Symptoms**: Cannot establish Bastion RDP session

**Diagnosis**:
```powershell
# Check Bastion health
az network bastion show `
  --resource-group rg-legacyvm-prod `
  --name bas-legacyvm-{random} `
  --query "{ProvisioningState:provisioningState, DNSName:dnsName}" `
  --output table

# Check VM status
az vm get-instance-view `
  --resource-group rg-legacyvm-prod `
  --name vm-legacyvm-{random} `
  --query "instanceView.statuses[?starts_with(code, 'PowerState/')].displayStatus" `
  --output tsv
```

**Solutions**:
1. **VM is stopped**: Start the VM
  ```powershell
  az vm start --resource-group rg-legacyvm-prod --name vm-legacyvm-{random}
  ```

2. **Bastion NSG rules incorrect**: Verify Bastion subnet NSG
  - Required: Allow inbound 443 from Internet
  - Required: Allow outbound 3389/22 to VirtualNetwork
  - Check: Portal > NSG > nsg-bastion-legacyvm-{random} > Security rules

3. **Browser issues**: Try different browser or incognito mode

### Issue: No Logs in Log Analytics

**Symptoms**: Queries return no results 10+ minutes after deployment

**Diagnosis**:
```kusto
// Check if workspace is receiving any data
Heartbeat
| where TimeGenerated > ago(1h)
| summarize count()

// Check diagnostic settings configuration
AzureDiagnostics
| where TimeGenerated > ago(1h)
| summarize count() by ResourceType
```

**Solutions**:
1. **Wait longer**: Initial log ingestion can take 10-15 minutes
2. **Verify diagnostic settings**:
  ```powershell
  # Check VM diagnostic settings
  az monitor diagnostic-settings list `
    --resource /subscriptions/{subscription-id}/resourceGroups/rg-legacyvm-prod/providers/Microsoft.Compute/virtualMachines/vm-legacyvm-{random} `
    --query "value[].{Name:name, LogAnalytics:workspaceId}" `
    --output table
  ```

3. **Manual diagnostic setting creation** (if missing):
  - Portal > VM > Diagnostic settings > Add diagnostic setting
  - Select all log categories and metrics
  - Send to Log Analytics workspace: law-legacyvm-{random}

### Issue: Alerts Not Firing

**Symptoms**: Test conditions met but no alerts visible in Portal

**Diagnosis**:
```powershell
# Check alert rules
az monitor metrics alert list `
  --resource-group rg-legacyvm-prod `
  --query "[].{Name:name, Enabled:enabled, Severity:severity}" `
  --output table

# Check alert condition evaluation
az monitor metrics alert show `
  --resource-group rg-legacyvm-prod `
  --name alert-disk-space-legacyvm-{random} `
  --query "{Enabled:enabled, Condition:criteria, State:properties.state}" `
  --output json
```

**Solutions**:
1. **Wait for evaluation window**: Alerts evaluate every 1-5 minutes
2. **Verify alert is enabled**: Should show `"enabled": true`
3. **Check metric availability**:
  ```powershell
  # List available metrics for VM
  az monitor metrics list-definitions `
    --resource /subscriptions/{subscription-id}/resourceGroups/rg-legacyvm-prod/providers/Microsoft.Compute/virtualMachines/vm-legacyvm-{random} `
    --query "[].{Name:name.value, Unit:unit}" `
    --output table
  ```

4. **Review activity log for alert evaluation**:
  - Portal > Monitor > Activity Log
  - Filter: Resource Type = "microsoft.insights/metricalerts"
  - Look for "Evaluate Action" events

## Clean Up Resources

**WARNING**: This will delete ALL resources and data. Ensure you have backups before proceeding.

```powershell
# Delete resource group and all resources
az group delete `
  --name rg-legacyvm-prod `
  --yes `
  --no-wait

# Verify deletion status (takes 5-10 minutes)
az group exists --name rg-legacyvm-prod
# Expected output: false
```

**Cost Estimate**: Keeping resources deployed costs approximately:
- VM (Standard_D2s_v3): ~$70/month
- Storage (1TB file share + disks): ~$50/month
- Bastion: ~$140/month
- Other services (negligible): ~$10/month
- **Total**: ~$270/month in westus3 region

## Next Steps

After successful deployment:

1. **Configure VM**:
  - Install required applications on Windows Server 2016
  - Map file share as network drive: `\\st{random}.file.core.windows.net\fileshare`
  - Configure Windows Firewall rules as needed

2. **Set Up Monitoring**:
  - Configure Log Analytics queries and save as functions
  - Create custom workbooks in Azure Monitor
  - Set up action groups for email/SMS notifications (currently Portal-only)

3. **Implement Backup** (not in scope of this deployment):
  - Azure Backup for VM
  - Azure Files snapshot/backup for file share

4. **Security Hardening** (additional measures):
  - Enable Azure Security Center recommendations
  - Implement Just-In-Time VM access
  - Review and tighten NSG rules based on actual traffic

5. **Operational Procedures**:
  - Document VM maintenance schedules
  - Create runbooks for common tasks
  - Establish change management process

## Support

For issues related to:
- **Bicep**: Review [research.md](./research.md) for module documentation
- **Azure resources**: Check [data-model.md](./data-model.md) for architecture
- **Requirements**: See [spec.md](./spec.md) for detailed specifications
- **Governance**: Review [constitution.md](../../.specify/memory/constitution.md) for principles

For Azure support, visit: https://azure.microsoft.com/support/
/speckit.plan Create a detailed plan for the spec. Build with the latest version of Terraform and the latest available version of each Azure Verified Module. Use the Terraform MCP server ("io.github.hashicorp/terraform-mcp-server") to find out what's the latest version of each module - install and configure this MCP server as needed. Do NOT use the "Bicep/list_avm_metadata" MCP tool!
Only include direct resource references in the Terraform solution template (root module) if no related AVM resource modules are available. If there is no Azure Verified Module available, use `Azure/azapi` resources or data sources for all control-plane resources and supported data-plane operations.
AzureRM may be used only under the [TFFR3](https://azure.github.io/Azure-Verified-Modules/spec/TFFR3) exception for a specific data-plane/non-ARM operation that AzAPI genuinely cannot implement. Every exceptional `azurerm_*` block must be scoped to that operation, pin AzureRM to `~> 4.0`, include the prescribed TFLint exclusion, document the AzAPI gap and upstream issue or pull request, and be replaced when AzAPI support ships.
Never use AzureRM for a control-plane resource or as a convenience fallback. Always use module interfaces for diagnostic settings, role assignments, resource locks, tags, managed identities, private endpoints, customer managed keys, etc.; use the related interface built into each resource module when available.
Do not create and reference local modules, or any other Terraform files. If a subset of the deployments fail, don't delete anything, just attempt redeploying the whole solution after fixing any bugs. Follow IaC best practices: define everything in a single root module using the standard module files of `main.tf`, `variables.tf`, `outputs.tf`, `terraform.tf`, and `terraform.tfvars`.
Always build Terraform explicit dependencies to determine order of deployment for each Azure resource, only use explicit dependencies with the `depends_on` meta-argument when it's not possible to otherwise determine the order of deployment. The Azure subscription ID will always be supplied as an env var or via az cli, it must not be exposed as a variable.

The subscription ID will be provided at deployment time via environment variable or az cli, it should not be exposed as a variable in the code.

When generating the admin password for the VM, use the secret feature built into the AVM Key Vault module. Leverage the random_password resource in the random provider to generate a new random password and do not use any external helper script (including deployment scripts) for generating the password. Provide this password to the VM module by referencing the Key vault secret that stores it. The solution template (root module) must first generate this password including a random, complex string, using the random_password Terraform resource, store it in Key Vault and then reference it for the VM to use it as admin password at deployment time. Ephemeral resources and write only attributes should be used for the password.

Don't connect the file share to the VM just yet - i.e., no need to extract storage keys or shared access signatures - we will do this later.

If implementing resource level locks, always use the built-in AVM "interface" for resource locks, instead of directly deploying the "Microsoft.Authorization/locks" resource.

Terraform solution template (root module) must validate without warnings or errors using the latest stable Terraform CLI version. Generate a warning when not the latest version of an AVM module is used. Before validating the solution template (root module) or attempting the first deployment, always fix all warnings or errors related to the AVM module versioning by updating to the latest available version of each module.

Always use snake case for Terraform HCL resource names, module names, variable names, output names, map keys, etc. Never shorten names, always use the full name. E.g. `network_security_group` instead of `nsg`, `virtual_machine` instead of `vm`, etc.

Ephemeral resources and write only attributes should be used for passwords.
➕ Expand to see the results

Notice how the plan step creates the plan.md file and a number of additional helper files. These may very depending on your prompts, the solution you are building, the version of Spec Kit and the LLM used. These typically include: data-model.md, research.md, quickstart.md and optional files in the contracts folder, such as outputs.md and parameters.md.

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

In the Copilot chat window, you should see results, similar to this:
Specify Bootstrap

<!-- markdownlint-disable -->
# Implementation Plan: Legacy Business Application Infrastructure

**Branch**: `001-my-legacy-workload` | **Date**: 2026-02-18 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-my-legacy-workload/spec.md`

**Note**: This plan implements the `/speckit.plan` command workflow.

## Summary

Deploy a legacy Windows Server 2016 business application infrastructure using Terraform and Azure Verified Modules (AVM). The solution includes:
- **Core Compute**: Standard_D2s_v3 VM with Windows Server 2016, 500GB data disk, managed in isolated VNet
- **Network Security**: VNet (10.0.0.0/24) with 3 subnets, NSGs with deny-by-default rules, Azure Bastion for secure RDP
- **Storage**: 1TB Azure Files share accessible via private endpoint
- **Secrets Management**: Azure Key Vault storing VM admin password (generated with random_password resource)
- **Internet Access**: NAT Gateway for outbound-only connectivity
- **Observability**: Log Analytics (180-day retention) with diagnostic logging and critical alerts

**Technical Approach**: Single Terraform root module deploying all resources via AVM modules from Terraform Registry. No backup solution (infrastructure is disposable/recreatable). All configuration in terraform.tfvars per specification. VM password generated using random_password resource, stored in Key Vault via AVM module interface, then referenced by VM module.

## Technical Context

**Infrastructure Language**: Terraform >= 1.9.0 (latest stable as of 2026-02-18)

**Required Providers**:
- `Azure/azapi` ~> 2.12 (required for all direct Azure resource interactions)
- `hashicorp/azurerm` ~> 4.0 (required transitively by currently published AVM modules)
- `hashicorp/random` ~> 3.6 (for random_password and random_string resources)

**AVM Modules** (versions to be verified from Terraform Registry during Phase 0):
- `Azure/avm-res-resources-resourcegroup/azurerm` - Resource group
- `Azure/avm-res-network-virtualnetwork/azurerm` - VNet with 3 subnets
- `Azure/avm-res-network-networksecuritygroup/azurerm` - NSGs for each subnet
- `Azure/avm-res-compute-virtualmachine/azurerm` - Windows Server 2016 VM
- `Azure/avm-res-network-bastionhost/azurerm` - Azure Bastion
- `Azure/avm-res-keyvault-vault/azurerm` - Key Vault for secrets
- `Azure/avm-res-storage-storageaccount/azurerm` - Storage account with file share
- `Azure/avm-res-network-privateendpoint/azurerm` - Private endpoint (if not included in storage module)
- `Azure/avm-res-network-natgateway/azurerm` - NAT Gateway
- `Azure/avm-res-operationalinsights-workspace/azurerm` - Log Analytics Workspace

**Note**: Exact module names and latest versions must be verified from https://registry.terraform.io/namespaces/Azure during Phase 0 research. If no AVM module exists, use AzAPI directly. AzureRM is permitted only for a specific data-plane/non-ARM operation that AzAPI cannot implement, never for a control-plane resource.

**State Backend**: Azure Storage Account (pre-existing, not managed by this Terraform)
**State File**: `my-legacy-workload-prod.tfstate`
**Target Region**: westus3
**Project Type**: Infrastructure-only (Terraform root module)

**Deployment Method**: Manual terraform apply via CLI (CI/CD pipeline optional for Phase 2)
**Security Tooling**: tfsec >= 1.28, checkov >= 3.0 (for static security analysis)
**Complexity**: 12 Azure resources via AVM modules, estimated ~300-400 lines of Terraform
**Estimated Monthly Cost**: <$200/month (per spec SC-013)

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

Based on `.specify/memory/constitution.md` version 1.0.0:

- [x] **Principle I**: All Azure resources defined in Terraform `.tf` files (no imperative scripts except justified)
  - All 12 resources deployed via Terraform AVM modules
  - No custom PowerShell/CLI scripts for resource deployment

- [x] **Principle II**: All modules sourced from Azure Verified Modules (AVM) Terraform Registry (`Azure/avm-*`)
  - Zero custom/third-party modules
  - All resources use official AVM modules where available
  - Direct Azure resources use AzAPI only when no AVM module is available

- [x] **Principle III**: Security requirements met:
  - [x] VM managed identity (system-assigned) configured via AVM module interface
  - [x] VM password generated with random_password, stored in Key Vault via AVM secrets interface
  - [x] NSGs with deny-by-default rules, explicit allow for RDP from Bastion only
  - [x] Diagnostic settings enabled via AVM module diagnostic_settings interface
  - [x] Encryption at rest (default Microsoft-managed keys)
  - [x] Resource locks via AVM module lock interface
  - [x] No secrets in .tf or .tfvars files (password generated at runtime, stored in Key Vault)

- [x] **Principle IV**: Single root module pattern (all resources in terraform/ directory root)
  - terraform/main.tf contains all module instantiations
  - No local child modules created
  - Terraform dependency graph manages deployment order

- [x] **Principle V**: Deployment includes terraform validate and terraform plan review gates
  - Validation workflow: init → fmt → validate → plan → review → apply
  - Plan file (plan.tfplan) generated and reviewed before apply

- [x] **Deployment Standards**: Target region is `westus3`, naming convention followed (`<type>-avmlegacy-<suffix>`)
  - All resources use location = var.location (default: "westus3")
  - Naming via locals using random_string for uniqueness

- [x] **Project Constraints**: No HA/DR/scalability features added (legacy workload, cost-optimized)
  - Single VM (no availability set, no load balancer)
  - No backup policies (infrastructure disposable per clarification session)
  - Standard/HDD tier for cost optimization

**Constitution Compliance**: ✅ **PASSED** - All principles satisfied

## Project Structure

### Documentation (this feature)

```
specs/001-my-legacy-workload/
├── spec.md              # Feature specification (input)
├── plan.md              # This file (Phase 0-1 output)
├── research.md          # Phase 0 research findings (to be created)
├── data-model.md        # Phase 1 data model (infrastructure entities - to be created)
├── quickstart.md        # Phase 1 deployment guide (to be created)
├── contracts/           # Phase 1 contracts (N/A for infrastructure, no external APIs)
├── checklists/
│   └── requirements.md  # Specification validation checklist (exists)
└── tasks.md             # Phase 2 task breakdown (created by /speckit.tasks command)
```

### Source Code (repository root)

```
terraform/
├── main.tf              # Primary resource declarations and AVM module calls
├── variables.tf         # Input variable definitions with descriptions
├── outputs.tf           # Output value definitions for infrastructure details
├── terraform.tf         # Terraform version and provider configurations
├── backend.tf           # Remote state backend configuration (Azure Storage)
├── locals.tf            # Local value computations (naming, tags)
├── prod.tfvars          # Production environment variable values (this is the ONLY tfvars file)
└── README.md            # Terraform deployment instructions

docs/
├── README.md            # Project overview and setup instructions
└── architecture.md      # Infrastructure architecture diagram and design decisions

.github/
└── workflows/
    └── terraform-validate.yml  # CI/CD pipeline for validation (optional Phase 2)

.gitignore               # Terraform-specific ignore patterns (.terraform/, *.tfstate, *.tfvars except prod.tfvars.example)
```

**Structure Decision**: Selected Option 1 (Terraform Infrastructure). This is a pure infrastructure deployment with no application code. All Terraform files in `terraform/` directory at repository root. Single `prod.tfvars` file per spec requirement (no dev/test environments).

## Complexity Tracking

> **Note**: No constitution violations - this section documents architectural decisions only

| Decision | Rationale | Alternative Considered |
|----------|-----------|------------------------|
| No local Terraform modules | Per spec requirements and constitution, use AVM modules exclusively. Local modules only if AVM unavailable | Could create local modules for repeated patterns - rejected per spec FR-025 |
| Single tfvars file (prod.tfvars) | Spec requires production environment only (per clarification: no dev/test/staging) | Could use terraform workspaces - rejected per spec FR-024 |
| No backup automation | Per clarification session: infrastructure is disposable, recreatable from Terraform. No backup needed | Could add Azure Backup via AVM module - rejected per clarification |
| Minimal VNet (/24) | Per clarification session: cost-optimized, no growth expected for legacy workload | Could use /23 or larger - rejected for cost optimization |
| 1TB file share | Per clarification session: large capacity selected to avoid future expansion | Could use smaller quota (100GB-500GB) - rejected per user preference |

---

## Phase 0: Outline & Research

**Objective**: Resolve all "NEEDS CLARIFICATION" items from Technical Context and research AVM module capabilities

### Research Tasks

#### Task 1: Verify Latest Terraform Version
**Research**: Confirm Terraform stable version >= 1.9.0 available
**Method**: Check https://developer.hashicorp.com/terraform/downloads or run `terraform version`
**Outputs**: Exact Terraform version constraint for terraform.tf

#### Task 2: Verify AzAPI Provider Version
**Research**: Confirm the latest AzAPI 2.x provider version compatible with AVM requirements
**Method**: Check https://registry.terraform.io/providers/Azure/azapi/latest and AVM module documentation
**Outputs**: Exact provider version constraint

#### Task 3: Research AVM Module Availability and Versions
**Research**: For each required Azure resource, identify:
1. Official AVM module name on Terraform Registry
2. Latest stable version (semantic versioning)
3. Module README documentation link
4. Key input variables and interfaces (diagnostic_settings, lock, managed_identities, private_endpoints, secrets)

**Method**: Visit https://registry.terraform.io/namespaces/Azure and search for:
- `avm-res-network-virtualnetwork`
- `avm-res-network-networksecuritygroup`
- `avm-res-compute-virtualmachine`
- `avm-res-network-bastionhost`
- `avm-res-keyvault-vault`
- `avm-res-storage-storageaccount`
- `avm-res-network-privateendpoint` (or check if storage module has built-in private endpoint interface)
- `avm-res-network-natgateway`
- `avm-res-operationalinsights-workspace`
- Insights/monitoring module for metric alerts (name TBD)

**Critical**: Verify each module supports:
- `diagnostic_settings` interface for Log Analytics integration
- `lock` interface for resource locks (CanNotDelete)
- `managed_identities` interface for system-assigned identity
- `secrets` interface (Key Vault module only) for storing random_password output
- `private_endpoints` interface (storage module) for private connectivity

**Outputs**: Populate `research.md` with findings:

```markdown
## AVM Module Research

### Module: avm-res-network-virtualnetwork
- **Registry Path**: Azure/avm-res-network-virtualnetwork/azurerm
- **Latest Version**: [VERSION] (verify from registry)
- **Documentation**: https://registry.terraform.io/modules/Azure/avm-res-network-virtualnetwork/azurerm/latest
- **Key Interfaces**:
  - Subnets: Supports multiple subnet definitions with CIDR allocation
  - NSG Association: [Check if built-in or separate]
  - Diagnostic Settings: [Verify interface availability]
- **Variables Required for Spec**:
  - address_space = ["10.0.0.0/24"]
  - subnets = { vm = "10.0.0.0/27", bastion = "10.0.0.32/26", private_endpoint = "10.0.0.96/28" }
  - location, resource_group_name, etc.

[Repeat for each module...]
```

#### Task 4: Research NSG Rule Patterns
**Research**: Best practices for NSG rules in AVM network security group module:
- Deny-by-default posture
- Allow RDP (3389) from Bastion subnet to VM subnet
- Allow HTTPS (443) inbound to Bastion subnet (Azure Bastion requirement)
- Allow SMB (445) from VM subnet to Private Endpoint subnet

**Method**: Review AVM NSG module documentation for security_rules input structure
**Outputs**: Document NSG rule schema in research.md

#### Task 5: Research VM Password Flow with Key Vault
**Research**: Confirm workflow for generating password and storing in Key Vault via AVM:
1. Create random_password resource (length, complexity requirements)
2. Pass random_password.result to Key Vault AVM module's `secrets` interface
3. Reference Key Vault secret in VM AVM module's admin_password input

**Method**: Review AVM Key Vault module's `secrets` interface and VM module's authentication inputs
**Outputs**: Document password generation pattern in research.md

#### Task 6: Research Private Endpoint Integration
**Research**: Determine if storage account AVM module has built-in private endpoint interface or requires separate private endpoint module
**Method**: Check AVM storage account module documentation for `private_endpoints` input variable
**Outputs**: Decision in research.md - use built-in interface vs separate module

#### Task 7: Research Log Analytics Integration Patterns
**Research**: How to configure diagnostic settings for VM, Key Vault, Storage Account to send logs to Log Analytics
**Method**: Review each AVM module's `diagnostic_settings` interface structure
**Outputs**: Document diagnostic_settings input schema in research.md

#### Task 8: Research Alerting Approach
**Research**: AVM modules or direct AzAPI resources for metric alerts (VM stopped, disk >90%, Key Vault access failures)
**Method**: Check for AVM alerting/monitoring modules; if none exists, use AzAPI `Microsoft.Insights/metricAlerts`
**Outputs**: Decision documented in research.md

### Research Consolidation

**Output**: `research.md` file with structure:

```markdown
# Research Findings: Legacy Business Application Infrastructure

## Decision Log

### Decision 1: Terraform Version
- **Chosen**: Terraform 1.9.x (latest stable)
- **Rationale**: Latest features, bug fixes, security patches
- **Alternatives Considered**: 1.8.x (stable but older), 1.10+ (if available, may have breaking changes)

### Decision 2: AVM Module Versions
- **Chosen**: Latest stable version for each module (semver ~> X.Y.0)
- **Rationale**: Latest features, security fixes, Azure API compatibility
- **Alternatives Considered**: Pin to specific patch versions (rejected - want latest patches)

[Continue for each research task...]

## Module Documentation Summary

### VNet Module
[Findings from Task 3...]

### NSG Module
[Findings from Task 3 and Task 4...]

[etc.]
```

---

## Phase 1: Design & Contracts

**Prerequisites:** `research.md` complete with all module versions and interfaces documented

### Design Artifacts

#### 1. Data Model (Infrastructure Entities)

**Output**: `data-model.md`

```markdown
# Infrastructure Data Model: Legacy Business Application Infrastructure

## Entity Relationships

```
┌─────────────────────────────────────────────────────────┐
│ Resource Group (rg-avmlegacy-wus3)                      │
│                                                           │
│  ┌─────────────────────────────────────────────┐        │
│  │ Virtual Network (10.0.0.0/24)               │        │
│  │  ├─ VM Subnet (10.0.0.0/27)                │        │
│  │  ├─ Bastion Subnet (10.0.0.32/26)          │        │
│  │  └─ Private Endpoint Subnet (10.0.0.96/28) │        │
│  └─────────────────────────────────────────────┘        │
│           │           │              │                   │
│           │           │              │                   │
│  ┌────────▼────┐ ┌───▼──────┐ ┌────▼────────┐         │
│  │ NSG (VM)    │ │NSG(Bastn)│ │NSG(PrivEndpt)│         │
│  └─────────────┘ └──────────┘ └──────────────┘         │
│           │                          │                   │
│  ┌────────▼─────────┐                │                   │
│  │ VM (Std_D2s_v3) │                │                   │
│  │ - OS Disk       │◄────────┐      │                   │
│  │ - Data Disk     │         │      │                   │
│  └─────────────────┘         │      │                   │
│           │                   │      │                   │
│           │            ┌──────┴──────▼──────┐           │
│           │            │ Key Vault           │           │
│           │            │ - VM Admin Password │           │
│           ▼            └─────────────────────┘           │
│    ┌──────────┐                │                         │
│    │ Bastion  │                │                         │
│    │ (RDP)    │                │                         │
│    └──────────┘                │                         │
│           │                     │                         │
│  ┌────────▼─────────┐          │                         │
│  │ NAT Gateway      │          │                         │
│  │ (Outbound Only)  │          │                         │
│  └──────────────────┘          │                         │
│                                 │                         │
│  ┌──────────────────────────┐  │                         │
│  │ Storage Account          │  │                         │
│  │ - File Share (1TB)       │  │                         │
│  │ - Private Endpoint ──────┼──┘                         │
│  └──────────────────────────┘                            │
│                                                           │
│  ┌──────────────────────────┐                            │
│  │ Log Analytics Workspace  │                            │
│  │ - 180-day retention      │                            │
│  │ - Diagnostic logs (VM,   │                            │
│  │   Key Vault, Storage)    │                            │
│  │ - Metric Alerts (3)      │                            │
│  └──────────────────────────┘                            │
└───────────────────────────────────────────────────────────┘
```

## Core Entities

### 1. Resource Group
- **Name**: rg-avmlegacy-wus3
- **Location**: westus3
- **Lock**: CanNotDelete
- **Purpose**: Container for all infrastructure resources

### 2. Virtual Network
- **Name**: vnet-avmlegacy-{random}
- **Address Space**: 10.0.0.0/24
- **Subnets**:
  - vm_subnet: 10.0.0.0/27 (30 usable IPs)
  - AzureBastionSubnet: 10.0.0.32/26 (62 usable IPs, Azure requirement)
  - private_endpoint_subnet: 10.0.0.96/28 (14 usable IPs)
- **Diagnostic Settings**: Enabled, logs to Log Analytics

### 3. Network Security Groups (3)
- **vm_nsg**:
  - Inbound Rules: Allow RDP (3389) from AzureBastionSubnet CIDR
  - Outbound Rules: Allow all (default), NAT Gateway handles internet access
- **bastion_nsg**:
  - Inbound Rules: Allow HTTPS (443) from Internet (Azure Bastion requirement)
  - Outbound Rules: Allow RDP (3389) to vm_subnet CIDR
- **private_endpoint_nsg**:
  - Inbound Rules: Allow SMB (445) from vm_subnet CIDR
  - Outbound Rules: Deny all (implicit)

### 4. Virtual Machine
- **Name**: vm-avmlegacy-{random} (ensure ≤15 chars per spec)
- **Computer Name**: Derived from VM name, truncated to 15 chars if needed
- **Size**: Standard_D2s_v3
- **OS**: Windows Server 2016
- **OS Disk**: Standard HDD (127GB default)
- **Data Disk**: 500GB Standard HDD, LUN 0
- **Admin Username**: vmadmin
- **Admin Password**: Sourced from Key Vault secret (generated via random_password)
- **Managed Identity**: System-assigned
- **Availability Zone**: Zone 1 (or 2, 3 per requirement - never -1)
- **Diagnostic Settings**: Enabled, logs to Log Analytics
- **Lock**: CanNotDelete

### 5. Azure Bastion
- **Name**: bastion-avmlegacy-{random}
- **SKU**: Basic or Standard (verify cost in Phase 0)
- **Subnet**: AzureBastionSubnet (/26)
- **Public IP**: Managed by Bastion
- **Lock**: CanNotDelete

### 6. Key Vault
- **Name**: kv-avmlegacy-{random} (3-24 chars, globally unique)
- **SKU**: Standard
- **Soft Delete**: Enabled (90 days default)
- **Purge Protection**: Enabled
- **RBAC vs Access Policies**: Use RBAC (recommended for AVM)
- **Secrets**:
  - vm-admin-password: Generated from random_password resource
- **Diagnostic Settings**: Enabled, logs to Log Analytics
- **Lock**: CanNotDelete

### 7. Storage Account
- **Name**: stavmlegacy{random} (3-24 chars, lowercase alphanumeric, globally unique)
- **SKU**: Standard_LRS
- **Kind**: StorageV2
- **File Share**:
  - Name**: legacyappdata (or from tfvars)
  - Quota**: 1024 GB (1TB)
  - Tier**: TransactionOptimized (Standard)
- **Public Network Access**: Disabled
- **Private Endpoint**: Enabled, connected to private_endpoint_subnet
- **Diagnostic Settings**: Enabled, logs to Log Analytics
- **Lock**: CanNotDelete

### 8. Private Endpoint
- **Name**: pe-storage-avmlegacy-{random}
- **Subnet**: private_endpoint_subnet
- **Private DNS Integration**: Enabled (creates privatelink.file.core.windows.net DNS entry)
- **Sub-resource**: file (for Azure Files)

### 9. NAT Gateway
- **Name**: nat-avmlegacy-{random}
- **SKU**: Standard
- **Public IP**: Dedicated public IP for outbound traffic
- **Associated Subnets**: vm_subnet only
- **Idle Timeout**: 4 minutes (default)

### 10. Log Analytics Workspace
- **Name**: law-avmlegacy-{random}
- **SKU**: PerGB2018
- **Retention**: 180 days
- **Daily Cap**: None (or set based on cost constraints)
- **Diagnostic Log Sources**: VM, Key Vault, Storage Account

### 11. Metric Alerts (3)
- **VM Stopped Alert**:
  - Metric**: VM Availability (or PowerState)
  - Condition**: Threshold = 0 (stopped)
  - Action Group**: (email/webhook TBD in tfvars)
- **VM Disk Usage Alert**:
  - Metric**: Disk Space Used Percentage
  - Condition**: Threshold > 90%
  - Action Group**: (same as above)
- **Key Vault Access Failure Alert**:
  - Metric**: Failed requests or Access denied events
  - Condition**: Count > 0 over 5 minutes
  - Action Group**: (same as above)

### 12. Supporting Resources
- **random_string**: Generate 6-character unique suffix for naming
- **random_password**: Generate VM admin password (16+ chars, complexity requirements)

## Terraform State Dependencies

Implicit dependency order (Terraform will resolve):
1. random_string, random_password (no dependencies)
2. Resource Group
3. Log Analytics Workspace (for diagnostic settings)
4. Key Vault → secrets (stores random_password)
5. VNet → Subnets
6. NSGs (reference VNet for associations)
7. NAT Gateway → Public IP
8. VM (references Key Vault secret, VNet subnet, NSG)
9. Bastion (references VNet Bastion subnet)
10. Storage Account → File Share → Private Endpoint
11. Diagnostic Settings (references Log Analytics, resources)
12. Metric Alerts (references resources, Log Analytics)
13. Resource Locks (references resources)
```

#### 2. API Contracts

**Output**: N/A for infrastructure project (no external APIs to define)

#### 3. Quickstart Guide

**Output**: `quickstart.md`

```markdown
# Quickstart: Deploy Legacy Business Application Infrastructure

## Prerequisites

1. **Terraform CLI**: Version >= 1.9.0
  ```bash
  terraform version
  # Terraform v1.9.x
  ```

2. **Azure CLI**: Authenticated with sufficient permissions
  ```bash
  az login
  az account show
  # Verify correct subscription
  ```

3. **Azure Subscription**: Contributor role on target subscription or resource group

4. **Terraform State Backend**: Pre-existing Azure Storage Account with container for state
  - Storage Account name: `<your-state-storage>`
  - Container name: `tfstate`
  - SAS token or Storage Account Key

5. **Security Tools** (optional but recommended):
  - tfsec >= 1.28
  - checkov >= 3.0

## Setup Steps

### Step 1: Clone Repository and Navigate to Terraform Directory

```bash
git clone <repository-url>
cd <repository>/terraform
```

### Step 2: Configure Backend

Create `backend.hcl` file (not committed to git):

```hcl
storage_account_name = "<your-state-storage>"
container_name       = "tfstate"
key                  = "my-legacy-workload-prod.tfstate"
resource_group_name  = "<state-storage-resource-group>"
```

### Step 3: Review and Customize prod.tfvars

Edit `prod.tfvars` to customize deployment:

```hcl
# Required variables
location         = "westus3"
workload_name    = "avmlegacy"
environment      = "prod"
vm_admin_secret_name = "vm-admin-password"  # Key Vault secret name

# Optional overrides (defaults provided in variables.tf)
vm_size          = "Standard_D2s_v3"
vm_data_disk_size_gb = 500
file_share_quota_gb  = 1024
log_analytics_retention_days = 180
availability_zone = 1  # or 2, 3 - never -1

# Alert action group (email/webhook)
alert_action_group_email = "admin@example.com"
```

### Step 4: Initialize Terraform

```bash
terraform init -backend-config=backend.hcl
```

Expected output:
```
Terraform has been successfully initialized!
```

### Step 5: Format and Validate

```bash
terraform fmt -recursive
terraform validate
```

Expected output:
```
Success! The configuration is valid.
```

### Step 6: Run Security Scans (Optional)

```bash
tfsec .
checkov -d .
```

Fix any HIGH or CRITICAL findings before proceeding.

### Step 7: Plan Deployment

```bash
terraform plan -var-file=prod.tfvars -out=plan.tfplan
```

**Review the plan carefully**:
- Verify 12-15 resources to be created (exact count depends on AVM module resource expansion)
- Check resource names match naming convention
- Verify no unexpected deletions or replacements
- Confirm all resources deploying to westus3

### Step 8: Apply Deployment

```bash
terraform apply plan.tfplan
```

Deployment takes approximately 20-30 minutes. Progress:
1. Resource Group, Log Analytics (1-2 min)
2. VNet, NSGs, Key Vault (3-5 min)
3. Storage Account, Private Endpoint (5-7 min)
4. NAT Gateway, Bastion (10-15 min - Bastion is slowest)
5. VM (7-10 min)
6. Diagnostic Settings, Alerts, Locks (2-3 min)

### Step 9: Verify Deployment

```bash
# Get outputs
terraform output

# Expected outputs:
# resource_group_name = "rg-avmlegacy-wus3"
# vm_name = "vm-avmlegacy-a1b2c3"
# key_vault_name = "kv-avmlegacy-a1b2c3"
# storage_account_name = "stavmlegacya1b2c3"
# log_analytics_workspace_id = "/subscriptions/..."
```

Check Azure Portal:
1. Navigate to Resource Group `rg-avmlegacy-wus3`
2. Verify VM is running
3. Test Bastion connection (Connect → Bastion)
4. Retrieve password from Key Vault secret
5. Verify Log Analytics has diagnostic logs

## Post-Deployment

### Connect to VM via Bastion

1. Azure Portal → Virtual Machines → `vm-avmlegacy-...`
2. Click "Connect" → "Bastion"
3. Username: `vmadmin`
4. Password: Retrieve from Key Vault:
  ```bash
  az keyvault secret show --name vm-admin-password --vault-name <kv-name> --query value -o tsv
  ```
5. Click "Connect"

### Mount Azure Files Share

From within the VM (via Bastion RDP session):

```powershell
# Get storage account name from terraform output
$storageAccountName = "<storage-account-name>"
$fileShareName = "legacyappdata"

# Note: Authentication via private endpoint - no key needed for mounted drive
# Access share via UNC path using private endpoint IP or FQDN
net use Z: \\$storageAccountName.privatelink.file.core.windows.net\$fileShareName
```

### Verify Internet Connectivity

```powershell
# From VM
Invoke-WebRequest -Uri "https://www.microsoft.com" -UseBasicParsing
# Should succeed via NAT Gateway
```

### Check Diagnostic Logs

Azure Portal → Log Analytics Workspace → Logs:

```kusto
// VM metrics
Perf
| where Computer startswith "vm-avmlegacy"
| where TimeGenerated > ago(1h)
| take 10

// Key Vault access logs
AzureDiagnostics
| where ResourceType == "VAULTS"
| where TimeGenerated > ago(1h)
| take 10
```

## Troubleshooting

### Issue: Terraform init fails with backend authentication error
**Solution**: Verify backend.hcl credentials and ensure storage account allows access from your IP

### Issue: VM creation fails with quota error
**Solution**: Check Azure subscription quotas for Standard_D2s_v3 in westus3 region

### Issue: Bastion deployment times out
**Solution**: Bastion can take 15-20 minutes. If timeout occurs, run `terraform apply` again (idempotent)

### Issue: Cannot connect via Bastion
**Solution**: Verify NSG rules allow RDP from Bastion subnet. Check VM is running. Verify password from Key Vault.

### Issue: File share inaccessible from VM
**Solution**: Verify private endpoint deployed correctly. Check NSG allows SMB (445) from VM subnet. Verify private DNS resolution.

## Cleanup

**Warning**: This destroys all infrastructure. Ensure data is backed up if needed (though per spec, infrastructure is disposable).

```bash
terraform destroy -var-file=prod.tfvars
```

Confirm with `yes` when prompted.

**Note**: Some resources (Key Vault with purge protection) may enter soft-delete state and require manual purge after 90 days.
```

#### 4. Agent Context Update

**Output**: Run agent context update script (if applicable for Copilot context files)

```bash
# Run from repository root
./.specify/scripts/powershell/update-agent-context.ps1 -AgentType copilot
```

This updates .github/copilot-instructions.md or similar with:
- Terraform/AVM technology stack
- westus3 region
- Constitution principles
- Preserves manual additions between markers

#### 5. Re-evaluate Constitution Check

**Post-Design Validation**: Review design artifacts against constitution:

- [x] **Principle I**: All resources in Terraform (data-model.md documents 12 resources via AVM modules)
- [x] **Principle II**: Only AVM modules used (no custom modules in design)
- [x] **Principle III**: Security controls documented in data-model.md (NSGs, Key Vault, managed identity, diagnostic logging, locks)
- [x] **Principle IV**: Single root module structure documented in project structure
- [x] **Principle V**: Quickstart.md documents validation workflow (init → fmt → validate → plan → apply)

**Constitution Compliance Post-Design**: ✅ **MAINTAINED**

---

## Terraform Code Structure

### File: terraform.tf

```hcl
# Terraform and Provider Configuration
# Constitution Principle I & V: Use latest stable Terraform and AzAPI provider

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.12"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }

  # Backend configuration - parameterized via backend.hcl
  backend "azurerm" {
    # Configured via: terraform init -backend-config=backend.hcl
    # backend.hcl contains:
    #   storage_account_name = "..."
    #   container_name       = "tfstate"
    #   key                  = "my-legacy-workload-prod.tfstate"
    #   resource_group_name  = "..."
  }
}

provider "azapi" {
  enable_preflight = true
}

provider "azurerm" {
  features {}
}

provider "random" {}
```

### File: variables.tf

```hcl
# Input Variables for Legacy Business Application Infrastructure
# Constitution Principle I: All configurable values in terraform.tfvars

# Required Variables

variable "location" {
  description = "Azure region for all resources. Per constitution: westus3"
  type        = string
  default     = "westus3"

  validation {
    condition     = var.location == "westus3"
    error_message = "Per constitution IC-001, all resources must deploy to westus3."
  }
}

variable "workload_name" {
  description = "Workload identifier for naming convention. Per constitution: avmlegacy"
  type        = string
  default     = "avmlegacy"
}

variable "environment" {
  description = "Environment name. Per spec: prod only"
  type        = string
  default     = "prod"

  validation {
    condition     = var.environment == "prod"
    error_message = "Per spec FR-024, only production environment is deployed."
  }
}

# VM Configuration

variable "vm_size" {
  description = "Azure VM SKU. Per clarification: Standard_D2s_v3"
  type        = string
  default     = "Standard_D2s_v3"
}

variable "vm_admin_username" {
  description = "VM administrator username. Per spec FR-005: vmadmin"
  type        = string
  default     = "vmadmin"

  validation {
    condition     = var.vm_admin_username == "vmadmin"
    error_message = "Per spec FR-005, VM admin username must be vmadmin."
  }
}

variable "vm_admin_secret_name" {
  description = "Key Vault secret name for VM admin password. Per spec FR-016: configurable"
  type        = string
  default     = "vm-admin-password"
}

variable "vm_data_disk_size_gb" {
  description = "VM data disk size in GB. Per spec FR-003: 500GB"
  type        = number
  default     = 500

  validation {
    condition     = var.vm_data_disk_size_gb == 500
    error_message = "Per spec FR-003, VM data disk must be 500GB."
  }
}

variable "availability_zone" {
  description = "Availability zone for VM. Per spec FR-023: 1, 2, or 3 (never -1)"
  type        = number
  default     = 1

  validation {
    condition     = contains([1, 2, 3], var.availability_zone)
    error_message = "Per spec FR-023 and IC-006, availability zone must be 1, 2, or 3."
  }
}

# Network Configuration

variable "vnet_address_space" {
  description = "VNet address space. Per clarification: 10.0.0.0/24"
  type        = list(string)
  default     = ["10.0.0.0/24"]

  validation {
    condition     = length(var.vnet_address_space) == 1 && var.vnet_address_space[0] == "10.0.0.0/24"
    error_message = "Per clarification, VNet must use 10.0.0.0/24 address space."
  }
}

variable "vm_subnet_cidr" {
  description = "VM subnet CIDR. Per clarification: 10.0.0.0/27"
  type        = string
  default     = "10.0.0.0/27"
}

variable "bastion_subnet_cidr" {
  description = "Bastion subnet CIDR. Per clarification: 10.0.0.32/26 (Azure requires /26 minimum)"
  type        = string
  default     = "10.0.0.32/26"
}

variable "private_endpoint_subnet_cidr" {
  description = "Private Endpoint subnet CIDR. Per clarification: 10.0.0.96/28"
  type        = string
  default     = "10.0.0.96/28"
}

# Storage Configuration

variable "file_share_name" {
  description = "Azure Files share name"
  type        = string
  default     = "legacyappdata"
}

variable "file_share_quota_gb" {
  description = "File share quota in GB. Per clarification: 1024GB (1TB)"
  type        = number
  default     = 1024

  validation {
    condition     = var.file_share_quota_gb == 1024
    error_message = "Per clarification, file share quota must be 1TB (1024GB)."
  }
}

# Observability Configuration

variable "log_analytics_retention_days" {
  description = "Log Analytics retention in days. Per clarification: 180 days"
  type        = number
  default     = 180

  validation {
    condition     = var.log_analytics_retention_days == 180
    error_message = "Per clarification, Log Analytics retention must be 180 days."
  }
}

variable "alert_action_group_email" {
  description = "Email address for alert notification action group"
  type        = string
  # No default - must be provided in tfvars
}

# Tags

variable "tags" {
  description = "Common tags for all resources"
  type        = map(string)
  default = {
    Environment = "Production"
    Workload    = "Legacy Business Application"
    ManagedBy   = "Terraform"
    CostCenter  = "IT-Infrastructure"
  }
}
```

### File: locals.tf

```hcl
# Local Values for Computed Names and Configurations
# Constitution Principle: Naming convention <type>-<workload>-<suffix>

locals {
  # Generate unique suffix for globally unique names
  unique_suffix = random_string.unique_suffix.result

  # Location abbreviation
  location_abbr = "wus3"  # westus3

  # Naming per constitution IC-003
  resource_group_name       = "rg-${var.workload_name}-${var.environment}-${local.location_abbr}"
  vnet_name                = "vnet-${var.workload_name}-${local.unique_suffix}"
  vm_nsg_name              = "nsg-vm-${var.workload_name}-${local.unique_suffix}"
  bastion_nsg_name         = "nsg-bastion-${var.workload_name}-${local.unique_suffix}"
  private_endpoint_nsg_name = "nsg-pe-${var.workload_name}-${local.unique_suffix}"

  # VM name must be ≤15 chars for computer name (Windows NetBIOS limit per spec FR-004)
  vm_name_raw = "vm-${var.workload_name}-${local.unique_suffix}"
  vm_name = substr(local.vm_name_raw, 0, min(length(local.vm_name_raw), 15))
  vm_computer_name = local.vm_name  # Same as VM name, truncated to 15 chars

  bastion_name             = "bastion-${var.workload_name}-${local.unique_suffix}"
  key_vault_name           = "kv-${var.workload_name}-${local.unique_suffix}"

  # Storage account name: lowercase alphanumeric only, max 24 chars
  storage_account_name = "st${var.workload_name}${local.unique_suffix}"  # e.g., "stavmlegacya1b2c3"

  private_endpoint_name    = "pe-storage-${var.workload_name}-${local.unique_suffix}"
  nat_gateway_name         = "nat-${var.workload_name}-${local.unique_suffix}"
  nat_public_ip_name       = "pip-nat-${var.workload_name}-${local.unique_suffix}"
  law_name                 = "law-${var.workload_name}-${local.unique_suffix}"
  action_group_name        = "ag-${var.workload_name}-${local.unique_suffix}"

  # Subnet names
  vm_subnet_name               = "vm-subnet"
  bastion_subnet_name          = "AzureBastionSubnet"  # Azure requirement: exact name
  private_endpoint_subnet_name = "private-endpoint-subnet"

  # Common tags
  common_tags = merge(
    var.tags,
    {
      DeployedBy = "Terraform"
      Region     = var.location
      Spec       = "001-my-legacy-workload"
    }
  )
}
```

### File: main.tf

```hcl
#############################################################################
# Legacy Business Application Infrastructure - Main Configuration
# Constitution Compliance: All principles I-V enforced
# Spec: 001-my-legacy-workload
#############################################################################

# Random Resources for Naming

resource "random_string" "unique_suffix" {
  length  = 6
  special = false
  upper   = false
  numeric = true
}

# Per spec: Generate VM admin password using random_password
# This will be stored in Key Vault via AVM module interface
resource "random_password" "vm_admin_password" {
  length           = 24
  special          = true
  min_lower        = 2
  min_upper        = 2
  min_numeric      = 2
  min_special      = 2
  override_special = "!@#$%^&*()-_=+[]{}|;:,.<>?"
}

#############################################################################
# Resource Group
# Constitution IC-005: Single resource group for all resources
#############################################################################

module "resource_group" {
  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
  version = "~> 0.2"

  name     = local.resource_group_name
  location = var.location
  tags     = local.common_tags
  lock = {
    kind = "CanNotDelete"
    name = "rg-lock-do-not-delete"
  }
}

#############################################################################
# Log Analytics Workspace
# Spec FR-018: Log Analytics for centralized logging
# Created early for diagnostic settings on other resources
#############################################################################

module "log_analytics" {
  source  = "Azure/avm-res-operationalinsights-workspace/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION from Terraform Registry

  name                = local.law_name
  resource_group_name = module.resource_group.name
  location            = var.location

  # Per clarification: 180-day retention
  sku                 = "PerGB2018"
  retention_in_days   = var.log_analytics_retention_days
  daily_quota_gb      = -1  # No daily cap (or set based on cost requirements)

  tags = local.common_tags

  # Lock interface (if supported by AVM module)
  lock = {
    kind = "CanNotDelete"
    name = "law-lock-do-not-delete"
  }
}

#############################################################################
# Virtual Network
# Spec FR-007: VNet with 3 subnets
# Constitution IC-008: 10.0.0.0/24 with specific CIDR allocations
#############################################################################

module "virtual_network" {
  source  = "Azure/avm-res-network-virtualnetwork/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.vnet_name
  resource_group_name = module.resource_group.name
  location            = var.location
  address_space       = var.vnet_address_space

  # Define 3 subnets per spec FR-007
  subnets = {
    vm_subnet = {
      name                               = local.vm_subnet_name
      address_prefixes                   = [var.vm_subnet_cidr]
      network_security_group_resource_id = module.vm_nsg.resource_id
    }
    bastion_subnet = {
      name                               = local.bastion_subnet_name  # Must be exact name per Azure requirement
      address_prefixes                   = [var.bastion_subnet_cidr]
      network_security_group_resource_id = module.bastion_nsg.resource_id
    }
    private_endpoint_subnet = {
      name                               = local.private_endpoint_subnet_name
      address_prefixes                   = [var.private_endpoint_subnet_cidr]
      network_security_group_resource_id = module.private_endpoint_nsg.resource_id
      # Per spec IC-009: Disable network policies for private endpoints
      private_endpoint_network_policies_enabled = false
    }
  }

  tags = local.common_tags

  # Diagnostic settings - Constitution SEC-010
  diagnostic_settings = {
    law_diag = {
      name                  = "vnet-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
      # Enable all log categories and metrics (check module documentation for exact syntax)
    }
  }

  # Lock interface
  lock = {
    kind = "CanNotDelete"
    name = "vnet-lock-do-not-delete"
  }
}

#############################################################################
# Network Security Groups
# Spec FR-008: NSGs with deny-by-default posture
# Constitution SEC-004: Explicit allow rules only
#############################################################################

# VM Subnet NSG
module "vm_nsg" {
  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.vm_nsg_name
  resource_group_name = module.resource_group.name
  location            = var.location

  # Spec FR-009: Allow RDP from Bastion subnet only
  security_rules = [
    {
      name                       = "Allow-RDP-From-Bastion"
      priority                   = 100
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = "3389"
      source_address_prefix      = var.bastion_subnet_cidr
      destination_address_prefix = var.vm_subnet_cidr
      description                = "Allow RDP from Bastion subnet per spec FR-009"
    },
    {
      name                       = "Deny-All-Inbound"
      priority                   = 4096
      direction                  = "Inbound"
      access                     = "Deny"
      protocol                   = "*"
      source_port_range          = "*"
      destination_port_range     = "*"
      source_address_prefix      = "*"
      destination_address_prefix = "*"
      description                = "Explicit deny-by-default per constitution SEC-004"
    }
  ]

  tags = local.common_tags

  diagnostic_settings = {
    law_diag = {
      name                  = "nsg-vm-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
    }
  }
}

# Bastion Subnet NSG
module "bastion_nsg" {
  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
  version = "~> 0.1.0"

  name                = local.bastion_nsg_name
  resource_group_name = module.resource_group.name
  location            = var.location

  # Bastion NSG rules per Azure Bastion requirements
  # See: https://learn.microsoft.com/azure/bastion/bastion-nsg
  security_rules = [
    {
      name                       = "Allow-HTTPS-Inbound"
      priority                   = 100
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = "443"
      source_address_prefix      = "Internet"
      destination_address_prefix = "*"
      description                = "Allow HTTPS from Internet per Azure Bastion requirement"
    },
    {
      name                       = "Allow-GatewayManager-Inbound"
      priority                   = 110
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = "443"
      source_address_prefix      = "GatewayManager"
      destination_address_prefix = "*"
      description                = "Allow Azure Bastion control plane"
    },
    {
      name                       = "Allow-RDP-To-VM-Subnet"
      priority                   = 100
      direction                  = "Outbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = "3389"
      source_address_prefix      = "*"
      destination_address_prefix = var.vm_subnet_cidr
      description                = "Allow RDP to VM subnet per spec SEC-006"
    },
    {
      name                       = "Allow-AzureCloud-Outbound"
      priority                   = 110
      direction                  = "Outbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = "443"
      source_address_prefix      = "*"
      destination_address_prefix = "AzureCloud"
      description                = "Allow Bastion to Azure services"
    }
  ]

  tags = local.common_tags

  diagnostic_settings = {
    law_diag = {
      name                  = "nsg-bastion-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
    }
  }
}

# Private Endpoint Subnet NSG
module "private_endpoint_nsg" {
  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
  version = "~> 0.1.0"

  name                = local.private_endpoint_nsg_name
  resource_group_name = module.resource_group.name
  location            = var.location

  # Spec SEC-007: Allow SMB from VM subnet
  security_rules = [
    {
      name                       = "Allow-SMB-From-VM-Subnet"
      priority                   = 100
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = "445"
      source_address_prefix      = var.vm_subnet_cidr
      destination_address_prefix = var.private_endpoint_subnet_cidr
      description                = "Allow SMB from VM subnet per spec SEC-007"
    },
    {
      name                       = "Deny-All-Inbound"
      priority                   = 4096
      direction                  = "Inbound"
      access                     = "Deny"
      protocol                   = "*"
      source_port_range          = "*"
      destination_port_range     = "*"
      source_address_prefix      = "*"
      destination_address_prefix = "*"
      description                = "Explicit deny-by-default"
    }
  ]

  tags = local.common_tags

  diagnostic_settings = {
    law_diag = {
      name                  = "nsg-pe-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
    }
  }
}

#############################################################################
# NAT Gateway
# Spec FR-012: NAT Gateway for outbound internet access
#############################################################################

module "nat_gateway" {
  source  = "Azure/avm-res-network-natgateway/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.nat_gateway_name
  resource_group_name = module.resource_group.name
  location            = var.location

  # Public IP for outbound traffic
  public_ip_addresses = [
    {
      name  = local.nat_public_ip_name
      zones = [var.availability_zone]  # Match VM availability zone per best practice
    }
  ]

  # Associate with VM subnet
  subnet_associations = [
    {
      subnet_id = module.virtual_network.subnets["vm_subnet"].id
    }
  ]

  tags = local.common_tags
}

#############################################################################
# Key Vault
# Spec FR-015: Key Vault for VM admin password
# Constitution SEC-002: Store secrets in Key Vault
#############################################################################

data "azapi_client_config" "current" {}

module "key_vault" {
  source  = "Azure/avm-res-keyvault-vault/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.key_vault_name
  resource_group_name = module.resource_group.name
  location            = var.location

  tenant_id                  = data.azapi_client_config.current.tenant_id
  sku_name                   = "standard"
  soft_delete_retention_days = 90
  purge_protection_enabled   = true  # Per spec SEC-012

  # Use RBAC authorization (recommended over access policies)
  enable_rbac_authorization = true

  # Per spec FR-006 & FR-016: Store VM admin password as secret
  # AVM secrets interface (check module documentation for exact syntax)
  secrets = {
    vm_admin_password = {
      name  = var.vm_admin_secret_name
      value = random_password.vm_admin_password.result
      # Optionally set expiration, content_type, etc.
    }
  }

  tags = local.common_tags

  # Diagnostic settings - Constitution SEC-010
  diagnostic_settings = {
    law_diag = {
      name                  = "kv-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
    }
  }

  # Lock - Constitution SEC-011
  lock = {
    kind = "CanNotDelete"
    name = "kv-lock-do-not-delete"
  }

  role_assignments = {
    deployment_identity_secrets_officer = {
      role_definition_id_or_name = "Key Vault Secrets Officer"
      principal_id               = data.azapi_client_config.current.object_id
    }
  }

  depends_on = [random_password.vm_admin_password]
}

#############################################################################
# Storage Account with File Share
# Spec FR-013: Storage account with Azure Files
# Spec FR-014: Private endpoint access only
#############################################################################

module "storage_account" {
  source  = "Azure/avm-res-storage-storageaccount/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.storage_account_name
  resource_group_name = module.resource_group.name
  location            = var.location

  account_kind             = "StorageV2"
  account_tier             = "Standard"  # HDD per spec IC-007
  account_replication_type = "LRS"       # No geo-redundancy per constitution IC-004

  # Per spec SEC-009: Disable public network access
  public_network_access_enabled = false

  # Encryption per spec SEC-013
  enable_infrastructure_encryption = true

  # File share configuration
  file_shares = {
    legacy_app_data = {
      name  = var.file_share_name
      quota = var.file_share_quota_gb
      tier  = "TransactionOptimized"  # Standard tier
    }
  }

  # Private endpoint configuration (if supported by module interface)
  private_endpoints = {
    file_endpoint = {
      name                          = local.private_endpoint_name
      subnet_resource_id            = module.virtual_network.subnets["private_endpoint_subnet"].id
      subresource_names             = ["file"]  # For Azure Files
      private_dns_zone_group_name   = "file-private-dns"
      # Private DNS zone integration (auto-created or existing)
      private_dns_zone_resource_ids = [] # Or specify existing zone
    }
  }

  tags = local.common_tags

  # Diagnostic settings
  diagnostic_settings = {
    law_diag = {
      name                  = "storage-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
    }
  }

  # Lock
  lock = {
    kind = "CanNotDelete"
    name = "storage-lock-do-not-delete"
  }
}

#############################################################################
# Azure Bastion
# Spec FR-010: Azure Bastion for secure RDP access
#############################################################################

module "bastion" {
  source  = "Azure/avm-res-network-bastionhost/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.bastion_name
  resource_group_name = module.resource_group.name
  location            = var.location

  # Bastion subnet (must be exact name "AzureBastionSubnet")
  subnet_id = module.virtual_network.subnets["bastion_subnet"].id

  # SKU: Basic or Standard (check cost implications)
  sku = "Basic"  # Or "Standard" for additional features

  # Public IP managed by Bastion module
  # (AVM module typically creates this automatically)

  tags = local.common_tags

  # Lock
  lock = {
    kind = "CanNotDelete"
    name = "bastion-lock-do-not-delete"
  }
}

#############################################################################
# Virtual Machine
# Spec FR-001: Windows Server 2016 VM with Standard_D2s_v3
#############################################################################

module "virtual_machine" {
  source  = "Azure/avm-res-compute-virtualmachine/azurerm"
  version = "~> 0.1.0"  # VERIFY LATEST VERSION

  name                = local.vm_name  # Truncated to 15 chars
  resource_group_name = module.resource_group.name
  location            = var.location

  # Per spec FR-004: Computer name (NetBIOS) ≤15 chars
  computer_name = local.vm_computer_name

  # Per clarification: Standard_D2s_v3
  vm_size = var.vm_size

  # Per spec IC-006: Availability zone 1, 2, or 3 (never -1)
  zone = var.availability_zone

  # Windows Server 2016 image
  os_profile = {
    windows = {
      admin_username = var.vm_admin_username
      # Reference password from Key Vault secret
      admin_password = module.key_vault.secrets[var.vm_admin_secret_name].value
    }
  }

  source_image_reference = {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2016-Datacenter"
    version   = "latest"
  }

  # Network configuration
  network_interfaces = {
    nic1 = {
      name = "${local.vm_name}-nic"
      ip_configurations = {
        ipconfig1 = {
          name                          = "ipconfig1"
          subnet_id                     = module.virtual_network.subnets["vm_subnet"].id
          private_ip_address_allocation = "Dynamic"
          # Per spec FR-011: No public IP
          public_ip_address_id = null
        }
      }
    }
  }

  # OS disk: Standard HDD per spec FR-002
  os_disk = {
    name                 = "${local.vm_name}-osdisk"
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"  # Standard HDD
    disk_size_gb         = 127  # Default Windows Server size
  }

  # Data disk: 500GB Standard HDD per spec FR-003
  data_disks = {
    data1 = {
      name                 = "${local.vm_name}-datadisk"
      lun                  = 0
      caching              = "ReadWrite"
      storage_account_type = "Standard_LRS"
      disk_size_gb         = var.vm_data_disk_size_gb
    }
  }

  # Per constitution SEC-001: System-assigned managed identity
  managed_identities = {
    system_assigned = true
  }

  tags = local.common_tags

  # Diagnostic settings - Constitution SEC-010
  diagnostic_settings = {
    law_diag = {
      name                  = "vm-diagnostics"
      workspace_resource_id = module.log_analytics.resource_id
    }
  }

  # Lock - Constitution SEC-011
  lock = {
    kind = "CanNotDelete"
    name = "vm-lock-do-not-delete"
  }

  depends_on = [module.key_vault]
}

#############################################################################
# Monitoring and Alerts
# Spec FR-020: Critical alerts for VM stopped, disk usage, Key Vault access
#############################################################################

# Action Group for Alert Notifications
resource "azapi_resource" "action_group" {
  type      = "Microsoft.Insights/actionGroups@2023-01-01"
  name      = local.action_group_name
  parent_id = module.resource_group.resource_id
  location  = "global"
  tags      = local.common_tags
  body = {
    properties = {
      enabled        = true
      groupShortName = "avmalerts"
      emailReceivers = [
        {
          name                 = "admin-email"
          emailAddress         = var.alert_action_group_email
          useCommonAlertSchema = true
        }
      ]
    }
  }

  response_export_values = []
}

# Alert 1: VM Stopped/Deallocated
resource "azapi_resource" "vm_stopped_metric_alert" {
  type      = "Microsoft.Insights/metricAlerts@2018-03-01"
  name      = "alert-vm-stopped-${local.vm_name}"
  parent_id = module.resource_group.resource_id
  location  = "global"
  tags      = local.common_tags
  body = {
    properties = {
      description         = "Alert when VM is stopped or deallocated"
      severity            = 0
      enabled             = true
      scopes              = [module.virtual_machine.resource_id]
      evaluationFrequency = "PT5M"
      windowSize          = "PT5M"
      criteria = {
        "odata.type" = "Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria"
        allOf = [
          {
            name             = "vm_availability"
            criterionType    = "StaticThresholdCriterion"
            metricNamespace  = "Microsoft.Compute/virtualMachines"
            metricName       = "VmAvailabilityMetric"
            operator         = "LessThan"
            threshold        = 1
            timeAggregation  = "Average"
          }
        ]
      }
      actions = [{ actionGroupId = azapi_resource.action_group.id }]
    }
  }

  response_export_values = []
}

# Alert 2: VM Disk Usage >90%
resource "azapi_resource" "vm_disk_usage_metric_alert" {
  type      = "Microsoft.Insights/metricAlerts@2018-03-01"
  name      = "alert-vm-disk-usage-${local.vm_name}"
  parent_id = module.resource_group.resource_id
  location  = "global"
  tags      = local.common_tags
  body = {
    properties = {
      description         = "Alert when VM disk usage exceeds 90%"
      severity            = 0
      enabled             = true
      scopes              = [module.virtual_machine.resource_id]
      evaluationFrequency = "PT15M"
      windowSize          = "PT15M"
      criteria = {
        "odata.type" = "Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria"
        allOf = [
          {
            name             = "vm_disk_usage"
            criterionType    = "StaticThresholdCriterion"
            metricNamespace  = "Microsoft.Compute/virtualMachines"
            metricName       = "OS Disk Used Percent"
            operator         = "GreaterThan"
            threshold        = 90
            timeAggregation  = "Average"
          }
        ]
      }
      actions = [{ actionGroupId = azapi_resource.action_group.id }]
    }
  }

  response_export_values = []
}

# Alert 3: Key Vault Access Failures
resource "azapi_resource" "key_vault_access_failures_metric_alert" {
  type      = "Microsoft.Insights/metricAlerts@2018-03-01"
  name      = "alert-kv-access-failures-${local.key_vault_name}"
  parent_id = module.resource_group.resource_id
  location  = "global"
  tags      = local.common_tags
  body = {
    properties = {
      description         = "Alert when Key Vault access failures occur"
      severity            = 0
      enabled             = true
      scopes              = [module.key_vault.resource_id]
      evaluationFrequency = "PT5M"
      windowSize          = "PT5M"
      criteria = {
        "odata.type" = "Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria"
        allOf = [
          {
            name            = "key_vault_access_failures"
            criterionType   = "StaticThresholdCriterion"
            metricNamespace = "Microsoft.KeyVault/vaults"
            metricName      = "ServiceApiResult"
            operator        = "GreaterThan"
            threshold       = 0
            timeAggregation = "Count"
            dimensions = [
              {
                name     = "StatusCode"
                operator = "Include"
                values   = ["403"]
              }
            ]
          }
        ]
      }
      actions = [{ actionGroupId = azapi_resource.action_group.id }]
    }
  }

  response_export_values = []
}

#############################################################################
# Note: File share mount to VM will be implemented in a later phase
# Per instructions: "Don't connect the file share to the VM just yet"
#############################################################################
```

### File: outputs.tf

```hcl
# Outputs for Legacy Business Application Infrastructure
# These values can be used by external modules or for manual reference

output "resource_group_name" {
  description = "Name of the resource group containing all resources"
  value       = module.resource_group.name
}

output "resource_group_id" {
  description = "ID of the resource group"
  value       = module.resource_group.resource_id
}

output "virtual_network_name" {
  description = "Name of the virtual network"
  value       = module.virtual_network.name
}

output "virtual_network_id" {
  description = "ID of the virtual network"
  value       = module.virtual_network.resource_id
}

output "vm_name" {
  description = "Name of the virtual machine"
  value       = module.virtual_machine.name
}

output "vm_id" {
  description = "ID of the virtual machine"
  value       = module.virtual_machine.resource_id
}

output "vm_private_ip_address" {
  description = "Private IP address of the VM"
  value       = module.virtual_machine.network_interfaces["nic1"].ip_configurations["ipconfig1"].private_ip_address
}

output "vm_computer_name" {
  description = "Computer name (NetBIOS name) of the VM"
  value       = local.vm_computer_name
}

output "key_vault_name" {
  description = "Name of the Key Vault"
  value       = module.key_vault.name
}

output "key_vault_id" {
  description = "ID of the Key Vault"
  value       = module.key_vault.resource_id
}

output "key_vault_uri" {
  description = "URI of the Key Vault"
  value       = module.key_vault.vault_uri
}

output "vm_admin_secret_name" {
  description = "Name of the Key Vault secret containing VM admin password"
  value       = var.vm_admin_secret_name
  sensitive   = true
}

output "storage_account_name" {
  description = "Name of the storage account"
  value       = module.storage_account.name
}

output "storage_account_id" {
  description = "ID of the storage account"
  value       = module.storage_account.resource_id
}

output "file_share_name" {
  description = "Name of the Azure Files share"
  value       = var.file_share_name
}

output "bastion_name" {
  description = "Name of the Azure Bastion host"
  value       = module.bastion.name
}

output "bastion_id" {
  description = "ID of the Azure Bastion host"
  value       = module.bastion.resource_id
}

output "log_analytics_workspace_name" {
  description = "Name of the Log Analytics workspace"
  value       = module.log_analytics.name
}

output "log_analytics_workspace_id" {
  description = "ID of the Log Analytics workspace"
  value       = module.log_analytics.resource_id
}

output "nat_gateway_name" {
  description = "Name of the NAT Gateway"
  value       = module.nat_gateway.name
}

output "nat_gateway_public_ip" {
  description = "Public IP address of the NAT Gateway"
  value       = module.nat_gateway.public_ip_addresses[0].ip_address
}

# Sensitive outputs - use with caution
output "vm_admin_password" {
  description = "VM administrator password (retrieve from Key Vault instead)"
  value       = random_password.vm_admin_password.result
  sensitive   = true
}

# Instructions for accessing resources
output "bastion_connect_instructions" {
  description = "Instructions for connecting to VM via Bastion"
  value       = <<-EOT
    1. Navigate to Azure Portal
    2. Go to Virtual Machines -> ${module.virtual_machine.name}
    3. Click "Connect" -> "Bastion"
    4. Username: ${var.vm_admin_username}
    5. Password: Retrieve from Key Vault secret "${var.vm_admin_secret_name}"
      Command: az keyvault secret show --name ${var.vm_admin_secret_name} --vault-name ${module.key_vault.name} --query value -o tsv
  EOT
}

output "file_share_mount_instructions" {
  description = "Instructions for mounting Azure Files share from VM"
  value       = <<-EOT
    From within the VM (via Bastion RDP):
    1. Open PowerShell as Administrator
    2. Run: net use Z: \\${module.storage_account.name}.privatelink.file.core.windows.net\${var.file_share_name}
    3. Verify: dir Z:

    Note: Authentication via private endpoint - no storage key needed for mounted drive
  EOT
}
```

### File: prod.tfvars

```hcl
# Production Environment Configuration
# Legacy Business Application Infrastructure
#
# Per spec FR-021: All configurable values in this file (not hardcoded in main.tf)
# Per spec FR-022: Rich comments explaining purpose

#############################################################################
# Core Configuration
#############################################################################

# Azure region for all resources
# Per constitution IC-001: Must be westus3
location = "westus3"

# Workload identifier for resource naming
# Per constitution: avmlegacy for legacy workload
workload_name = "avmlegacy"

# Environment name
# Per spec FR-024: Production only (no dev/test/staging)
environment = "prod"

#############################################################################
# Virtual Machine Configuration
#############################################################################

# VM size/SKU
# Per clarification: Standard_D2s_v3 (2 cores, 8GB RAM)
vm_size = "Standard_D2s_v3"

# VM administrator username
# Per spec FR-005: Must be "vmadmin"
vm_admin_username = "vmadmin"

# Key Vault secret name for VM admin password
# Per spec FR-016: Configurable via this variable
# Password will be automatically generated and stored in Key Vault
vm_admin_secret_name = "vm-admin-password"

# VM data disk size in GB
# Per spec FR-003: Must be 500GB HDD
vm_data_disk_size_gb = 500

# Availability zone for VM
# Per spec FR-023: Must be 1, 2, or 3 (never -1)
# Choose based on region availability
availability_zone = 1

#############################################################################
# Network Configuration
#############################################################################

# Virtual network address space
# Per clarification: 10.0.0.0/24 (minimal allocation, cost-optimized)
vnet_add_space = ["10.0.0.0/24"]

# VM subnet CIDR
# Per clarification: 10.0.0.0/27 (30 usable IPs)
vm_subnet_cidr = "10.0.0.0/27"

# Bastion subnet CIDR
# Per clarification: 10.0.0.32/26 (62 usable IPs, Azure /26 minimum requirement)
bastion_subnet_cidr = "10.0.0.32/26"

# Private Endpoint subnet CIDR
# Per clarification: 10.0.0.96/28 (14 usable IPs, sufficient for storage private endpoint)
private_endpoint_subnet_cidr = "10.0.0.96/28"

#############################################################################
# Storage Configuration
#############################################################################

# Azure Files share name
# Default: legacyappdata
# Change if specific naming required by legacy application
file_share_name = "legacyappdata"

# File share provisioned capacity in GB
# Per clarification: 1024GB (1TB) for large capacity and growth
file_share_quota_gb = 1024

#############################################################################
# Observability Configuration
#############################################################################

# Log Analytics retention period in days
# Per clarification: 180 days for extended compliance coverage
log_analytics_retention_days = 180

# Email address for alert notifications
# **REQUIRED**: Update with actual administrator email
# Receives critical alerts for VM stopped, disk >90%, Key Vault access failures
alert_action_group_email = "admin@example.com"  # ⚠️ UPDATE THIS

#############################################################################
# Resource Tags
#############################################################################

# Common tags applied to all resources
# Add additional cost center, project, owner tags as needed
tags = {
  Environment  = "Production"
  Workload     = "Legacy Business Application"
  ManagedBy    = "Terraform"
  CostCenter   = "IT-Infrastructure"
  Compliance   = "Required"
  DeployedBy   = "Infrastructure Team"
  DeployedDate = "2026-02-18"
  Spec         = "001-my-legacy-workload"
}

#############################################################################
# Notes
#############################################################################

# 1. Per clarification: No backup solution (infrastructure is disposable/recreatable from Terraform)
# 2. Per constitution: Single production environment only (no dev/test)
# 3. Per spec: All resources in westus3 region, single resource group
# 4. Per spec: VM password auto-generated, stored in Key Vault, not in this file
# 5. File share not yet mounted to VM - will be configured in later phase
#############################################################################
```

---

## Summary

Implementation plan complete with:

✅ **Phase 0 Research**: Documented tasks for verifying Terraform/provider versions and researching 10 AVM modules
✅ **Phase 1 Design**: Created data-model.md structure, quickstart guide, and agent context update approach
✅ **Terraform Code**: Complete root module with 5 files (terraform.tf, variables.tf, locals.tf, main.tf, outputs.tf, prod.tfvars)
✅ **Constitution Compliance**: All 5 principles validated pre and post-design
✅ **Security**: VM password via random_password → Key Vault → VM reference flow, NSGs, diagnostic logging, resource locks
✅ **Spec Compliance**: All 25 functional requirements addressed in code structure

**Next Steps**:
1. Execute Phase 0 research to verify exact AVM module versions from Terraform Registry
2. Run `/speckit.tasks` to generate detailed task breakdown for implementation
3. Begin implementation with terraform init and validation workflow
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture the changes in your project, with a comment of something like Plan created.

5. Checklist (Optional)

Spec Kit uses /speckit.checklist to validate the requirements. The prompt doesn’t require any specific inputs as it analyzes the existing plan for gaps.

Info

To learn more about the checklist step, see the Checklist chapter in the Spec Kit article.

  1. Run the following prompt to generate checklist items for our example:
​
/speckit.checklist
➕ Expand to see the results

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

When running the checklist prompt, Copilot may ask you a number of depth questions to clarify certain aspects of the plan. Here’s an example of what that looks like. You can answer in the following format, e.g.: Q1: E, Q2:A, Q3:A
Specify Bootstrap

In the Copilot chat window, you should see results, similar to this:
Specify Bootstrap

<!-- markdownlint-disable -->
# Implementation Plan Quality Validation Checklist

**Purpose**: Validate quality, completeness, and clarity of implementation plan and supporting documentation
**Created**: 2026-01-27
**Scope**: Comprehensive review across functional, security, network, and monitoring requirements
**Depth**: Standard (~40 items)
**Feature**: [spec.md](../spec.md) | [plan.md](../plan.md)

---

## Requirement Completeness

Requirements documentation coverage and thoroughness.

- [ ] CHK001 - Are VM compute requirements (CPU, memory, disk) explicitly specified with exact values? [Completeness, Spec §FR-001]
- [ ] CHK002 - Are all three network subnet ranges defined with CIDR notation and purpose documented? [Completeness, Spec §FR-003]
- [ ] CHK003 - Are storage account requirements specified for both file share quota and performance tier? [Completeness, Spec §FR-005]
- [ ] CHK004 - Are availability zone requirements documented with explicit valid range (1-3, never -1)? [Completeness, Spec §FR-014]
- [ ] CHK005 - Are diagnostic logging requirements defined for all resource types requiring monitoring? [Completeness, Spec §SEC-001]
- [ ] CHK006 - Are all 12 AVM modules documented with exact version numbers and purpose statements? [Completeness, Plan §Technical Context]
- [ ] CHK007 - Are deployment sequence phases defined with resource dependencies explicitly stated? [Completeness, Data-Model §Deployment Sequence]
- [ ] CHK008 - Are NSG security rules specified for all three subnets with protocol, port, and direction details? [Completeness, Data-Model §NSG Configuration]

## Requirement Clarity

Specificity and measurability of requirements to eliminate ambiguity.

- [ ] CHK009 - Is "Standard HDD" quantified with specific Azure SKU names (e.g., StandardSSD_LRS)? [Clarity, Spec §FR-001, FR-002]
- [ ] CHK010 - Is "minimal naming" defined with exact pattern format and character count range? [Clarity, Constitution §V, Plan §Technical Context]
- [ ] CHK011 - Are alert thresholds specified with exact percentage values and evaluation windows? [Clarity, Spec §MON-004, Data-Model §Alert Configuration]
- [ ] CHK012 - Is "secure remote access" quantified with specific protocol (RDP), port, and authentication method? [Clarity, Spec §FR-004]
- [ ] CHK013 - Are "least-privilege NSG rules" defined with concrete allow/deny examples per subnet? [Clarity, Spec §SEC-003]
- [ ] CHK014 - Is "deployment within 20 minutes" defined as a measurable success criterion with verification method? [Measurability, Spec §SC-001]
- [ ] CHK015 - Is password generation approach explicitly defined with uniqueString() seed sources documented? [Clarity, Research §Password Generation]
- [ ] CHK016 - Are resource name length constraints specified with Azure limits (e.g., Storage 24 chars, VM NetBIOS 15 chars)? [Clarity, Spec §FR-010, Data-Model §Resource Names]

## Requirement Consistency

Alignment and non-contradiction across specification, plan, and supporting documents.

- [ ] CHK017 - Do VM sizing requirements in spec match the data model configuration (Standard_D2s_v3 consistently specified)? [Consistency, Spec §FR-001, Data-Model §VM Config]
- [ ] CHK018 - Do network CIDR blocks in spec align with data model subnet allocations (10.0.0.0/24 breakdown)? [Consistency, Spec §FR-003, Data-Model §Network Topology]
- [ ] CHK019 - Do AVM module versions in plan match research document module selections (all 12 modules)? [Consistency, Plan §AVM Modules, Research §Module Inventory]
- [ ] CHK020 - Do alert requirements in spec match alert configuration in data model (3 critical alerts)? [Consistency, Spec §MON-003-005, Data-Model §Alerts]
- [ ] CHK021 - Do diagnostic logging requirements align across SEC-001 (spec) and technical context (plan)? [Consistency, Spec §SEC-001, Plan §Security Baseline]
- [ ] CHK022 - Does naming convention in constitution match implementation in data model? [Consistency, Constitution §V, Data-Model §Naming Model]
- [ ] CHK023 - Do deployment phases in plan align with dependency graph in data model? [Consistency, Plan §Phase 3, Data-Model §Deployment Sequence]

## Acceptance Criteria Quality

Measurability and testability of success criteria.

- [ ] CHK024 - Are all 11 success criteria (SC-001 to SC-011) objectively measurable with pass/fail conditions? [Measurability, Spec §Success Criteria]
- [ ] CHK025 - Can VM accessibility (SC-004) be verified through documented test procedure in quickstart? [Testability, Spec §SC-004, Quickstart §4.2]
- [ ] CHK026 - Can NSG rule effectiveness (SC-009) be validated with concrete test scenarios? [Testability, Spec §SC-009]
- [ ] CHK027 - Can Log Analytics ingestion (SC-007) be verified within specified 5-minute timeframe? [Measurability, Spec §SC-007]
- [ ] CHK028 - Are constitution compliance gates (all 6 principles) verifiable with documented evidence? [Measurability, Plan §Constitution Check]

## Scenario Coverage

Completeness of primary, alternate, error, recovery, and non-functional scenarios.

- [ ] CHK029 - Are all five user stories independently testable as documented in spec? [Coverage, Primary Flows, Spec §User Scenarios]
- [ ] CHK030 - Are VM computer name length violations (>15 chars) addressed with edge case handling? [Coverage, Edge Case, Spec §Edge Cases]
- [ ] CHK031 - Are storage account naming violations (>24 chars, invalid chars) documented as edge cases? [Coverage, Edge Case, Spec §Edge Cases]
- [ ] CHK032 - Are private endpoint deployment failures with successful storage account handled? [Coverage, Exception Flow, Spec §Edge Cases]
- [ ] CHK033 - Are Key Vault secret access failures monitored with alert configuration? [Coverage, Exception Flow, Spec §MON-005]
- [ ] CHK034 - Are availability zone validation requirements (1-3 only, never -1) enforced? [Coverage, Edge Case, Spec §FR-014]
- [ ] CHK035 - Are performance requirements addressed for standard HDD selection rationale? [Coverage, Non-Functional, Research §Storage Module]

## Edge Case Coverage

Boundary conditions, error states, and exceptional scenarios.

- [ ] CHK036 - Are requirements defined for zero-subnet scenarios or invalid CIDR blocks? [Gap, Edge Case]
- [ ] CHK037 - Are rollback requirements defined if VM deployment succeeds but Key Vault secret creation fails? [Gap, Recovery Flow]
- [ ] CHK038 - Are concurrent deployment conflict scenarios (multiple simultaneous deployments) addressed? [Gap, Edge Case]
- [ ] CHK039 - Are requirements specified for when Log Analytics workspace is unavailable during resource deployment? [Gap, Exception Flow]

## Dependencies & Assumptions

External dependencies, prerequisites, and assumption documentation.

- [ ] CHK040 - Are all Azure resource provider registration requirements documented as prerequisites? [Dependency, Quickstart §Prerequisites]
- [ ] CHK041 - Are subscription quota requirements validated as documented assumptions? [Assumption, Spec §Assumptions]
- [ ] CHK042 - Are tool version requirements (Bicep 0.33.0+, Azure CLI 2.65.0+) specified with verification commands? [Dependency, Plan §Technical Context, Quickstart §Prerequisites]
- [ ] CHK043 - Are required Azure permissions documented with specific role names? [Dependency, Quickstart §Prerequisites]
- [ ] CHK044 - Is the assumption about Windows Server 2016 image availability in westus3 validated? [Assumption, Spec §Assumptions]

## Traceability

Linkage between requirements, specifications, and implementation artifacts.

- [ ] CHK045 - Do all functional requirements (FR-001 to FR-016) have corresponding implementation guidance in plan phases? [Traceability]
- [ ] CHK046 - Do all security requirements (SEC-001 to SEC-008) map to specific AVM module configurations in research? [Traceability]
- [ ] CHK047 - Do all monitoring requirements (MON-001 to MON-005) trace to alert definitions in data model? [Traceability]
- [ ] CHK048 - Do all infrastructure constraints (IC-001 to IC-006) align with constitution principles? [Traceability]

## Ambiguities & Conflicts

Identification of unclear, contradictory, or incomplete requirement areas.

- [ ] CHK049 - Is "HDD-backed file share" disambiguated between Standard_LRS vs other HDD SKUs? [Ambiguity, Spec §FR-005]
- [ ] CHK050 - Is "Portal notifications only" for alerts clearly documented as excluding Action Groups? [Clarity, Spec §MON-003]
- [ ] CHK051 - Is the deployment timeframe "within 20 minutes" inclusive of validation steps or deployment only? [Ambiguity, Spec §SC-001]
- [ ] CHK052 - Are "rich comments" requirements quantified with minimum comment density or coverage percentage? [Ambiguity, Spec §FR-015]

---

## Validation Summary

**Total Items**: 52
**Pass Threshold**: ≥90% (47+ items checked)
**Review Date**: _________________
**Reviewer**: _________________
**Status**: ☐ PASSED  |  ☐ NEEDS REVISION  |  ☐ BLOCKED

### Issues Identified

_Document any requirement quality issues discovered during validation:_

1.
2.
3.

### Recommendations

_Suggested improvements for requirement clarity, completeness, or testability:_

1.
2.
3.

---

**Next Steps**:
- If PASSED → Proceed to Phase 2 implementation (Bicep template creation)
- If NEEDS REVISION → Address identified issues, re-validate
- If BLOCKED → Escalate missing information to stakeholders
/speckit.checklist
➕ Expand to see the results

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

When running the checklist prompt, Copilot may ask you a number of depth questions to clarify certain aspects of the plan. Here’s an example of what that looks like.
Specify Bootstrap

In the Copilot chat window, you should see results, similar to this:
Specify Bootstrap

# Requirements Quality Checklist: Implementation Readiness

**Type**: Implementation Readiness Validation
**Focus**: Comprehensive Coverage
**Depth**: Standard
**Audience**: Implementation Team
**Created**: 2026-02-18
**Spec**: [001-my-legacy-workload](../spec.md)
**Plan**: [plan.md](../plan.md)

**Purpose**: Validate that requirements provide complete, clear, and consistent guidance for Terraform implementation using Azure Verified Modules. This checklist tests requirement quality, NOT implementation correctness.

---

## Requirement Completeness

### Infrastructure Resources

- [ ] CHK001 - Are AVM module references specified for all required Azure resources? [Completeness, Spec Infrastructure Requirements]
- [ ] CHK002 - Are resource naming requirements defined with specific patterns and constraints? [Completeness, Spec IC-003, IC-010]
- [ ] CHK003 - Are all resource configuration requirements specified (SKUs, tiers, capacity)? [Completeness, Spec FR-001 through FR-025]
- [ ] CHK004 - Are provider version constraints documented for AzAPI, published AVM dependencies, and random, with direct AzureRM resources explicitly excluded? [Completeness, Plan Technical Context]
- [ ] CHK005 - Are all 12 Azure resources accounted for in both spec and plan? [Completeness, Cross-reference]

### Terraform-Specific Requirements

- [ ] CHK006 - Are requirements defined for all 5 Terraform files (terraform.tf, variables.tf, main.tf, outputs.tf, tfvars)? [Completeness, Spec FR-021, FR-022]
- [ ] CHK007 - Are state backend configuration requirements completely specified? [Completeness, Spec IC-002, State Management section]
- [ ] CHK008 - Are variable definition requirements clear for all configurable values? [Completeness, Spec FR-021]
- [ ] CHK009 - Are output requirements defined for infrastructure consumption by external consumers? [Gap, Plan outputs.tf section]
- [ ] CHK010 - Are Terraform validation workflow steps documented? [Completeness, Plan Constitution Principle V]

### Network Architecture

- [ ] CHK011 - Are VNet address space and subnet CIDR allocations completely specified? [Completeness, Spec IC-008, Clarifications]
- [ ] CHK012 - Are NSG rule requirements defined for all 3 subnets with source/destination specificity? [Completeness, Spec SEC-004 through SEC-007]
- [ ] CHK013 - Are private endpoint connectivity requirements fully documented? [Completeness, Spec FR-014, SEC-009, SEC-014]
- [ ] CHK014 - Are NAT Gateway association requirements clear (which subnets)? [Completeness, Spec FR-012]

### Security & Authentication

- [ ] CHK015 - Are password generation requirements specified with complexity constraints? [Completeness, Spec FR-006, SEC-002]
- [ ] CHK016 - Is the Key Vault secret storage and VM password reference flow clearly documented? [Completeness, Plan Password Flow section]
- [ ] CHK017 - Are managed identity requirements specified for all applicable resources? [Completeness, Spec SEC-001, SEC-003]
- [ ] CHK018 - Are diagnostic logging requirements defined for all monitored resources? [Completeness, Spec SEC-010]
- [ ] CHK019 - Are resource lock requirements specified with lock type and target resources? [Completeness, Spec SEC-011]

### Monitoring & Alerts

- [ ] CHK020 - Are alert condition thresholds quantified for all 3 critical alerts? [Completeness, Spec FR-020, Clarifications]
- [ ] CHK021 - Are Log Analytics retention requirements specified? [Completeness, Spec Clarifications - 180 days]
- [ ] CHK022 - Are alert action group requirements defined (notification method)? [Completeness, Spec Clarifications - Portal notifications]

---

## Requirement Clarity

### Ambiguity Resolution

- [ ] CHK023 - Is "Standard_D2s_v3" VM size explicitly stated (not "2 core, 8GB" generically)? [Clarity, Spec FR-001, Clarifications]
- [ ] CHK024 - Is "Standard HDD" tier explicitly specified vs ambiguous "standard storage"? [Clarity, Spec FR-002, FR-003, IC-007]
- [ ] CHK025 - Is "10.0.0.0/24" VNet address space quantified vs vague "small VNet"? [Clarity, Spec IC-008, Clarifications]
- [ ] CHK026 - Is "180 days" Log Analytics retention quantified vs vague "extended retention"? [Clarity, Spec Clarifications]
- [ ] CHK027 - Is "1TB" file share quota quantified vs vague "large capacity"? [Clarity, Spec Clarifications]

### Terraform-Specific Clarity

- [ ] CHK028 - Are AVM module variable names and structures referenced from module documentation? [Clarity, Spec FR-025]
- [ ] CHK029 - Is the random_password resource explicitly specified vs generic "password generator"? [Clarity, Plan main.tf section]
- [ ] CHK030 - Are AVM module "interfaces" (diagnostic_settings, lock, secrets) explicitly documented? [Clarity, Plan Constitution Principle III]
- [ ] CHK031 - Is "terraform.tfvars" explicit vs ambiguous "variable file"? [Clarity, Spec FR-021]

### Constraint Precision

- [ ] CHK032 - Is "15 characters or fewer" computer name limit quantified? [Clarity, Spec FR-004, IC-010]
- [ ] CHK033 - Is "westus3" region explicitly specified (not "US West" or "West US 3")? [Clarity, Spec IC-001]
- [ ] CHK034 - Is "vmadmin" username exact string specified? [Clarity, Spec FR-005]
- [ ] CHK035 - Are availability zone options explicitly defined as "1, 2, or 3 - NEVER -1"? [Clarity, Spec FR-023, IC-006]

---

## Requirement Consistency

### Cross-Reference Validation

- [ ] CHK036 - Do VNet subnet CIDRs in IC-008 match the clarifications section allocations? [Consistency, Cross-check IC-008 vs Clarifications]
- [ ] CHK037 - Is VM size requirement (FR-001) consistent across spec, user stories, and plan? [Consistency, FR-001, US1, Plan]
- [ ] CHK038 - Are file share capacity requirements consistent (1TB) across FR-013 and clarifications? [Consistency]
- [ ] CHK039 - Are NSG rule requirements consistent with subnet design (3 NSGs for 3 subnets)? [Consistency, FR-008, IC-008]
- [ ] CHK040 - Is Key Vault secret name requirement consistent (configurable via tfvars)? [Consistency, FR-016, SEC-002]

### Security Alignment

- [ ] CHK041 - Do NSG requirements align with zero-trust principles (deny-by-default in SEC-004)? [Consistency, SEC-004 through SEC-007]
- [ ] CHK042 - Are managed identity requirements consistent with "no credentials in code" requirement? [Consistency, SEC-001, SEC-003]
- [ ] CHK043 - Are private endpoint requirements consistent with "no public access" requirements? [Consistency, FR-014, SEC-009]

### Constitution Alignment

- [ ] CHK044 - Do spec requirements align with constitution Principle II (AVM-only)? [Consistency, Infrastructure Requirements vs Constitution]
- [ ] CHK045 - Do security requirements align with constitution Principle III (security controls)? [Consistency, SEC requirements vs Constitution]
- [ ] CHK046 - Do deployment requirements align with constitution Principle V (validation workflow)? [Consistency, SC-009 vs Constitution]

---

## Acceptance Criteria Quality

### Measurability

- [ ] CHK047 - Can "infrastructure deployment within 30 minutes" be objectively measured? [Measurability, SC-001]
- [ ] CHK048 - Can "RDP connection within 2 minutes" be objectively timed? [Measurability, SC-002]
- [ ] CHK049 - Can "diagnostic logs appear within 15 minutes" be objectively verified? [Measurability, SC-007]
- [ ] CHK050 - Can "total cost under $200/month" be objectively calculated? [Measurability, SC-013]

### Testability

- [ ] CHK051 - Are acceptance criteria testable with concrete validation steps? [Testability, All SC items]
- [ ] CHK052 - Are user story test scenarios written in Given/When/Then format? [Testability, User Stories 1-4]
- [ ] CHK053 - Do edge cases include expected behavior or just failure scenarios? [Testability, Edge Cases section]

### Completeness of Success Criteria

- [ ] CHK054 - Are success criteria defined for all 4 user stories? [Completeness, SC items map to US1-US4]
- [ ] CHK055 - Are success criteria defined for Terraform code quality (fmt, validate, tfsec)? [Completeness, SC-009]
- [ ] CHK056 - Are success criteria defined for security controls (no public IP, logs flowing)? [Completeness, SC-006, SC-007, SC-008]

---

## Scenario Coverage

### Primary Scenario Validation

- [ ] CHK057 - Are requirements defined for initial Terraform deployment (terraform apply)? [Coverage, Primary Flow]
- [ ] CHK058 - Are requirements defined for RDP access via Bastion post-deployment? [Coverage, US2]
- [ ] CHK059 - Are requirements defined for file share access via private endpoint? [Coverage, US3]
- [ ] CHK060 - Are requirements defined for internet access via NAT Gateway? [Coverage, US4]

### Alternate Scenario Coverage

- [ ] CHK061 - Are requirements defined for Terraform state backend configuration? [Coverage, Alternate Flow]
- [ ] CHK062 - Are requirements defined for variable customization via tfvars? [Coverage, FR-021]
- [ ] CHK063 - Are requirements defined for manual post-deployment file share mounting? [Coverage, Clarifications]

### Exception/Error Scenario Coverage

- [ ] CHK064 - Are requirements defined for handling VM naming exceeding 15 chars? [Coverage, Edge Cases]
- [ ] CHK065 - Are requirements defined for storage account name conflicts? [Coverage, Edge Cases]
- [ ] CHK066 - Are requirements defined for CIDR allocation failures? [Coverage, Edge Cases]
- [ ] CHK067 - Are requirements defined for partial deployment failures? [Coverage, Clarifications - incremental redeployment]
- [ ] CHK068 - Are requirements defined for Key Vault secret name conflicts? [Coverage, Edge Cases]

### Recovery Scenario Coverage

- [ ] CHK069 - Are requirements defined for redeploying after fixing errors? [Coverage, Clarifications - keep resources, fix, redeploy]
- [ ] CHK070 - Is the "no rollback/delete" approach clearly specified for failed deployments? [Coverage, Clarifications]

---

## Edge Case Coverage

### Boundary Conditions

- [ ] CHK071 - Is the 15-character NetBIOSlimit explicitly tested in edge cases? [Edge Case, IC-010, Edge Cases section]
- [ ] CHK072 - Are availability zone unavailability scenarios addressed? [Edge Case, Edge Cases section]
- [ ] CHK073 - Are VNet address space exhaustion scenarios addressed? [Edge Case, Edge Cases section]
- [ ] CHK074 - Are global naming conflicts (Key Vault, Storage Account) addressed? [Edge Case, Edge Cases section]

### Configuration Edge Cases

- [ ] CHK075 - Are NSG rule conflict scenarios addressed? [Edge Case, Edge Cases section]
- [ ] CHK076 - Are private DNS resolution failure scenarios addressed? [Edge Case, Edge Cases section]
- [ ] CHK077 - Are disk attachment failure scenarios addressed? [Edge Case, Edge Cases section]

---

## Non-Functional Requirements

### Performance Requirements

- [ ] CHK078 - Are deployment time expectations quantified (30 minutes in SC-001)? [NFR, SC-001]
- [ ] CHK079 - Are connection time expectations quantified (2 minutes RDP in SC-002)? [NFR, SC-002]
- [ ] CHK080 - Are log ingestion timeframes quantified (15 minutes in SC-007)? [NFR, SC-007]
- [ ] CHK081 - Is "Standard HDD is adequate" assumption documented? [NFR, Assumption A-009]

### Cost Requirements

- [ ] CHK082 - Is the cost constraint quantified (<$200/month)? [NFR, SC-013]
- [ ] CHK083 - Are cost optimization requirements specified (HDD vs SSD)? [NFR, IC-007]

### Security Requirements (Non-Functional)

- [ ] CHK084 - Are all security requirements explicitly listed in SEC section? [NFR, SEC-001 through SEC-014]
- [ ] CHK085 - Are encryption requirements specified (at-rest with Microsoft keys)? [NFR, SEC-013]
- [ ] CHK086 - Are soft-delete and purge protection requirements specified? [NFR, SEC-012]

### Compliance Requirements

- [ ] CHK087 - Are log retention requirements specified (180 days)? [NFR, Clarifications]
- [ ] CHK088 - Are resource lock requirements specified for compliance-critical resources? [NFR, SEC-011]

---

## Dependencies & Assumptions

### External Dependencies

- [ ] CHK089 - Are all Terraform/Azure CLI version dependencies documented? [Dependency, D-001, D-002]
- [ ] CHK090 - Is the pre-existing state backend dependency documented? [Dependency, D-005, A-002]
- [ ] CHK091 - Are AVM module registry dependencies documented? [Dependency, D-003]
- [ ] CHK092 - Are deployment permission dependencies documented? [Dependency, A-003]

### Assumption Validation

- [ ] CHK093 - Are quota assumptions documented (VM size, Bastion, NAT Gateway)? [Assumption, A-001]
- [ ] CHK094 - Are availability zone support assumptions documented? [Assumption, A-005]
- [ ] CHK095 - Are naming conflict assumptions documented? [Assumption, A-004]
- [ ] CHK096 - Is the "no domain join" assumption explicitly stated? [Assumption, A-014]
- [ ] CHK097 - Is the "no ExpressRoute/VPN" assumption explicitly stated? [Assumption, A-013]

### Validated Constraints

- [ ] CHK098 - Are all infrastructure constraints (IC-001 through IC-010) fully documented? [Dependency, Infrastructure Constraints section]
- [ ] CHK099 - Are Terraform provider version constraints specified? [Dependency, Plan Technical Context]

---

## Ambiguities & Conflicts

### Potential Ambiguities

- [ ] CHK100 - Is "Bastion SKU (Basic or Standard)" resolved to specific choice? [Ambiguity, Data Model section]
- [ ] CHK101 - Is "RBAC vs Access Policies" for Key Vault resolved to specific choice? [Ambiguity, Data Model section]
- [ ] CHK102 - Is "Action Group notification target" specified beyond "email or webhook"? [Ambiguity, Assumption A-011]
- [ ] CHK103 - Are AVM module version selection criteria specified (always latest stable)? [Ambiguity, Plan Technical Context note]

### Potential Conflicts

- [ ] CHK104 - Do VNet subnet CIDRs in spec match Clarifications section? [Conflict Check, IC-008 vs Clarifications]
- [ ] CHK105 - Does "no backup" decision conflict with any compliance requirements? [Conflict Check, Clarifications vs OS-006]
- [ ] CHK106 - Does "Standard HDD" choice conflict with performance expectations? [Conflict Check, IC-007, A-009]

### Specification Gaps

- [ ] CHK107 - Are requirements missing for NSG flow log configuration? [Gap, SEC-014 mentions flow logs]
- [ ] CHK108 - Are requirements missing for custom DNS configuration? [Gap, Private endpoint DNS]
- [ ] CHK109 - Are requirements missing for alert action group email address? [Gap, Clarifications mention portal only]
- [ ] CHK110 - Are requirements missing for VM OS disk size specification? [Gap, FR-002 mentions standard HDD but not size]

---

## Traceability & Documentation

### Requirement Traceability

- [ ] CHK111 - Are all functional requirements (FR-001 through FR-025) traceable to user stories? [Traceability]
- [ ] CHK112 - Are all security requirements (SEC-001 through SEC-014) traceable to constitution? [Traceability]
- [ ] CHK113 - Are all infrastructure constraints (IC-001 through IC-010) traceable to constitution or clarifications? [Traceability]
- [ ] CHK114 - Are all success criteria (SC-001 through SC-013) traceable to user stories? [Traceability]

### Clarification Documentation

- [ ] CHK115 - Are all clarification session Q&A pairs documented with decisions? [Documentation, Clarifications section]
- [ ] CHK116 - Are clarification decisions integrated into requirements (not just listed)? [Documentation, Requirements reflect clarifications]
- [ ] CHK117 - Are out-of-scope items explicitly documented? [Documentation, Out of Scope section]

### Plan-Spec Alignment

- [ ] CHK118 - Does the plan reference all 25 functional requirements? [Traceability, Plan addresses all FRs]
- [ ] CHK119 - Does the plan reference all 14 security requirements? [Traceability, Plan addresses all SECs]
- [ ] CHK120 - Does the plan reference all 10 infrastructure constraints? [Traceability, Plan addresses all ICs]

---

## Summary

**Total Checklist Items**: 120
**Expected Completion Time**: 2-3 hours for comprehensive review
**Target Audience**: Implementation team preparing to build Terraform code

**Usage Instructions**:
1. Review each checklist item sequentially
2. Mark ✅ for satisfied requirements, ❌ for gaps/issues
3. Document findings in adjacent notes column (if needed)
4. Escalate any ❌ items to spec author for clarification/resolution
5. Re-validate after spec updates

**Pass Criteria**:
- ≥95% items marked ✅ (114+ passing items)
- Zero CRITICAL gaps (ambiguities in security, naming, or Terraform structure)
- All conflicts resolved before implementation begins

**Next Steps After Completion**:
- If ≥95% pass: Proceed to implementation (terraform code generation)
- If <95% pass: Update spec.md to address gaps, then re-run checklist
- Archive this checklist with implementation for audit trail
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture your new checklist, with a comment of something like Checklist prepared.

6. Tasks

Spec Kit uses /speckit.tasks to generate the tasks.md file. The prompt doesn’t require any specific inputs as it analyzes the existing plan to break it down into actionable tasks. Tasks can be evolved through iterating over the tasks.md file by either manually editing it or repeatedly fine tuning the prompt used with /speckit.tasks, or leveraging /speckit.analyze to review/validate and challenge the tasks.

Info

To learn more about what the tasks should include, see the Tasks chapter in the Spec Kit article.

  1. Run the following prompt to generate tasks for our example:
​
/speckit.tasks
➕ Expand to see the results

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

In the Copilot chat window, you should see something like this:
Specify Bootstrap

<!-- markdownlint-disable -->
# Tasks: Legacy VM Workload Infrastructure

**Feature**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md)
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
**Date**: 2026-01-27
**Branch**: `001-legacy-vm-workload`

**Organization**: Tasks are grouped by user story (US1-US5) to enable independent implementation and testing.

## Format: `[ID] [P?] [Story?] Description`

- **[P]**: Parallelizable (different files, no blocking dependencies)
- **[Story]**: User story label (US1, US2, US3, US4, US5)
- File paths included in descriptions

---

## Phase 1: Setup (Project Initialization)

**Purpose**: Basic project structure and Bicep configuration

- [ ] T001 Create infrastructure directory structure: infra/, infra/docs/
- [ ] T002 Create bicepconfig.json with AVM analyzer rules at infra/bicepconfig.json
- [ ] T003 [P] Create .gitignore file to exclude .bicep build artifacts (*.json from main.bicep compilation)
- [ ] T004 [P] Create project README.md at repository root with quickstart reference
- [ ] T005 Initialize main.bicep with metadata, targetScope='resourceGroup', location parameter

---

## Phase 2: Foundational (Blocking Prerequisites for All User Stories)

**Purpose**: Shared infrastructure that MUST be complete before any user story implementation

**⚠️ CRITICAL**: No user story work can begin until this phase is complete

- [ ] T006 Add parameters to main.bicep: vmSize (default: Standard_D2s_v3), vmAdminUsername (default: vmadmin), vmAdminPasswordSecretName (default: 'vm-admin-password'), availabilityZone (default: 1), fileShareQuotaGiB (default: 1024), logAnalyticsRetentionDays (default: 30)
- [ ] T007 Define variables in main.bicep: suffix = uniqueString(resourceGroup().id), vmPassword = 'P@ssw0rd!${uniqueString(resourceGroup().id, deployment().name, utcNow('u'))}' (NOTE: utcNow() makes deployment non-idempotent - password regenerates on each deploy. Acceptable for initial deployment; consider removing utcNow() for idempotent redeployments)
- [ ] T008 Define resource naming variables: vnetName, vmName, kvName, lawName, stName (storage: no hyphens, max 24 chars)
- [ ] T009 [P] Define tags variable: workload='legacy-vm', environment='production', compliance='legacy-retention', managedBy='bicep-avm'
- [ ] T010 Add AVM module for Log Analytics Workspace (avm/res/operational-insights/workspace:0.15.0) at infra/main.bicep
- [ ] T011 Configure Log Analytics parameters: name, location, retentionInDays, tags
- [ ] T012 Create main.bicepparam file at infra/main.bicepparam with 'using' directive and parameter defaults

**Checkpoint**: Foundation ready - user story phases can now proceed in parallel (if staffed) or sequentially by priority

---

## Phase 3: User Story 1 - Core VM Infrastructure (Priority: P1) 🎯 MVP

**Goal**: Deploy VNet, VM with Windows Server 2016, basic networking - foundational workload infrastructure

**Independent Test**: Deploy to test resource group, verify VM created with correct specs (Standard_D2s_v3, Windows Server 2016), VM communicates within VNet

### Validation for User Story 1 (MANDATORY - Constitution Principle III) ⚠️

> **NOTE: These validation tasks must be executed BEFORE deployment**

- [ ] T013 [US1] Run `bicep build infra/main.bicep` to compile and check syntax errors
- [ ] T014 [US1] Run `az deployment group validate --resource-group rg-legacyvm-test --template-file main.bicep --parameters main.bicepparam`
- [ ] T015 [US1] Run `az deployment group what-if --resource-group rg-legacyvm-test --template-file main.bicep --parameters main.bicepparam`
- [ ] T016 [US1] Review what-if output: verify VNet, VM, NIC will be created with no unexpected changes

### Implementation for User Story 1

- [ ] T017 [P] [US1] Add AVM module for Virtual Network (avm/res/network/virtual-network:0.7.2) in main.bicep
- [ ] T018 [US1] Configure VNet parameters: name, location, addressPrefixes=['10.0.0.0/24'], subnets array with 3 subnets (VM: 10.0.0.0/27, Bastion: 10.0.0.64/26, PE: 10.0.0.128/27)
- [ ] T019 [US1] Add diagnostic settings to VNet module: send to Log Analytics workspace ID reference
- [ ] T020 [P] [US1] Add AVM module for Virtual Machine (avm/res/compute/virtual-machine:0.21.0) in main.bicep
- [ ] T021 [US1] Configure VM parameters: name='vm-legacyvm-${suffix}', computerName='vm-${substring(suffix,0,10)}' (≤15 chars), size=vmSize parameter, adminUsername=vmAdminUsername, adminPassword=kvSecretReference, zone=availabilityZone
- [ ] T022 [US1] Configure VM OS: imageReference for Windows Server 2016, osDisk with Standard_LRS SKU (HDD performance tier)
- [ ] T023 [US1] Configure VM managed identity: type='SystemAssigned'
- [ ] T024 [US1] Configure VM NIC: attach to VM subnet, no public IP, dynamic private IP
- [ ] T025 [US1] Add VM diagnostic settings to Log Analytics workspace
- [ ] T026 [US1] Add VM outputs: vmName, vmResourceId, vmPrivateIP

### Deployment for User Story 1

- [ ] T027 [US1] Create Azure resource group: `az group create --name rg-legacyvm-test --location westus3`
- [ ] T028 [US1] Deploy to test resource group: `az deployment group create --resource-group rg-legacyvm-test --template-file main.bicep --parameters main.bicepparam`
- [ ] T029 [US1] Verify VNet created with 3 subnets in Azure Portal (10.0.0.0/24 address space)
- [ ] T030 [US1] Verify VM created with Windows Server 2016, Standard_D2s_v3, correct zone
- [ ] T031 [US1] Verify VM computer name is ≤15 characters (NetBIOS limit)
- [ ] T032 [US1] Verify diagnostic logs flowing to Log Analytics workspace within 5 minutes

**Checkpoint**: User Story 1 complete - VM infrastructure deployed and validated. Ready to proceed with US2 and US3 in parallel.

---

## Phase 4: User Story 2 - Secure Storage and Data Disk (Priority: P2)

**Goal**: Attach 500GB data disk to VM, deploy storage account with 1TB file share via private endpoint

**Independent Test**: Verify 500GB HDD data disk attached to VM, file share accessible from VM through private endpoint (no internet traversal)

### Implementation for User Story 2

- [ ] T033 [P] [US2] Add data disk to VM module configuration in main.bicep: dataDisks array with disk size=500, sku=Standard_LRS, lun=0, name='datadisk-01'
- [ ] T034 [P] [US2] Add AVM module for Storage Account (avm/res/storage/storage-account:0.31.0) in main.bicep
- [ ] T035 [US2] Configure storage parameters: name='st${replace(suffix, '-', '')}' (max 24 chars, no hyphens), kind='StorageV2', sku='Standard_LRS', accessTier='Hot', publicNetworkAccess='Disabled'
- [ ] T036 [US2] Configure file share in storage module: fileServices with share name='fileshare', quota=fileShareQuotaGiB (1024 GiB)
- [ ] T037 [US2] Add diagnostic settings to storage module: send to Log Analytics workspace
- [ ] T038 [P] [US2] Add AVM module for Private DNS Zone (avm/res/network/private-dns-zone:0.8.0) in main.bicep
- [ ] T039 [US2] Configure DNS zone name: 'privatelink.file.core.windows.net', VNet link to main VNet
- [ ] T040 [P] [US2] Add AVM module for Private Endpoint (avm/res/network/private-endpoint:0.11.1) in main.bicep
- [ ] T041 [US2] Configure private endpoint: subnet=PE subnet, groupIds=['file'], privateDnsZoneResourceIds=[DNS zone ID], link to storage account resource
- [ ] T042 [US2] Add storage outputs: storageAccountName, fileShareName, privateEndpointIP

### Deployment for User Story 2

- [ ] T043 [US2] Re-run validation: `bicep build`, `az deployment validate`, `what-if` analysis
- [ ] T044 [US2] Deploy updated template to test resource group
- [ ] T045 [US2] Verify 500GB data disk attached to VM in Azure Portal (LUN 0, Standard_LRS)
- [ ] T046 [US2] Verify storage account created with public access disabled
- [ ] T047 [US2] Verify file share created with 1024 GiB quota
- [ ] T048 [US2] Verify private endpoint resolves to internal IP (10.0.0.128/27 range): `nslookup st{random}.file.core.windows.net` from VM
- [ ] T049 [US2] Test file share access from VM via Bastion: `Test-NetConnection -ComputerName st{random}.file.core.windows.net -Port 445`

**Checkpoint**: User Stories 1 AND 2 complete - VM with data disk and storage file share via private endpoint validated.

---

## Phase 5: User Story 3 - Secure Access and Secrets Management (Priority: P2)

**Goal**: Deploy Azure Bastion for secure RDP access, Key Vault for storing VM password

**Independent Test**: Connect to VM through Bastion host using password retrieved from Key Vault (no public IP on VM)

### Implementation for User Story 3

- [ ] T050 [P] [US3] Add AVM module for Key Vault (avm/res/key-vault/vault:0.13.3) in main.bicep
- [ ] T051 [US3] Configure Key Vault parameters: name='kv-legacyvm-${suffix}', sku='standard', enableRbacAuthorization=true, softDeleteRetentionInDays=90
- [ ] T052 [US3] Add Key Vault secret via module's secrets parameter: name=vmAdminPasswordSecretName parameter, value=vmPassword variable, contentType='text/plain'
- [ ] T053 [US3] Add RBAC role assignment in Key Vault module: principalId=VM managed identity, roleDefinitionIdOrName='Key Vault Secrets User'
- [ ] T054 [US3] Add Key Vault diagnostic settings to Log Analytics workspace
- [ ] T055 [P] [US3] Update VM module configuration: change adminPassword to reference Key Vault secret (use getSecret() or secretReference)
- [ ] T056 [P] [US3] Add AVM module for Bastion Host (avm/res/network/bastion-host:0.8.2) in main.bicep
- [ ] T057 [US3] Configure Bastion parameters: name='bas-legacyvm-${suffix}', sku='Basic', vnetId=VNet resource ID, subnetName='AzureBastionSubnet'
- [ ] T058 [US3] Add Bastion diagnostic settings to Log Analytics workspace
- [ ] T059 [US3] Add Key Vault and Bastion outputs: kvName, kvResourceId, bastionName, bastionResourceId

### Deployment for User Story 3

- [ ] T060 [US3] Re-run validation: `bicep build`, `az deployment validate`, `what-if` analysis
- [ ] T061 [US3] Deploy updated template to test resource group (expected duration: 15-20 minutes for Bastion)
- [ ] T062 [US3] Verify Key Vault created with RBAC enabled (not access policies)
- [ ] T063 [US3] Verify VM password stored as Key Vault secret: `az keyvault secret show --name vm-admin-password --vault-name kv-legacyvm-{suffix}`
- [ ] T064 [US3] Verify VM managed identity has 'Key Vault Secrets User' role on Key Vault
- [ ] T065 [US3] Verify Azure Bastion deployed successfully in Bastion subnet
- [ ] T066 [US3] Test Bastion connectivity: Connect to VM via Azure Portal → VM → Connect → Bastion, use username='vmadmin' and password from Key Vault
- [ ] T067 [US3] Verify VM has no public IP address assigned (confirm access only through Bastion)

**Checkpoint**: User Stories 1, 2, AND 3 complete - Full VM infrastructure with secure access and storage operational.

---

## Phase 6: User Story 4 - Internet Connectivity and Network Security (Priority: P3)

**Goal**: Configure NAT Gateway for outbound internet, implement NSGs for all subnets with least-privilege rules

**Independent Test**: Verify VM reaches internet through NAT Gateway, NSG rules block unauthorized traffic

### Implementation for User Story 4

- [ ] T068 [P] [US4] Add AVM module for NAT Gateway (avm/res/network/nat-gateway:2.0.1) in main.bicep
- [ ] T069 [US4] Configure NAT Gateway parameters: name='nat-legacyvm-${suffix}', zone=availabilityZone, publicIpAddressObjects=[{name: 'pip-nat'}]
- [ ] T070 [US4] Update VNet module configuration: associate NAT Gateway with VM subnet (natGatewayId in subnet definition)
- [ ] T071 [US4] Add NAT Gateway diagnostic settings to Log Analytics workspace
- [ ] T072 [P] [US4] Add AVM module for VM Subnet NSG (avm/res/network/network-security-group:0.5.2) in main.bicep
- [ ] T073 [US4] Configure VM NSG security rules: **CRITICAL - inbound allow TCP 3389 from Bastion subnet (10.0.0.64/26) priority 100**, inbound deny all (priority 4096), outbound allow Internet (priority 100), outbound allow VNet (priority 200), outbound deny all (priority 4096)
- [ ] T074 [US4] Update VNet module: associate VM NSG with VM subnet (networkSecurityGroupId in subnet definition)
- [ ] T075 [US4] Add VM NSG diagnostic settings to Log Analytics workspace
- [ ] T076 [P] [US4] Add AVM module for Bastion Subnet NSG (avm/res/network/network-security-group:0.5.2) in main.bicep
- [ ] T077 [US4] Configure Bastion NSG security rules: inbound allow 443 from Internet, allow GatewayManager 443, allow AzureLoadBalancer 443, allow Bastion communication 8080/5701; outbound allow SSH/RDP to VNet, allow Azure Cloud 443, allow Bastion communication, allow HTTP 80
- [ ] T078 [US4] Update VNet module: associate Bastion NSG with Bastion subnet
- [ ] T079 [US4] Add Bastion NSG diagnostic settings to Log Analytics workspace
- [ ] T080 [P] [US4] Add AVM module for PE Subnet NSG (avm/res/network/network-security-group:0.5.2) in main.bicep
- [ ] T081 [US4] Configure PE NSG security rules: inbound allow TCP 445 from VM subnet (10.0.0.0/27), inbound deny all; outbound allow all
- [ ] T082 [US4] Update VNet module: associate PE NSG with PE subnet
- [ ] T083 [US4] Add PE NSG diagnostic settings to Log Analytics workspace
- [ ] T084 [US4] Add NSG and NAT Gateway outputs: nsgVmName, nsgBastionName, nsgPeName, natGatewayName

### Deployment for User Story 4

- [ ] T085 [US4] Re-run validation: `bicep build`, `az deployment validate`, `what-if` analysis
- [ ] T086 [US4] Deploy updated template to test resource group
- [ ] T087 [US4] Verify NAT Gateway created with public IP and associated with VM subnet
- [ ] T088 [US4] Verify 3 NSGs created and associated with correct subnets
- [ ] T089 [US4] Test outbound internet from VM via Bastion RDP session: `Test-NetConnection -ComputerName google.com -Port 443` (should succeed through NAT Gateway)
- [ ] T090 [US4] Test NSG deny rules: attempt unauthorized inbound connection to VM (should be blocked)
- [ ] T091 [US4] Verify all NSG diagnostic logs flowing to Log Analytics workspace

**Checkpoint**: User Stories 1-4 complete - Full network security and internet connectivity operational.

---

## Phase 7: User Story 5 - Monitoring and Alerting (Priority: P3)

**Goal**: Configure diagnostic settings for all resources, deploy 3 critical alerts (VM stopped, disk >85%, Key Vault access failures)

**Independent Test**: Verify diagnostic logs flowing to Log Analytics, trigger test alert scenarios and confirm alerts fire

### Implementation for User Story 5

- [ ] T092 [P] [US5] Add AVM module for VM Stopped Alert (avm/res/insights/metric-alert:0.4.1) in main.bicep
- [ ] T093 [US5] Configure VM stopped alert: name='alert-vm-stopped-legacyvm-${suffix}', targetResourceId=VM resource ID, metricName='Percentage CPU', operator='LessThan', threshold=1, aggregation='Average', windowSize='PT15M', severity=0 (Critical), enabled=true, autoMitigate=false
- [ ] T094 [US5] Ensure no action groups configured (Portal-only notifications per MON-003)
- [ ] T095 [P] [US5] Add AVM module for Disk Space Alert (avm/res/insights/metric-alert:0.4.1) in main.bicep
- [ ] T096 [US5] Configure disk space alert: name='alert-disk-space-legacyvm-${suffix}', targetResourceId=VM resource ID, metricName='OS Disk Used Percentage', operator='GreaterThan', threshold=85, aggregation='Average', windowSize='PT5M', severity=0, enabled=true, autoMitigate=false
- [ ] T097 [P] [US5] Add AVM module for Key Vault Access Failure Alert (avm/res/insights/metric-alert:0.4.1) in main.bicep
- [ ] T098 [US5] Configure KV alert: name='alert-kv-access-fail-legacyvm-${suffix}', targetResourceId=Key Vault resource ID, metricName='ServiceApiHit', dimensions=[{name: 'ActivityName', operator: 'Include', values: ['SecretGet']}, {name: 'StatusCode', operator: 'Include', values: ['Unauthorized']}], operator='GreaterThan', threshold=0, aggregation='Count', windowSize='PT5M', severity=0
- [ ] T099 [US5] Review all existing resource modules: verify diagnostic settings already configured for VNet, VM, Storage, Key Vault, NSGs, NAT Gateway, Bastion (completed in previous phases)
- [ ] T100 [US5] Add alert outputs: alertVmStoppedName, alertDiskSpaceName, alertKvFailureName

### Deployment for User Story 5

- [ ] T101 [US5] Re-run validation: `bicep build`, `az deployment validate`, `what-if` analysis
- [ ] T102 [US5] Deploy updated template to test resource group
- [ ] T103 [US5] Verify Log Analytics workspace contains logs from all resources: run query in Azure Portal → Log Analytics → Logs → `AzureDiagnostics | where ResourceGroup == 'rg-legacyvm-test' | summarize count() by ResourceType`
- [ ] T104 [US5] Verify 3 metric alerts created and enabled in Azure Portal → Monitor → Alerts
- [ ] T105 [US5] Test VM stopped alert: Stop VM, wait 15 minutes, verify alert fires and visible in Portal
- [ ] T106 [US5] Test Key Vault access failure alert: Attempt to access non-existent secret `az keyvault secret show --name fake-secret --vault-name kv-legacyvm-{suffix}`, wait 5 minutes, verify alert fires
- [ ] T107 [US5] Verify alert notifications visible in Azure Portal → Monitor → Alerts (no external action groups configured)
- [ ] T108 [US5] Document alert testing procedures in infra/docs/deployment.md

**Checkpoint**: All 5 user stories complete - Full monitoring and alerting operational. MVP infrastructure complete!

---

## Phase 8: Polish & Cross-Cutting Concerns

**Purpose**: Final improvements and documentation that span multiple user stories

- [ ] T109 [P] Review main.bicep for code quality: verify all resources have comments explaining purpose
- [ ] T110 [P] Review main.bicepparam for documentation: verify parameter descriptions and defaults documented
- [ ] T111 [P] Validate constitution compliance: check all 6 principles satisfied (IC-001 to IC-006, SEC-001 to SEC-008)
- [ ] T112 [P] Run final validation suite: `bicep build`, `az deployment validate`, `what-if` analysis
- [ ] T113 Update infra/docs/deployment.md with full deployment outcomes and lessons learned
- [ ] T114 [P] Create architecture diagram in infra/docs/architecture.md showing all resources and dependencies
- [ ] T115 Verify all success criteria met (SC-001 to SC-011): deployment time <20 minutes, all resources operational, logs flowing, alerts working
- [ ] T116 Execute quickstart.md validation end-to-end: follow deployment guide steps, verify successful deployment
- [ ] T117 [P] Review Azure Security Center recommendations: address any high-severity compliance issues
- [ ] T118 Document estimated monthly costs in README.md: VM ~$70, Storage ~$50, Bastion ~$140, Other ~$10 = Total ~$270/month
- [ ] T119 Create CHANGELOG.md entry: document initial deployment date, version 1.0.0, all resources deployed
- [ ] T120 Final code review: verify no hardcoded values, all parameters in main.bicepparam, rich comments present

**Checkpoint**: Production-ready infrastructure complete. Ready for operational handoff.

---

## Dependencies & Execution Order

### Phase Dependencies

1. **Setup (Phase 1)**: No dependencies - start immediately
2. **Foundational (Phase 2)**: Depends on Setup - BLOCKS all user stories
3. **User Story 1 (Phase 3)**: Depends on Foundational - MVP foundation, MUST complete first
4. **User Story 2 (Phase 4)**: Depends on Foundational - Can proceed after US1 or in parallel
5. **User Story 3 (Phase 5)**: Depends on Foundational and US1 (VM must exist for Key Vault password reference) - Bastion depends on VNet from US1
6. **User Story 4 (Phase 6)**: Depends on Foundational and US1 (NAT Gateway and NSGs associate with VNet subnets from US1)
7. **User Story 5 (Phase 7)**: Depends on all previous user stories (alerts target VM, Key Vault; diagnostic settings reference Log Analytics from Foundational)
8. **Polish (Phase 8)**: Depends on all user stories complete

### User Story Interdependencies

- **US1 (Core VM)**: Independent after Foundational - can start first
- **US2 (Storage)**: Depends on US1 (data disk attaches to VM, private endpoint needs VNet)
- **US3 (Secure Access)**: Depends on US1 (Key Vault stores password for VM, Bastion accesses VM, both need VNet)
- **US4 (Network Security)**: Depends on US1 (NAT Gateway and NSGs associate with VNet subnets)
- **US5 (Monitoring)**: Depends on US1, US3 (alerts target VM and Key Vault resources)

### Recommended Execution Sequence

**Option 1 - Sequential by Priority** (single developer):
1. Setup → Foundational → US1 → US2 → US3 → US4 → US5 → Polish

**Option 2 - Parallel with Blocking** (team of 3):
1. Setup → Foundational
2. US1 (Developer 1)
3. After US1: US2 + US3 + US4 in parallel (Developers 1, 2, 3)
4. After US2/US3/US4: US5 (any developer)
5. Polish

### Validation Cadence

- Run `bicep build` after every file modification
- Run `az deployment validate` before every deployment
- Run `what-if` analysis before every deployment to test/production
- Test each user story independently after its deployment phase

### MVP Definition

**Minimum Viable Product = User Story 1 Complete**

At T032 completion, you have:
- Windows Server 2016 VM operational
- VNet with 3 subnets configured
- Basic infrastructure validated

This is sufficient to demonstrate foundational workload capabilities. Subsequent user stories add storage, secure access, network security, and monitoring incrementally.

---

## Parallel Execution Opportunities

### Tasks That Can Run in Parallel

**Phase 1 (Setup)**: T003, T004 can run in parallel with T002

**Phase 2 (Foundational)**: T009, T012 can run in parallel after T008

**Phase 3 (US1)**: T017, T020 can run in parallel (research different modules)

**Phase 4 (US2)**: T033, T034, T038, T040 can run in parallel (different module additions)

**Phase 5 (US3)**: T050, T055, T056 can run in parallel (Key Vault, Bastion, VM update)

**Phase 6 (US4)**: T068, T072, T076, T080 can run in parallel (NAT Gateway + 3 NSGs)

**Phase 7 (US5)**: T092, T095, T097 can run in parallel (3 independent alert modules)

**Phase 8 (Polish)**: T109, T110, T111, T114, T117, T118 can run in parallel (different file updates)

### Example Parallel Workflow (3 developers)

**Sprint 1 (Week 1)**:
- Dev 1: T001-T012 (Setup + Foundational)
- Dev 2: T003-T004 in parallel with Dev 1
- Dev 3: Start planning US2 tasks

**Sprint 2 (Week 2)**:
- Dev 1: T013-T032 (US1 - Core VM)
- Dev 2 + Dev 3: Prepare for US2/US3 parallel work

**Sprint 3 (Week 3)**:
- Dev 1: T033-T049 (US2 - Storage)
- Dev 2: T050-T067 (US3 - Secure Access)
- Dev 3: T068-T091 (US4 - Network Security)

**Sprint 4 (Week 4)**:
- Dev 1: T092-T108 (US5 - Monitoring)
- Dev 2 + Dev 3: T109-T120 (Polish) in parallel

**Total Duration**: ~4 weeks with 3 developers, or ~6 weeks sequential

---

## Implementation Strategy

### MVP-First Approach

1. **Deliver US1 first** (Phase 3: Core VM Infrastructure)
  - Provides foundational value: operational VM with networking
  - Independent testable increment
  - Validates Bicep template structure and AVM module usage

2. **Add US2 + US3** (Phase 4-5: Storage + Secure Access)
  - Provides secure operations capabilities
  - Data storage layer complete
  - Bastion access operational

3. **Add US4 + US5** (Phase 6-7: Network Security + Monitoring)
  - Production-grade security and observability
  - Complete infrastructure compliance

### Incremental Delivery Value

| Completion Point | Value Delivered | Can Deploy to Production? |
|------------------|-----------------|---------------------------|
| After US1 | Basic VM workload operational | No - missing security controls |
| After US1 + US3 | VM with secure access | No - missing storage and monitoring |
| After US1 + US2 + US3 | VM with storage and secure access | Maybe - basic functionality complete, but no monitoring |
| After US1-US4 | Full network security in place | Maybe - functionally complete, limited observability |
| After US1-US5 | Full monitoring and alerting | **YES** - production-ready |

### Rollback Strategy

- Each user story deployment is incremental - previous state preserved
- ARM deployment mode: Incremental (default) - only adds/updates resources
- Rollback: Redeploy previous version of main.bicep (if maintained in git)
- Nuclear option: Delete resource group and redeploy from scratch (acceptable for this single-RG workload)

---

## Task Validation Checklist

Before marking tasks.md as complete, verify:

- [ ] All 5 user stories from spec.md mapped to task phases
- [ ] Each user story has validation tasks (T013-T016 pattern for Bicep/ARM validation)
- [ ] Each user story has implementation tasks with AVM module references
- [ ] Each user story has deployment tasks with verification steps
- [ ] Each user story is independently testable (checkpoint verifications defined)
- [ ] All 16 functional requirements (FR-001 to FR-016) covered in tasks
- [ ] All 8 security requirements (SEC-001 to SEC-008) covered in tasks
- [ ] All 6 infrastructure constraints (IC-001 to IC-006) covered in tasks
- [ ] All 5 monitoring requirements (MON-001 to MON-005) covered in tasks
- [ ] Task IDs sequential (T001 to T120)
- [ ] Parallel tasks marked with [P]
- [ ] User story tasks marked with [US1] to [US5]
- [ ] File paths included in task descriptions
- [ ] Constitution validation tasks included (T111)
- [ ] Quickstart validation task included (T116)

**Total Tasks**: 120
**Estimated Duration**: 4-6 weeks (depending on team size and parallel execution)
/speckit.tasks
➕ Expand to see the results

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

In the Copilot chat window, you should see something like this:
Specify Bootstrap

# Implementation Tasks: Legacy Business Application Infrastructure

**Feature**: 001-my-legacy-workload
**Status**: Ready for Implementation
**Created**: 2026-02-18
**Input**: [spec.md](./spec.md) | [plan.md](./plan.md)
**Total Tasks**: 78

**Organization**: Tasks organized by User Story to enable independent implementation and parallel execution. Each phase delivers a complete, independently testable capability.

**Task Format**: `- [ ] [TaskID] [P] [StoryLabel] Description with file path`
- **[P]** = Parallelizable (different files, no dependencies on incomplete tasks)
- **[Story]** = User Story label (US1, US2, US3, US4)

---

## Task Execution Strategy

### Implementation Approach
- **MVP First**: Complete User Story 1 (P1) before moving to other stories
- **Incremental Delivery**: Each user story phase is fully functional and independently testable
- **Parallel Opportunities**: Tasks marked [P] can run concurrently within the same phase
- **Validation Gates**: Terraform validate + plan review between phases

### Dependency Flow
1. **Setup** → **Foundational** (blocking)
2. **Foundational** → **US1** (P1 - Core Compute)
3. **US1** → **US2** (P2 - Secure Access)
4. **US1 + US2** → **US3** (P3 - Storage)
5. **US1 + US2 + US3** → **US4** (P4 - Observability)
6. **All User Stories** → **Polish**

### Independent Test Criteria Per Story
- **US1**: VM running in VNet with NSGs, no external access
- **US2**: RDP via Bastion using Key Vault password
- **US3**: File share accessible from VM via private endpoint
- **US4**: Internet via NAT, logs in Log Analytics, alerts functional

---

## Phase 1: Setup & Project Initialization

**Objective**: Initialize Terraform project structure, install tooling, configure remote state backend

**Prerequisites**: Azure subscription with Contributor access, Azure CLI authenticated, Terraform >= 1.9.0 installed

### Project Structure Tasks

- [ ] T001 Create terraform/ directory at repository root
- [ ] T002 Create docs/ directory for documentation
- [ ] T003 Create .github/workflows/ directory for CI/CD (optional)
- [ ] T004 Create .gitignore file with Terraform patterns (.terraform/, *.tfstate, *.tfplan, *.tfvars except *.tfvars.example)

### Terraform Configuration Files (Shell)

- [ ] T005 [P] Create terraform/terraform.tf with provider version constraints (Terraform >= 1.9.0, AzAPI ~> 2.12 for direct resources, AzureRM ~> 4.0 for published AVM dependencies, random ~> 3.6)
- [ ] T006 [P] Create terraform/variables.tf shell with empty file (will populate in Phase 2)
- [ ] T007 [P] Create terraform/locals.tf shell with empty file (will populate in Phase 2)
- [ ] T008 [P] Create terraform/main.tf shell with header comment
- [ ] T009 [P] Create terraform/outputs.tf shell with empty file (will populate per user story)
- [ ] T010 [P] Create terraform/prod.tfvars.example template file

### State Backend Configuration

- [ ] T011 Verify pre-existing Azure Storage Account for Terraform state exists (per spec dependency D-005)
- [ ] T012 Create terraform/backend.hcl.example with backend configuration template (storage_account_name, container_name, key, resource_group_name)
- [ ] T013 Document backend configuration in terraform/README.md (instructions for creating backend.hcl from example)

### Tooling Installation

- [ ] T014 [P] Install tfsec >= 1.28 for security scanning (per plan Security Tooling)
- [ ] T015 [P] Install checkov >= 3.0 for compliance scanning (per plan Security Tooling)
- [ ] T016 [P] Verify Terraform CLI >= 1.9.0 installed (terraform version)

### Documentation

- [ ] T017 Create terraform/README.md with deployment instructions (init → fmt → validate → plan → apply workflow)
- [ ] T018 Create docs/README.md with project overview
- [ ] T019 Create docs/architecture.md placeholder for infrastructure diagrams

**Phase 1 Validation**:
- ✅ Directory structure created
- ✅ All Terraform shell files exist
- ✅ Backend configuration documented
- ✅ Security tooling installed

---

## Phase 2: Foundational Infrastructure (Blocking Prerequisites)

**Objective**: Deploy foundational resources required by all user stories (Resource Group, naming resources, Log Analytics for diagnostic settings)

**Prerequisites**: Phase 1 complete, backend.hcl configured, Terraform initialized

### Terraform Initialization

- [ ] T020 Run terraform init -backend-config=backend.hcl to initialize backend and download providers

### Random Resources for Naming

- [ ] T021 [US1] Implement random_string resource in terraform/main.tf for unique suffix (6 chars, lowercase alphanumeric)
- [ ] T022 [US1] Implement random_password resource in terraform/main.tf for VM admin password (24 chars, complexity requirements per plan)

### Locals for Naming Convention

- [ ] T023 [US1] Define locals.tf unique_suffix from random_string
- [ ] T024 [US1] Define locals.tf location_abbr = "wus3"
- [ ] T025 [US1] [P] Define locals.tf resource group name (rg-avmlegacy-prod-wus3)
- [ ] T026 [US1] [P] Define locals.tf common_tags map with Environment, Workload, ManagedBy, Region, Spec

### Core Variables

- [ ] T027 [P] Define variables.tf location variable (default: westus3, validation: must equal westus3)
- [ ] T028 [P] Define variables.tf workload_name variable (default: avmlegacy)
- [ ] T029 [P] Define variables.tf environment variable (default: prod, validation: must equal prod)
- [ ] T030 [P] Define variables.tf tags variable (map of strings)

### Resource Group

- [ ] T031 [US1] Implement the Resource Group AVM module in terraform/main.tf (name from locals, location from var.location, tags from locals.common_tags)
- [ ] T032 [US1] Configure the Resource Group lock through the module lock interface (CanNotDelete per spec SEC-011)

### Log Analytics Workspace (Required for Diagnostic Settings)

- [ ] T033 Implement module block for Log Analytics Workspace in terraform/main.tf using Azure/avm-res-operationalinsights-workspace/azurerm
- [ ] T034 Configure Log Analytics SKU = PerGB2018, retention = 180 days (per spec clarifications)
- [ ] T035 Configure Log Analytics lock via AVM module lock interface (CanNotDelete)
- [ ] T036 [P] Define variables.tf log_analytics_retention_days variable (default: 180, validation: must equal 180)
- [ ] T037 Define locals.tf law_name using naming convention (law-avmlegacy-{suffix})
- [ ] T038 [P] Implement terraform/outputs.tf log_analytics_workspace_id output
- [ ] T039 [P] Implement terraform/outputs.tf log_analytics_workspace_name output

### Foundational Validation

- [ ] T040 Populate terraform/prod.tfvars with foundational variable values (location, workload_name, environment, tags)
- [ ] T041 Run terraform fmt -recursive to format all .tf files
- [ ] T042 Run terraform validate to check syntax
- [ ] T043 Run terraform plan -var-file=prod.tfvars to preview foundational resources (expect: Resource Group + Lock + Random resources + Log Analytics)
- [ ] T044 Review plan output for correctness (naming, tags, lock, retention)
- [ ] T045 Run terraform apply plan.tfplan to deploy foundational infrastructure
- [ ] T046 Verify Resource Group, random resources, and Log Analytics created in Azure Portal

**Phase 2 Validation**:
- ✅ Resource Group deployed to westus3
- ✅ Resource Group lock (CanNotDelete) applied
- ✅ Random resources generated (suffix, password)
- ✅ Log Analytics Workspace deployed with 180-day retention
- ✅ Terraform state stored in remote backend

---

## Phase 3: User Story 1 (P1) - Core Compute and Network Infrastructure

**Objective**: Deploy Windows Server 2016 VM within isolated VNet with proper subnet segmentation and network security controls

**User Story**: Deploy a Windows Server 2016 virtual machine within an isolated virtual network with proper subnet segmentation and network security controls.

**Independent Test**: Verify VM is created and running in specified VNet with proper subnets. Confirm NSGs attached to subnets and default deny rules in place. VM should be isolated with no internet or external access.

**Prerequisites**: Phase 2 (Foundational) complete - Resource Group and Log Analytics exist

### Variables for Networking and VM

- [ ] T047 [P] [US1] Define variables.tf vnet_address_space variable (default: ["10.0.0.0/24"], validation: must equal 10.0.0.0/24)
- [ ] T048 [P] [US1] Define variables.tf vm_subnet_cidr variable (default: 10.0.0.0/27)
- [ ] T049 [P] [US1] Define variables.tf bastion_subnet_cidr variable (default: 10.0.0.32/26)
- [ ] T050 [P] [US1] Define variables.tf private_endpoint_subnet_cidr variable (default: 10.0.0.96/28)
- [ ] T051 [P] [US1] Define variables.tf vm_size variable (default: Standard_D2s_v3)
- [ ] T052 [P] [US1] Define variables.tf vm_admin_username variable (default: vmadmin, validation: must equal vmadmin)
- [ ] T053 [P] [US1] Define variables.tf vm_data_disk_size_gb variable (default: 500, validation: must equal 500)
- [ ] T054 [P] [US1] Define variables.tf availability_zone variable (default: 1, validation: must be 1, 2, or 3)

### Locals for Resource Naming

- [ ] T055 [US1] Define locals.tf vnet_name using naming convention (vnet-avmlegacy-{suffix})
- [ ] T056 [US1] [P] Define locals.tf vm_nsg_name (nsg-vm-avmlegacy-{suffix})
- [ ] T057 [US1] [P] Define locals.tf bastion_nsg_name (nsg-bastion-avmlegacy-{suffix})
- [ ] T058 [US1] [P] Define locals.tf private_endpoint_nsg_name (nsg-pe-avmlegacy-{suffix})
- [ ] T059 [US1] Define locals.tf vm_name_raw and vm_name (vm-avmlegacy-{suffix}, truncated to 15 chars for NetBIOS per spec FR-004)
- [ ] T060 [US1] Define locals.tf vm_computer_name = vm_name (same as VM name, ensures 15 char limit)
- [ ] T061 [US1] [P] Define locals.tf vm_subnet_name, bastion_subnet_name (AzureBastionSubnet exact), private_endpoint_subnet_name

### Virtual Network

- [ ] T062 [US1] Implement module block for VNet in terraform/main.tf using Azure/avm-res-network-virtualnetwork/azurerm
- [ ] T063 [US1] Configure VNet address_space = var.vnet_address_space (10.0.0.0/24)
- [ ] T064 [US1] Configure VNet subnets map with 3 subnets (vm_subnet, bastion_subnet, private_endpoint_subnet with CIDRs from variables)
- [ ] T065 [US1] Configure private_endpoint subnet with private_endpoint_network_policies_enabled = false (per spec IC-009)
- [ ] T066 [US1] Configure VNet diagnostic_settings sending logs to Log Analytics (reference module.log_analytics.resource_id)
- [ ] T067 [US1] Configure VNet lock via AVM module lock interface (CanNotDelete)
- [ ] T068 [US1] [P] Implement terraform/outputs.tf virtual_network_name output
- [ ] T069 [US1] [P] Implement terraform/outputs.tf virtual_network_id output

### Network Security Groups

- [ ] T070 [P] [US1] Implement module block for VM NSG in terraform/main.tf using Azure/avm-res-network-networksecuritygroup/azurerm
- [ ] T071 [US1] Configure VM NSG security_rules: Allow-RDP-From-Bastion (priority 100, source: bastion_subnet_cidr, dest: vm_subnet_cidr, port 3389, protocol TCP)
- [ ] T072 [US1] Configure VM NSG security_rules: Deny-All-Inbound (priority 4096, deny all per spec SEC-004)
- [ ] T073 [US1] Configure VM NSG diagnostic_settings sending logs to Log Analytics
- [ ] T074 [P] [US1] Implement module block for Bastion NSG in terraform/main.tf using Azure/avm-res-network-networksecuritygroup/azurerm
- [ ] T075 [US1] Configure Bastion NSG security_rules: Allow-HTTPS-Inbound (priority 100, source: Internet, port 443 per Azure Bastion requirement)
- [ ] T076 [US1] Configure Bastion NSG security_rules: Allow-GatewayManager-Inbound (priority 110, source: GatewayManager service tag, port 443)
- [ ] T077 [US1] Configure Bastion NSG security_rules: Allow-RDP-To-VM-Subnet (outbound, priority 100, dest: vm_subnet_cidr, port 3389)
- [ ] T078 [US1] Configure Bastion NSG security_rules: Allow-AzureCloud-Outbound (outbound, priority 110, dest: AzureCloud service tag, port 443)
- [ ] T079 [US1] Configure Bastion NSG diagnostic_settings sending logs to Log Analytics
- [ ] T080 [P] [US1] Implement module block for Private Endpoint NSG in terraform/main.tf using Azure/avm-res-network-networksecuritygroup/azurerm
- [ ] T081 [US1] Configure Private Endpoint NSG security_rules: Allow-SMB-From-VM-Subnet (priority 100, source: vm_subnet_cidr, port 445 per spec SEC-007)
- [ ] T082 [US1] Configure Private Endpoint NSG security_rules: Deny-All-Inbound (priority 4096, deny all)
- [ ] T083 [US1] Configure Private Endpoint NSG diagnostic_settings sending logs to Log Analytics

### NSG-Subnet Associations

- [ ] T084 [P] [US1] Configure the VNet module vm_subnet with module.vm_nsg.resource_id
- [ ] T085 [P] [US1] Configure the VNet module bastion_subnet with module.bastion_nsg.resource_id
- [ ] T086 [P] [US1] Configure the VNet module private_endpoint_subnet with module.private_endpoint_nsg.resource_id

### Virtual Machine

- [ ] T087 [US1] Implement module block for VM in terraform/main.tf using Azure/avm-res-compute-virtualmachine/azurerm
- [ ] T088 [US1] Configure VM name = locals.vm_name (truncated to 15 chars)
- [ ] T089 [US1] Configure VM computer_name = locals.vm_computer_name (same as name, ≤15 chars per spec FR-004)
- [ ] T090 [US1] Configure VM vm_size = var.vm_size (Standard_D2s_v3)
- [ ] T091 [US1] Configure VM zone = var.availability_zone (1, 2, or 3 per spec FR-023)
- [ ] T092 [US1] Configure VM source_image_reference (publisher: MicrosoftWindowsServer, offer: WindowsServer, sku: 2016-Datacenter, version: latest)
- [ ] T093 [US1] Configure VM os_profile with admin_username = var.vm_admin_username (vmadmin)
- [ ] T094 [US1] Configure VM os_profile admin_password referencing Key Vault secret (will update in Phase 4 after Key Vault deployed - use placeholder for now)
- [ ] T095 [US1] Configure VM network_interfaces with nic1 connected to vm_subnet (subnet_id from module.virtual_network.subnets)
- [ ] T096 [US1] Configure VM network_interfaces with private_ip_address_allocation = Dynamic, public_ip_address_id = null (no public IP per spec FR-011)
- [ ] T097 [US1] Configure VM os_disk (name: {vm_name}-osdisk, caching: ReadWrite, storage_account_type: Standard_LRS per spec FR-002)
- [ ] T098 [US1] Configure VM data_disks with data1 (name: {vm_name}-datadisk, lun: 0, caching: ReadWrite, storage_account_type: Standard_LRS, disk_size_gb: 500 per spec FR-003)
- [ ] T099 [US1] Configure VM managed_identities with system_assigned = true (per spec SEC-001)
- [ ] T100 [US1] Configure VM diagnostic_settings sending logs to Log Analytics
- [ ] T101 [US1] Configure VM lock via AVM module lock interface (CanNotDelete per spec SEC-011)
- [ ] T102 [US1] Add depends_on for Key Vault module (will add in Phase 4)
- [ ] T103 [US1] [P] Implement terraform/outputs.tf vm_name output
- [ ] T104 [US1] [P] Implement terraform/outputs.tf vm_id output
- [ ] T105 [US1] [P] Implement terraform/outputs.tf vm_private_ip_address output
- [ ] T106 [US1] [P] Implement terraform/outputs.tf vm_computer_name output

### User Story 1 Deployment

- [ ] T107 [US1] Populate terraform/prod.tfvars with US1 variable values (vnet_address_space, subnet CIDRs, vm_size, vm_admin_username, availability_zone)
- [ ] T108 [US1] Run terraform fmt -recursive
- [ ] T109 [US1] Run terraform validate
- [ ] T110 [US1] Run terraform plan -var-file=prod.tfvars -out=us1.tfplan (expect: VNet + 3 subnets + 3 NSGs + 3 associations + VM with disks)
- [ ] T111 [US1] Review plan output for US1 completeness (check VM size, computer name ≤15 chars, NSG rules, no public IP)
- [ ] T112 [US1] Run terraform apply us1.tfplan
- [ ] T113 [US1] Verify VM running in Azure Portal (check VM properties: size, computer name, admin username, no public IP)
- [ ] T114 [US1] Verify VNet has 3 subnets with correct CIDR allocations
- [ ] T115 [US1] Verify NSGs attached to each subnet
- [ ] T116 [US1] Verify NSG rules (VM NSG allows RDP from Bastion only, Bastion NSG allows HTTPS from Internet)
- [ ] T117 [US1] Verify VM has no external access (cannot reach internet, not reachable from internet)

**Phase 3 (US1) Validation**:
- ✅ VM running with Standard_D2s_v3, Windows Server 2016, 500GB data disk
- ✅ VNet (10.0.0.0/24) with 3 subnets created
- ✅ NSGs attached to subnets with deny-by-default rules
- ✅ VM isolated (no public IP, no internet access)
- ✅ Computer name ≤15 characters
- ✅ VM admin username = vmadmin

**Parallel Execution Opportunities (US1)**:
- Tasks T047-T054 (variable definitions) can run in parallel
- Tasks T056-T058 (NSG naming locals) can run in parallel
- Tasks T070, T074, T080 (NSG module blocks) can run in parallel after VNet deployed
- Tasks T084-T086 (NSG associations) can run in parallel after NSGs created
- Tasks T103-T106 (output definitions) can run in parallel

---

## Phase 4: User Story 2 (P2) - Secure Remote Access

**Objective**: Enable secure remote access to VM through Azure Bastion and store VM administrator password securely in Azure Key Vault

**User Story**: Enable secure remote access to the virtual machine through Azure Bastion and store the VM administrator password securely in Azure Key Vault.

**Independent Test**: Verify administrators can connect to VM via Azure Bastion using credentials retrieved from Key Vault. Confirm VM has no public IP address.

**Prerequisites**: Phase 3 (US1) complete - VM and VNet deployed

### Variables for Key Vault and Bastion

- [ ] T118 [P] [US2] Define variables.tf vm_admin_secret_name variable (default: vm-admin-password for Key Vault secret name per spec FR-016)

### Locals for Resource Naming

- [ ] T119 [P] [US2] Define locals.tf bastion_name (bastion-avmlegacy-{suffix})
- [ ] T120 [P] [US2] Define locals.tf key_vault_name (kv-avmlegacy-{suffix}, note: 3-24 chars, globally unique)

### Key Vault

- [ ] T121 [US2] Implement module block for Key Vault in terraform/main.tf using Azure/avm-res-keyvault-vault/azurerm
- [ ] T122 [US2] Configure Key Vault name = locals.key_vault_name (3-24 chars)
- [ ] T123 [US2] Configure Key Vault tenant_id from data.azapi_client_config.current
- [ ] T124 [US2] Configure Key Vault sku_name = standard
- [ ] T125 [US2] Configure Key Vault soft_delete_retention_days = 90, purge_protection_enabled = true (per spec SEC-012)
- [ ] T126 [US2] Configure Key Vault enable_rbac_authorization = true (use RBAC vs access policies per plan)
- [ ] T127 [US2] Configure Key Vault secrets interface with vm_admin_password secret (name: var.vm_admin_secret_name, value: random_password.vm_admin_password.result)
- [ ] T128 [US2] Configure Key Vault diagnostic_settings sending logs to Log Analytics
- [ ] T129 [US2] Configure Key Vault lock via AVM module lock interface (CanNotDelete per spec SEC-011)
- [ ] T130 [US2] Add depends_on = [random_password.vm_admin_password]
- [ ] T131 [US2] [P] Implement terraform/outputs.tf key_vault_name output
- [ ] T132 [US2] [P] Implement terraform/outputs.tf key_vault_id output
- [ ] T133 [US2] [P] Implement terraform/outputs.tf key_vault_uri output
- [ ] T134 [US2] [P] Implement terraform/outputs.tf vm_admin_secret_name output (sensitive = true)

### Key Vault RBAC for Deployment Identity

- [ ] T135 [US2] Implement data.azapi_client_config.current in terraform/main.tf
- [ ] T136 [US2] Configure the Key Vault module role_assignments interface for the deployment identity (role: Key Vault Secrets Officer, principal_id: current identity)

### Update VM to Reference Key Vault Secret

- [ ] T137 [US2] Update VM module in terraform/main.tf to reference Key Vault secret for admin_password (module.key_vault.secrets[var.vm_admin_secret_name].value)
- [ ] T138 [US2] Update VM module depends_on to include module.key_vault

### Azure Bastion

- [ ] T139 [US2] Implement module block for Bastion in terraform/main.tf using Azure/avm-res-network-bastionhost/azurerm
- [ ] T140 [US2] Configure Bastion name = locals.bastion_name
- [ ] T141 [US2] Configure Bastion subnet_id referencing VNet module bastion_subnet (module.virtual_network.subnets["bastion_subnet"].id)
- [ ] T142 [US2] Configure Bastion sku = Basic (or Standard based on Phase 0 research cost analysis)
- [ ] T143 [US2] Configure Bastion lock via AVM module lock interface (CanNotDelete)
- [ ] T144 [US2] [P] Implement terraform/outputs.tf bastion_name output
- [ ] T145 [US2] [P] Implement terraform/outputs.tf bastion_id output

### Bastion Connection Instructions

- [ ] T146 [US2] Implement terraform/outputs.tf bastion_connect_instructions output with multi-line instructions (Portal navigation, username, password retrieval command)

### User Story 2 Deployment

- [ ] T147 [US2] Populate terraform/prod.tfvars with US2 variable values (vm_admin_secret_name)
- [ ] T148 [US2] Run terraform fmt -recursive
- [ ] T149 [US2] Run terraform validate
- [ ] T150 [US2] Run terraform plan -var-file=prod.tfvars -out=us2.tfplan (expect: Key Vault + secret + RBAC + Bastion + VM update)
- [ ] T151 [US2] Review plan output for US2 completeness (check Key Vault soft-delete, purge protection, secret stored, Bastion SKU)
- [ ] T152 [US2] Run terraform apply us2.tfplan
- [ ] T153 [US2] Verify Key Vault created with soft-delete and purge protection enabled
- [ ] T154 [US2] Verify Key Vault secret exists with name from vm_admin_secret_name variable
- [ ] T155 [US2] Retrieve password from Key Vault using Azure CLI: az keyvault secret show --name [secret-name] --vault-name [kv-name] --query value -o tsv
- [ ] T156 [US2] Verify Bastion deployed and connected to AzureBastionSubnet
- [ ] T157 [US2] Test RDP connection via Bastion (Portal → VM → Connect → Bastion, use vmadmin username and Key Vault password)
- [ ] T158 [US2] Verify VM has no public IP address (confirm from VM properties)

**Phase 4 (US2) Validation**:
- ✅ Key Vault deployed with soft-delete and purge protection
- ✅ VM admin password stored in Key Vault secret
- ✅ Bastion deployed and connected to VNet
- ✅ RDP connection successful via Bastion using Key Vault password
- ✅ VM has no public IP address

**Parallel Execution Opportunities (US2)**:
- Tasks T118 (variable), T119-T120 (locals) can run in parallel
- Tasks T131-T134 (Key Vault outputs) can run in parallel
- Tasks T144-T145 (Bastion outputs) can run in parallel

---

## Phase 5: User Story 3 (P3) - Application Storage Integration

**Objective**: Provide secure access to Azure Files share for application data storage via private endpoint

**User Story**: Provide secure access to an Azure Files share for application data storage, connected via private endpoint to ensure data does not traverse the public internet.

**Independent Test**: From VM, mount Azure Files share using private endpoint IP. Verify data can be written to and read from the share without public internet connectivity.

**Prerequisites**: Phase 3 (US1) and Phase 4 (US2) complete - VM, VNet, and Key Vault deployed

### Variables for Storage

- [ ] T159 [P] [US3] Define variables.tf file_share_name variable (default: legacyappdata)
- [ ] T160 [P] [US3] Define variables.tf file_share_quota_gb variable (default: 1024, validation: must equal 1024 per spec clarifications)

### Locals for Storage Naming

- [ ] T161 [P] [US3] Define locals.tf storage_account_name (stavmlegacy{suffix}, lowercase alphanumeric, max 24 chars)
- [ ] T162 [P] [US3] Define locals.tf private_endpoint_name (pe-storage-avmlegacy-{suffix})

### Storage Account with File Share

- [ ] T163 [US3] Implement module block for Storage Account in terraform/main.tf using Azure/avm-res-storage-storageaccount/azurerm
- [ ] T164 [US3] Configure Storage Account name = locals.storage_account_name (lowercase alphanumeric)
- [ ] T165 [US3] Configure Storage Account account_kind = StorageV2
- [ ] T166 [US3] Configure Storage Account account_tier = Standard, account_replication_type = LRS (HDD per spec IC-007)
- [ ] T167 [US3] Configure Storage Account public_network_access_enabled = false (per spec SEC-009)
- [ ] T168 [US3] Configure Storage Account enable_infrastructure_encryption = true (per spec SEC-013)
- [ ] T169 [US3] Configure Storage Account file_shares with legacy_app_data share (name: var.file_share_name, quota: var.file_share_quota_gb, tier: TransactionOptimized)
- [ ] T170 [US3] Configure Storage Account private_endpoints interface for file subresource (check if built-in or need separate module per Phase 0 research Task 6)
- [ ] T171 [US3] Configure private endpoint name = locals.private_endpoint_name, subnet_resource_id = private_endpoint_subnet, subresource_names = ["file"]
- [ ] T172 [US3] Configure private endpoint private_dns_zone_group_name = "file-private-dns" (auto-create or existing zone per research)
- [ ] T173 [US3] Configure Storage Account diagnostic_settings sending logs to Log Analytics
- [ ] T174 [US3] Configure Storage Account lock via AVM module lock interface (CanNotDelete per spec SEC-011)
- [ ] T175 [US3] [P] Implement terraform/outputs.tf storage_account_name output
- [ ] T176 [US3] [P] Implement terraform/outputs.tf storage_account_id output
- [ ] T177 [US3] [P] Implement terraform/outputs.tf file_share_name output

### File Share Mount Instructions

- [ ] T178 [US3] Implement terraform/outputs.tf file_share_mount_instructions output with PowerShell commands for mounting from VM

### User Story 3 Deployment

- [ ] T179 [US3] Populate terraform/prod.tfvars with US3 variable values (file_share_name, file_share_quota_gb)
- [ ] T180 [US3] Run terraform fmt -recursive
- [ ] T181 [US3] Run terraform validate
- [ ] T182 [US3] Run terraform plan -var-file=prod.tfvars -out=us3.tfplan (expect: Storage Account + file share + private endpoint)
- [ ] T183 [US3] Review plan output for US3 completeness (check public_network_access = false, file share quota = 1TB, private endpoint connected)
- [ ] T184 [US3] Run terraform apply us3.tfplan
- [ ] T185 [US3] Verify Storage Account created with Standard_LRS replication
- [ ] T186 [US3] Verify file share created with 1TB quota (1024GB)
- [ ] T187 [US3] Verify Storage Account has public network access disabled
- [ ] T188 [US3] Verify private endpoint created and connected to private_endpoint_subnet
- [ ] T189 [US3] Test file share mounting from VM via Bastion RDP session (use output file_share_mount_instructions)
- [ ] T190 [US3] From VM: Run net use Z: \\[storage-account-name].privatelink.file.core.windows.net\[file-share-name]
- [ ] T191 [US3] From VM: Test write access by creating a test file on Z: drive
- [ ] T192 [US3] From VM: Test read access by reading the test file from Z: drive
- [ ] T193 [US3] Verify DNS resolution from VM resolves storage FQDN to private endpoint IP (not public IP)

**Phase 5 (US3) Validation**:
- ✅ Storage Account deployed with Standard_LRS, no public access
- ✅ File share created with 1TB quota
- ✅ Private endpoint deployed and connected to VNet
- ✅ File share accessible from VM via private endpoint (mount successful)
- ✅ Read/write operations successful on mounted file share
- ✅ DNS resolves to private IP (not public IP)

**Parallel Execution Opportunities (US3)**:
- Tasks T159-T160 (variables) can run in parallel
- Tasks T161-T162 (locals) can run in parallel
- Tasks T175-T177 (outputs) can run in parallel

---

## Phase 6: User Story 4 (P4) - Internet Access and Observability

**Objective**: Enable outbound internet access via NAT Gateway and implement comprehensive monitoring through Log Analytics with critical alerts

**User Story**: Enable outbound internet access via NAT Gateway for Windows Updates and patches, and implement comprehensive monitoring through Log Analytics with diagnostic logging and critical alerts.

**Independent Test**: From VM, verify outbound internet connectivity (e.g., download Windows Update). Confirm diagnostic logs flowing to Log Analytics and test alerts trigger correctly.

**Prerequisites**: Phase 3 (US1), Phase 4 (US2), Phase 5 (US3) complete - All infrastructure deployed

### Variables for NAT Gateway and Alerts

- [ ] T194 [P] [US4] Define variables.tf alert_action_group_email variable (no default - must be provided in tfvars)

### Locals for NAT Gateway and Alerts

- [ ] T195 [P] [US4] Define locals.tf nat_gateway_name (nat-avmlegacy-{suffix})
- [ ] T196 [P] [US4] Define locals.tf nat_public_ip_name (pip-nat-avmlegacy-{suffix})
- [ ] T197 [P] [US4] Define locals.tf action_group_name (ag-avmlegacy-{suffix})

### NAT Gateway

- [ ] T198 [US4] Implement module block for NAT Gateway in terraform/main.tf using Azure/avm-res-network-natgateway/azurerm
- [ ] T199 [US4] Configure NAT Gateway name = locals.nat_gateway_name
- [ ] T200 [US4] Configure NAT Gateway public_ip_addresses with pip-nat public IP (name: locals.nat_public_ip_name, zones: [var.availability_zone])
- [ ] T201 [US4] Configure NAT Gateway subnet_associations with vm_subnet (subnet_id from module.virtual_network.subnets["vm_subnet"].id)
- [ ] T202 [US4] [P] Implement terraform/outputs.tf nat_gateway_name output
- [ ] T203 [US4] [P] Implement terraform/outputs.tf nat_gateway_public_ip output

### Action Group for Alerts

- [ ] T204 [US4] Implement an AzAPI `Microsoft.Insights/actionGroups` resource in terraform/main.tf (name: locals.action_group_name, short name: avmalerts)
- [ ] T205 [US4] Configure action group email_receiver (name: admin-email, email_address: var.alert_action_group_email)

### Metric Alerts

- [ ] T206 [P] [US4] Implement an AzAPI `Microsoft.Insights/metricAlerts` resource for VM stopped in terraform/main.tf (name: alert-vm-stopped-{vm_name}, scope: VM ID)
- [ ] T207 [US4] Configure VM stopped alert criteria (metric: VmAvailabilityMetric, aggregation: Average, operator: LessThan, threshold: 1, severity: 0)
- [ ] T208 [US4] Configure VM stopped alert frequency = PT5M, window_size = PT5M
- [ ] T209 [US4] Configure VM stopped alert action referencing action group
- [ ] T210 [P] [US4] Implement an AzAPI `Microsoft.Insights/metricAlerts` resource for VM disk usage in terraform/main.tf (name: alert-vm-disk-usage-{vm_name}, scope: VM ID)
- [ ] T211 [US4] Configure VM disk alert criteria (metric: OS Disk Used Percent, aggregation: Average, operator: GreaterThan, threshold: 90, severity: 0)
- [ ] T212 [US4] Configure VM disk alert frequency = PT15M, window_size = PT15M
- [ ] T213 [US4] Configure VM disk alert action referencing action group
- [ ] T214 [P] [US4] Implement an AzAPI `Microsoft.Insights/metricAlerts` resource for Key Vault access failures in terraform/main.tf (name: alert-kv-access-failures-{kv_name}, scope: Key Vault ID)
- [ ] T215 [US4] Configure Key Vault alert criteria (metric: ServiceApiResult, aggregation: Count, operator: GreaterThan, threshold: 0, severity: 0)
- [ ] T216 [US4] Configure Key Vault alert dimension filter (name: StatusCode, operator: Include, values: ["403"])
- [ ] T217 [US4] Configure Key Vault alert frequency = PT5M, window_size = PT5M
- [ ] T218 [US4] Configure Key Vault alert action referencing action group

### User Story 4 Deployment

- [ ] T219 [US4] Populate terraform/prod.tfvars with US4 variable values (alert_action_group_email - **UPDATE THIS**)
- [ ] T220 [US4] Run terraform fmt -recursive
- [ ] T221 [US4] Run terraform validate
- [ ] T222 [US4] Run terraform plan -var-file=prod.tfvars -out=us4.tfplan (expect: NAT Gateway + public IP + action group + 3 alerts)
- [ ] T223 [US4] Review plan output for US4 completeness (check NAT Gateway associated with vm_subnet, alerts configured with correct thresholds)
- [ ] T224 [US4] Run terraform apply us4.tfplan
- [ ] T225 [US4] Verify NAT Gateway deployed with public IP
- [ ] T226 [US4] Verify NAT Gateway associated with vm_subnet
- [ ] T227 [US4] Test outbound internet connectivity from VM via Bastion RDP session (`Invoke-WebRequest -Uri "https://www.microsoft.com" -UseBasicParsing`)
- [ ] T228 [US4] Verify VM cannot receive inbound connections from internet (remains inaccessible)
- [ ] T229 [US4] Verify diagnostic logs from VM in Log Analytics (run query: Perf | where Computer startswith "vm-avmlegacy" | take 10)
- [ ] T230 [US4] Verify diagnostic logs from Key Vault in Log Analytics (run query: AzureDiagnostics | where ResourceType == "VAULTS" | take 10)
- [ ] T231 [US4] Verify diagnostic logs from Storage Account in Log Analytics
- [ ] T232 [US4] Test VM stopped alert by stopping VM in Portal (wait 5 minutes, verify alert notification)
- [ ] T233 [US4] Test alert action group email notification received
- [ ] T234 [US4] Start VM after alert test

**Phase 6 (US4) Validation**:
- ✅ NAT Gateway deployed and associated with vm_subnet
- ✅ VM has outbound internet access via NAT Gateway
- ✅ VM remains inaccessible from internet (inbound blocked)
- ✅ Diagnostic logs flowing to Log Analytics from VM, Key Vault, Storage Account
- ✅ 3 metric alerts configured and functional (VM stopped, disk >90%, Key Vault failures)
- ✅ Alert notifications delivered to action group

**Parallel Execution Opportunities (US4)**:
- Tasks T195-T197 (locals) can run in parallel
- Tasks T202-T203 (NAT Gateway outputs) can run in parallel
- Tasks T206, T210, T214 (alert resource creation) can run in parallel after action group created

---

## Phase 7: Polish & Cross-Cutting Concerns

**Objective**: Final validation, documentation, cost analysis, and CI/CD pipeline setup (optional)

**Prerequisites**: All user stories (US1-US4) complete and validated

### Final Terraform Validation

- [ ] T235 Run terraform fmt -recursive -check to ensure all files formatted
- [ ] T236 Run terraform validate to ensure no syntax errors
- [ ] T237 Run tfsec . to scan for security issues (expect: zero HIGH or CRITICAL findings per spec SC-009)
- [ ] T238 Run checkov -d . to scan for compliance issues
- [ ] T239 Address any HIGH or CRITICAL findings from security scans

### Cost Validation

- [ ] T240 Use Azure Pricing Calculator to estimate monthly cost of deployed infrastructure
- [ ] T241 Verify estimated cost is under $200/month per spec SC-013
- [ ] T242 Document cost breakdown in docs/README.md (VM, Bastion, NAT Gateway, Log Analytics, Storage, Key Vault)

### Documentation Finalization

- [ ] T243 Update terraform/README.md with complete deployment instructions (prerequisites, backend setup, variable customization, deployment steps)
- [ ] T244 Update docs/README.md with project overview (architecture summary, cost estimate, deployment time estimate)
- [ ] T245 Update docs/architecture.md with infrastructure diagram (VNet topology, resource relationships, security boundaries)
- [ ] T246 Document all outputs in terraform/README.md (how to retrieve VM password, connect via Bastion, mount file share)
- [ ] T247 Create terraform/prod.tfvars.example with all variables and rich comments (remove sensitive values)

### Deployment Success Criteria Verification

- [ ] T248 Verify SC-001: Infrastructure deployment completed within 30 minutes (time terraform apply)
- [ ] T249 Verify SC-002: RDP connection via Bastion established within 2 minutes
- [ ] T250 Verify SC-003: VM password from Key Vault successfully authenticates RDP session (100% success rate)
- [ ] T251 Verify SC-004: VM can mount Azure Files share and perform read/write operations
- [ ] T252 Verify SC-005: VM can download content from internet via NAT Gateway (test Windows Update or HTTP GET)
- [ ] T253 Verify SC-006: VM is NOT reachable via direct internet connection (external port scan shows no open ports)
- [ ] T254 Verify SC-007: Diagnostic logs from VM, Key Vault, Storage appear in Log Analytics within 15 minutes
- [ ] T255 Verify SC-008: Critical alerts trigger within 5 minutes (test VM stopped alert)
- [ ] T256 Verify SC-009: terraform fmt -check, terraform validate, tfsec pass with zero HIGH/CRITICAL findings
- [ ] T257 Verify SC-010: All values from terraform.tfvars (no hardcoded values in main.tf)
- [ ] T258 Verify SC-011: AVM module variables correctly structured per module documentation
- [ ] T259 Verify SC-012: VM computer name is 15 characters or fewer
- [ ] T260 Verify SC-013: Total monthly cost under $200/month

### Optional CI/CD Pipeline

- [ ] T261 [P] Create .github/workflows/terraform-validate.yml for CI/CD pipeline
- [ ] T262 [P] Configure pipeline to run terraform fmt -check on pull requests
- [ ] T263 [P] Configure pipeline to run terraform validate on pull requests
- [ ] T264 [P] Configure pipeline to run tfsec scan on pull requests
- [ ] T265 [P] Configure pipeline to fail on HIGH or CRITICAL security findings
- [ ] T266 [P] Test pipeline by creating a test pull request

### Final Acceptance

- [ ] T267 Execute complete teardown and redeploy to verify infrastructure is fully recreatable (terraform destroy → terraform apply)
- [ ] T268 Time full deployment from scratch (should complete within 30 minutes per SC-001)
- [ ] T269 Document actual deployment time and cost in docs/README.md

**Phase 7 Validation**:
- ✅ All 13 success criteria (SC-001 through SC-013) met
- ✅ Terraform validation passing (fmt, validate, tfsec, checkov)
- ✅ Cost under $200/month confirmed
- ✅ Complete documentation available (README, architecture, deployment guide)
- ✅ Infrastructure fully recreatable from Terraform

---

## Dependencies Summary

### Phase Dependencies
```text
Phase 1 (Setup)
  ↓
Phase 2 (Foundational) [BLOCKS all user stories]
  ↓
Phase 3 (US1 - Core Compute) [BLOCKS all subsequent stories]
  ↓
Phase 4 (US2 - Secure Access) [Independent of US3, US4]
  ↓
Phase 5 (US3 - Storage) [Independent of US4, depends on US1+US2]
  ↓
Phase 6 (US4 - Observability) [Depends on US1+US2+US3]
  ↓
Phase 7 (Polish) [Depends on all user stories]
```

### Critical Path
1. Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → US4 (Phase 6) → Polish (Phase 7)

### Parallel Opportunities
- **Setup Phase**: Tasks T005-T010 (Terraform file shells), T014-T016 (tooling installation)
- **Foundational Phase**: Variable definitions (T027-T030), outputs (T038-T039)
- **US1 Phase**: Variable definitions (T047-T054), NSG module blocks (T070, T074, T080), NSG associations (T084-T086), outputs (T068-T069, T103-T106)
- **US2 Phase**: Variable/locals (T118-T120), Key Vault outputs (T131-T134), Bastion outputs (T144-T145)
- **US3 Phase**: Variables (T159-T160), locals (T161-T162), outputs (T175-T177)
- **US4 Phase**: Locals (T195-T197), NAT Gateway outputs (T202-T203), alert resource creation (T206, T210, T214)
- **Polish Phase**: CI/CD pipeline tasks (T261-T266)

---

## Notes

### Tests
- **Not Generated**: Per spec, no test infrastructure requested. User story acceptance scenarios serve as manual test criteria.

### MVP Recommendation
- **Suggested MVP**: Complete through Phase 4 (US1 + US2) for minimum viable secure infrastructure (VM with RDP access via Bastion, password in Key Vault)
- **Optional for MVP**: US3 (Storage) and US4 (Observability) can be deferred

### File Share Mount
- **Post-Deployment Manual Step**: File share mounting to VM is manual per spec clarifications (no automation, aligns with IaC-first principle)
- **Instructions**: Provided in terraform/outputs.tf file_share_mount_instructions output

### AVM Module Versions
- **Research Required**: Phase 0 research (plan.md Phase 0) must verify latest AVM module versions from Terraform Registry before implementation
- **Version Constraints**: Use pessimistic versioning (~> X.Y.0) per constitution Principle II

### Constitution Compliance
- **All tasks align with**: Constitution Principles I-V (Terraform-first, AVM-only, Security/Reliability, Single-template, Validation-first)
- **No local modules**: All tasks use AVM modules exclusively (no custom module creation)

### Validation Gates
- **Between Phases**: Run terraform fmt → validate → plan → review before apply
- **Security Scanning**: Run tfsec and checkov before final deployment
- **Manual Testing**: Each user story has independent test criteria in spec.md

---

**End of Tasks Document**

**Total Tasks**: 269
**Estimated Implementation Time**: 20-30 hours (including research, implementation, testing, documentation)
**Deployment Time**: ~30 minutes per spec SC-001
**Cost**: <$200/month per spec SC-013
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture your task list, with a comment of something like Tasks generated.

7. Analyze (Optional)

Spec Kit uses /speckit.analyze to generate an analysis report. The prompt doesn’t require any specific inputs as it analyzes the existing spec, plan and tasks to produce the report.

Info

To learn more about the analyze step, see the Analyze chapter in the Spec Kit article.

  1. Run the following prompt to generate an analysis report for our example:
​
/speckit.analyze
➕ Expand to see the results

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

In the Copilot chat window, you should see something like this:
Specify Bootstrap

<!-- markdownlint-disable -->
# Specification Analysis Report

**Feature**: 001-legacy-vm-workload
**Analyzed Files**: spec.md, plan.md, tasks.md, constitution.md
**Date**: 2026-01-26
**Analysis Mode**: READ-ONLY (no modifications applied)

---

## Executive Summary

Analyzed **24 functional requirements**, **4 user stories**, and **61 implementation tasks** across the specification artifacts. Identified **16 findings** (0 CRITICAL, 5 HIGH, 8 MEDIUM, 3 LOW). Constitution compliance: **100% aligned** with all 5 principles satisfied. Primary concerns: **ambiguous terminology** (Standard HDD), **missing technical specifications** (AVM module versions, NSG rules, encryption details), and **underspecified monitoring thresholds**.

**Overall Assessment**: ✅ **SAFE TO PROCEED** with implementation after resolving 5 HIGH severity issues. No CRITICAL or constitution-blocking issues detected.

---

## Findings

| ID | Category | Severity | Location(s) | Summary | Recommendation |
|----|----------|----------|-------------|---------|----------------|
| A1 | Ambiguity | HIGH | spec.md:FR-003, FR-008, tasks.md:T025, T030, T033 | "Standard HDD" not mapped to explicit Azure SKU (Standard_LRS vs StandardSSD_LRS) | Specify `Standard_LRS` explicitly in spec FR-003, FR-008; update tasks T025, T030, T033 with SKU name |
| A2 | Underspecification | HIGH | spec.md:FR-022, tasks.md:T040 | "Disk utilization >90%" threshold ambiguous - percentage of what? (capacity, IOPS, throughput) | Clarify as "OS disk used capacity >90%" or "Data disk used capacity >90%"; specify which disk(s) |
| A3 | Underspecification | HIGH | spec.md:FR-020, plan.md, tasks.md:T012 | NSG rules incomplete - missing explicit allow rules for Key Vault (443), storage (445), Log Analytics (443) | Document required outbound NSG rules: Azure services (AzureCloud service tag) port 443 for KV/Storage/LA |
| A4 | Coverage Gap | HIGH | spec.md:FR-011, tasks.md:T013-T015 | Password complexity requirements not validated against Azure VM password policy (12-123 chars, 3 of 4 types) | Add validation task or clarify password generation formula meets Azure requirements |
| A5 | Underspecification | HIGH | All documents | AVM module version constraints missing - no "latest stable" definition or version pinning strategy | Add research task to query MCR for latest versions; specify version pinning strategy (exact vs ^0.x) |
| U1 | Underspecification | MEDIUM | spec.md:FR-019, tasks.md:T016, T020, T027, T036 | Diagnostic log categories not specified - which logs to collect per resource type? | Define log categories: VM (Performance, Security), KV (AuditEvent), Storage (Transaction, StorageRead) |
| U2 | Inconsistency | MEDIUM | spec.md:US4 acceptance scenario, plan.md | Key Vault access model conflict - spec mentions "access policies", plan says "RBAC". Which to use? | Resolve: Use RBAC per plan (T015 specifies enableRbacAuthorization: true); update spec to remove access policies reference |
| U3 | Coverage Gap | MEDIUM | spec.md, tasks.md | Encryption-at-rest requirements implicit but not explicit for VM disks, storage account, Key Vault | Add requirement: "All storage must use Azure-managed encryption at rest" or assume default encryption |
| U4 | Underspecification | MEDIUM | spec.md:FR-022, tasks.md:T039-T041 | Alert notification destinations undefined - who receives alerts? Email? Action group? | Document assumption: Alert rules created without notification actions (configured post-deployment) |
| U5 | Inconsistency | MEDIUM | spec.md:FR-016, plan.md, tasks.md:T007 | Availability zone terminology: spec says "selection", plan/tasks say "parameter" - is it user choice or fixed? | Clarify: availabilityZone is a parameter with default 1; user can select 1, 2, or 3 (not random assignment) |
| U6 | Underspecification | MEDIUM | spec.md:FR-024, plan.md | File share growth monitoring "documented procedures" - what procedures? Manual check? Alert? | Specify: Document manual procedure to query file share usage via Azure CLI/Portal; no automated monitoring |
| U7 | Coverage Gap | MEDIUM | spec.md, tasks.md:T028 | VM boot diagnostics storage account not specified - uses managed storage or custom? | Add clarification: Use managed boot diagnostics (no separate storage account); T028 should specify this |
| U8 | Underspecification | MEDIUM | spec.md:FR-014, plan.md, tasks.md:T009 | Random suffix generation uses uniqueString(resourceGroup().id) - what's the character length? | Specify: 6-character suffix using substring(uniqueString(resourceGroup().id), 0, 6) |
| T1 | Terminology Drift | LOW | spec.md uses "Azure Storage Account", tasks.md uses "Storage Account" | Minor inconsistency in terminology - not functionally impactful | Standardize on "Storage Account" throughout |
| T2 | Duplication | LOW | spec.md:FR-005, FR-017 + plan.md Technical Context | Resource group designation repeated - spec says "production", plan implies via parameter 'environment' | Consolidate: Resource group is deployment target (not created by template); environment tag controlled by parameter |
| T3 | Coverage Gap | LOW | tasks.md:T002 | No task detail for creating bicepconfig.json analyzer rules despite constitution principle IV requiring it | Add detail to T002: Include analyzer rule `use-recent-module-versions: warning` in bicepconfig.json |

---

## Coverage Summary

### Requirements-to-Tasks Mapping

| Requirement Key | Has Task? | Task IDs | Coverage Status |
|-----------------|-----------|----------|-----------------|
| FR-001 (WinServer2016 US West 3) | ✅ | T006, T023 | ✅ Covered by location param + imageReference |
| FR-002 (2 cores, 8GB RAM) | ✅ | T023 | ✅ vmSize parameter (Standard_D2s_v3) |
| FR-003 (Standard HDD OS) | ✅ | T025 | ⚠️ Ambiguous - need SKU clarification (A1) |
| FR-004 (500GB data disk) | ✅ | T030 | ⚠️ Ambiguous - need SKU clarification (A1) |
| FR-005 (Single RG) | ✅ | Implicit | ✅ Deployment target (no task needed) |
| FR-006 (VNet) | ✅ | T011 | ✅ 3 subnets defined |
| FR-007 (Bastion) | ✅ | T018-T021 | ✅ Fully covered |
| FR-008 (Storage + file share) | ✅ | T032-T033 | ⚠️ Ambiguous SKU (A1) |
| FR-009 (Private endpoint) | ✅ | T035 | ✅ Covered |
| FR-010 (Key Vault) | ✅ | T014 | ✅ Covered |
| FR-011 (Password gen + store) | ✅ | T013, T015 | ⚠️ Complexity validation gap (A4) |
| FR-012 (Username = administrator) | ✅ | T024, T053 | ✅ Covered |
| FR-013 (Secret name param) | ✅ | T015, T053 | ✅ Covered |
| FR-014 (Naming convention) | ✅ | T009 + all resources | ⚠️ Length not specified (U8) |
| FR-015 (AVM modules only) | ✅ | All module tasks | ⚠️ No version constraints (A5) |
| FR-016 (Zones 1-3) | ✅ | T007 | ⚠️ Terminology inconsistency (U5) |
| FR-017 (Parameter-driven) | ✅ | T005-T008, T053 | ✅ Covered |
| FR-018 (CanNotDelete locks) | ✅ | T017, T021, T029, T031, T037, T038 | ✅ Covered |
| FR-019 (Diagnostic logging) | ✅ | T016, T020, T027, T036 | ⚠️ Log categories unspecified (U1) |
| FR-020 (NSG rules) | ✅ | T012 | ⚠️ Incomplete rules (A3) |
| FR-021 (No public access) | ✅ | T034 | ✅ Covered |
| FR-022 (Critical alerts) | ✅ | T039-T041 | ⚠️ Threshold + notification gaps (A2, U4) |
| FR-023 (Log Analytics) | ✅ | T010 | ✅ Covered |
| FR-024 (File share growth) | ✅ | Documented in spec | ⚠️ Procedure undefined (U6) |

**Coverage %**: **100%** (24/24 requirements have associated tasks)

### User Story Coverage

| User Story | Priority | Tasks | Coverage Status |
|------------|----------|-------|-----------------|
| US1 - Core VM Infrastructure | P1 (MVP) | T018-T021, T022-T029 | ✅ Fully covered (9 tasks) |
| US2 - Attach Additional Storage | P2 | T030-T031 | ✅ Fully covered (2 tasks) |
| US3 - Connect to Secure File Share | P3 | T032-T038 | ✅ Fully covered (7 tasks) |
| US4 - Secure Secret Management | P1 (MVP) | T013-T017 | ✅ Fully covered (5 tasks) |

**User Story Coverage**: 100% (all 4 stories have complete task coverage)

---

## Constitution Alignment Issues

**Status**: ✅ **ZERO VIOLATIONS** - All 5 constitution principles are fully satisfied

| Principle | Status | Evidence | Risk Level |
|-----------|--------|----------|------------|
| I. AVM-Only Modules | ✅ PASS | Tasks T010-T041 reference only AVM modules from br/public:avm/... registry; zero direct resource declarations planned | None |
| II. IaC-First Approach | ✅ PASS | Single main.bicep (T004), no custom scripts/CSE, ARM incremental mode, manual post-deployment file share mounting (T058) | None |
| III. Security & Reliability | ✅ PASS | Private endpoints (T035), Key Vault (T014-T015), CanNotDelete locks (T017, T021, T029, T031, T037, T038), diagnostic logs (T016, T020, T027, T036), NSG (T012) | None |
| IV. Pre-Deployment Validation | ✅ PASS | Tasks T054-T055 explicitly mandate `az deployment group validate` + ARM What-If review before deployment (T056) | None |
| V. Naming Convention & Regional Standards | ✅ PASS | Naming via T009 (uniqueString suffix), location=westus3 (T006), zones 1-3 (T007), CAF abbreviations in all resource names | None |

**Constitution Compliance Score**: 100% (5/5 principles satisfied)

---

## Unmapped Tasks

**Status**: ✅ **ZERO UNMAPPED TASKS**

All 61 tasks map to at least one requirement, user story, or infrastructure prerequisite:
- **Setup tasks (T001-T003)**: Infrastructure initialization (required for all user stories)
- **Foundational tasks (T004-T012)**: Shared resources (Log Analytics, VNet, NSG) - prerequisite for all user stories
- **User story tasks (T013-T038)**: Direct mapping to US1, US2, US3, US4
- **Polish tasks (T039-T053)**: Cross-cutting concerns (alerts, outputs, parameters)
- **Validation tasks (T054-T061)**: Post-implementation verification

---

## Metrics

### Quantitative Analysis

- **Total Functional Requirements**: 24 (FR-001 through FR-024)
- **Total User Stories**: 4 (US1-P1, US2-P2, US3-P3, US4-P1)
- **Total Implementation Tasks**: 61 (T001 through T061)
- **Total Entities (Data Model)**: 12 Azure resources
- **Total Input Parameters**: 11 (3 required, 8 optional with defaults)
- **Total Output Values**: 10

### Coverage Metrics

- **Requirements Coverage**: 100% (24/24 requirements have ≥1 task)
- **User Story Coverage**: 100% (4/4 stories have complete task sets)
- **Constitution Compliance**: 100% (5/5 principles satisfied)
- **Task-to-Requirement Traceability**: 100% (all 61 tasks map to requirements or prerequisites)

### Quality Metrics

- **Ambiguity Count**: 5 findings (A1, A2, U1, U2, U8)
- **Duplication Count**: 1 finding (T2)
- **Underspecification Count**: 9 findings (A2, A3, A4, A5, U1, U4, U6, U7, U8)
- **Coverage Gaps**: 4 findings (A4, U3, U7, T3)
- **Inconsistencies**: 2 findings (U2, U5)
- **Terminology Drift**: 1 finding (T1)

### Severity Distribution

- **CRITICAL Issues**: 0 (deployment blockers)
- **HIGH Issues**: 5 (implementation ambiguity, missing technical specs)
- **MEDIUM Issues**: 8 (specification gaps, inconsistencies)
- **LOW Issues**: 3 (documentation polish, minor gaps)
- **Total Findings**: 16

---

## Dependency Analysis

### Critical Path Dependencies

```
Log Analytics (T010)
    ↓
VNet + NSG (T011-T012) ────┐
    ↓                       │
Key Vault + Password ───────┤
(T013-T017)                 │
    ↓                       │
VM + Bastion ←──────────────┘
(T018-T029)
    ↓
Data Disk (T030-T031)
    ↓
Storage + Private Endpoint (T032-T038)
    ↓
Alerts + Outputs (T039-T051)
    ↓
Validation (T054-T061)
```

### Parallelization Opportunities

**35 tasks marked [P]** can execute in parallel:

1. **Phase 2 - Foundational** (after Log Analytics + VNet):
  - T005-T008 (Parameters) - 4 tasks in parallel
  - T012 (NSG) can overlap with T010-T011

2. **Phase 3 - MVP** (after Key Vault complete):
  - T018-T021 (Bastion) parallel with T022-T029 (VM) - both depend on VNet only

3. **Phase 4-5** (after Foundation):
  - T030-T031 (Data Disk) parallel with T032-T038 (Storage + PE) - no mutual dependency

4. **Phase 6 - Polish**:
  - T039-T041 (Alert Rules) - 3 tasks in parallel
  - T042-T051 (Outputs) - 10 tasks in parallel
  - T052-T053 (Parameters file) parallel with T039-T051

**Estimated Parallelization Benefit**: ~30% time reduction (from 8.5 hours to ~6 hours with optimal parallelization)

---

## Next Actions

### Before `/speckit.implement` (MANDATORY)

**1. RESOLVE HIGH SEVERITY ISSUES (A1-A5)**:

- **A1 - Standard HDD SKU Ambiguity**:
  - Update spec.md FR-003: "Virtual machine MUST use Standard HDD storage tier (Standard_LRS SKU) for the OS disk"
  - Update spec.md FR-008: "System MUST deploy an Azure Storage Account with HDD-backed file share (Standard_LRS SKU) with 1TB initial quota"
  - Update tasks.md T025: "Configure VM OS disk with Standard_LRS storage SKU (Standard HDD)"
  - Update tasks.md T030: "Configure VM data disks array with 500GB disk, Standard_LRS (Standard HDD)"
  - Update tasks.md T033: "Configure Storage Account with Standard_LRS SKU"

- **A2 - Disk Utilization Alert Threshold**:
  - Update spec.md FR-022: "System MUST configure critical alerts for VM stopped, OS disk used capacity >90% OR data disk used capacity >90%, and Key Vault access failures"
  - Update tasks.md T040: "Add Metric Alert Rule for OS disk used capacity >90% AND data disk used capacity >90% with separate alert conditions"

- **A3 - NSG Rules Incomplete**:
  - Update spec.md FR-020 to specify: "NSG MUST allow outbound traffic to AzureCloud service tag on port 443 (for Key Vault, Storage, Log Analytics); MUST allow inbound RDP (3389) from AzureBastionSubnet only; MUST deny all other inbound traffic from Internet"
  - Update tasks.md T012: "Add Network Security Group module with rules: Priority 100 Allow RDP inbound from AzureBastionSubnet, Priority 110 Allow HTTPS outbound to AzureCloud tag, Priority 4096 Deny all inbound from Internet"

- **A4 - Password Complexity Validation**:
  - Add assumption to spec.md Assumptions section: "Generated password formula '${uniqueString(resourceGroup().id)}${guid(subscription().id, resourceGroup().id)}A1!' produces compliant passwords meeting Azure VM requirements (12-123 characters, contains uppercase, lowercase, number, special character)"
  - OR add validation task between T013-T014: Verify password generation meets Azure complexity rules

- **A5 - AVM Module Versioning**:
  - Add research task to research.md: Query MCR tags for latest stable versions of all 10 AVM modules
  - Add versioning strategy to plan.md Technical Context: "Use exact version pinning (e.g., 0.10.2) for production deployments to ensure reproducibility"
  - Update all tasks T010-T041 to specify version format: `br/public:avm/res/<module>:0.x.x` (replace with actual versions)

### Recommended (MEDIUM Severity Issues - U1-U8)

These can be addressed during implementation but are recommended before finalizing:

- **U1**: Specify diagnostic log categories per resource type in research.md or assume defaults
- **U2**: Update spec.md US4 acceptance scenario 3 to replace "access policies" with "RBAC" for consistency
- **U3**: Add encryption-at-rest requirement or assumption (Azure default encryption assumed)
- **U4**: Add assumption to spec.md: "Alert notification actions (email, webhook, action groups) configured post-deployment"
- **U5**: Clarify in spec.md FR-016: "Availability zone MUST be user-selectable via parameter (values 1, 2, or 3)"
- **U6**: Document in quickstart.md: "Monitor file share usage via Azure Portal → Storage Account → File Shares → Properties → Quota"
- **U7**: Update tasks.md T028: "Enable VM boot diagnostics using managed storage (no separate storage account)"
- **U8**: Update tasks.md T009: "Create random suffix variable using substring(uniqueString(resourceGroup().id), 0, 6)"

### Optional (LOW Severity Issues - T1-T3)

Address during implementation or final polish:

- **T1**: Standardize terminology to "Storage Account" throughout all documents
- **T2**: Clarify in plan.md that environment parameter is for tagging only (resource group is deployment target)
- **T3**: Expand tasks.md T002 description: "Create infra/bicepconfig.json with AVM registry alias and analyzer rule use-recent-module-versions: warning"

---

## Deployment Readiness Assessment

### Overall Readiness: ⚠️ **CONDITIONALLY READY**

**Recommendation**: ✅ **SAFE TO PROCEED** with implementation **AFTER** resolving 5 HIGH severity issues (A1-A5)

**Justification**:
- ✅ **Strengths**:
  - Zero CRITICAL blockers
  - 100% constitution compliance
  - Complete requirements-to-task coverage
  - Well-structured user story organization with clear MVP definition
  - Comprehensive dependency analysis with parallelization opportunities
  - No unmapped tasks or orphaned requirements

- ⚠️ **Risks**:
  - HIGH issues create implementation ambiguity (developer may choose wrong SKU, incomplete NSG rules)
  - Missing AVM version specifications could lead to breaking changes or deployment failures
  - Underspecified thresholds (disk utilization) may trigger false alerts or miss real issues

- ✅ **Mitigation**:
  - All HIGH issues are resolvable through specification updates (no architecture changes required)
  - Estimated remediation time: 30-45 minutes
  - No blocking dependencies or conflicting requirements detected

### Pre-Implementation Checklist

Before running `/speckit.implement`, ensure:

- [ ] All 5 HIGH severity issues (A1-A5) resolved in spec.md and tasks.md
- [ ] Constitution compliance maintained (currently 100%)
- [ ] AVM module versions researched and documented
- [ ] NSG rules fully specified with priorities and service tags
- [ ] Azure VM password complexity requirements validated or assumed

### Implementation Risk Level: **LOW-MEDIUM**

With HIGH issues resolved, implementation risk drops to LOW. Current specification provides sufficient clarity for experienced Bicep developer but requires technical gap-filling that could introduce inconsistencies if not addressed upfront.

---

## Remediation Options

**Would you like concrete remediation edits for the 5 HIGH severity issues (A1-A5)?**

Options:
- **A. Yes, provide all edits** - Generate specific text replacements for spec.md, plan.md, tasks.md to resolve all HIGH issues
- **B. Only critical clarifications** - Provide edits for A1-A3 (SKU, alerts, NSG) only; defer A4-A5 for implementation phase
- **C. Manual remediation** - Keep analysis report as-is; I'll manually address issues based on recommendations

---

## Appendix: Document Statistics

### Specification Artifacts

| Document | Lines | Sections | Key Content |
|----------|-------|----------|-------------|
| constitution.md | 114 | 5 principles | Non-negotiable rules; 100% compliance |
| spec.md | 169 | 4 user stories, 24 FRs, 13 success criteria | Requirements definition; 2 clarifications |
| plan.md | 200 | Technical context, constitution checks, Phase 0-1 summary | Implementation blueprint; 6 design artifacts |
| data-model.md | 394 | 12 entities, ERD, deployment order | Resource definitions; dependency mapping |
| research.md | 236 | 8 research topics | AVM mapping, password strategy, network design |
| contracts/parameters.md | 317 | 11 parameters | Input contract; 3 required, 8 optional |
| contracts/outputs.md | 234 | 10 outputs | Output contract; administrative + operational |
| quickstart.md | ~300 | 7-step deployment guide | Deployment procedures; troubleshooting |
| tasks.md | 277 | 61 tasks across 7 phases | Implementation checklist; MVP + incremental delivery |

### Total Documentation Volume

- **Total Lines**: ~2,241 lines
- **Total Artifacts**: 9 files (+ checklists/implementation-readiness.md)
- **Estimated Reading Time**: 45-60 minutes
- **Estimated Implementation Time**: 8.5 hours (6 hours with parallelization)

---

**Report Generated**: 2026-01-26
**Next Action**: Resolve HIGH severity issues → Run `/speckit.implement` → Deploy to test environment
/speckit.analyze
➕ Expand to see the results

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​

In the Copilot chat window, you should see something like this:
Specify Bootstrap

<!-- markdownlint-disable -->
# Specification Analysis Report

**Feature**: 001-my-legacy-workload
**Analysis Date**: 2026-02-18
**Analyzed Artifacts**: spec.md, plan.md, tasks.md, constitution.md
**Status**: ✅ **CRITICAL/HIGH ISSUES RESOLVED** - Ready for Implementation

---

## Executive Summary

Analyzed 3 core artifacts (spec.md, plan.md, tasks.md) and constitution.md before implementation. Found **31 findings** across 6 detection categories.

**Critical Issues**: 2 (resolved)
**High Issues**: 4 (resolved)
**Medium Issues**: 15 (documented - not blocking)
**Low Issues**: 10 (documented - not blocking)

**Resolution Status**: All 6 CRITICAL/HIGH issues have been remediated. Implementation can proceed.

---

## Critical/High Issues - RESOLVED ✅

### COV1 - Task Count Discrepancy (CRITICAL) ✅ FIXED
**Location**: tasks.md header
**Issue**: Header stated "Total Tasks: 78" but document contained 269 tasks (T001-T269)
**Resolution**: Updated header to "Total Tasks: 269" and added Phase 0 prerequisite note
**Impact**: Eliminated confusion about task scope

### A1 - Subnet CIDR Conflicts (HIGH) ✅ FIXED
**Location**: spec.md IC-008 vs Clarifications section
**Issue**:
- IC-008: Bastion=10.0.0.32/26, PrivateEndpoint=10.0.0.96/28
- Clarifications: Bastion=10.0.0.64/26, PrivateEndpoint=10.0.0.128/27

**Resolution**: Made IC-008 AUTHORITATIVE - updated spec.md clarifications to reference IC-008 values, marked as "SUPERSEDED BY IC-008"
**Rationale**: IC-008 values match tasks.md implementation (T047-T050)
**Impact**: Eliminated deployment failures from incorrect CIDR allocations

### A2 - Disk Alert Threshold Conflict (HIGH) ✅ FIXED
**Location**: spec.md SC-008 vs Clarifications
**Issue**:
- SC-008: ">90%"
- Clarifications: "85% full"

**Resolution**: Standardized to 90% throughout - updated SC-008 to "disk >90% capacity" and clarifications to "90% (aligns with SC-008)"
**Rationale**: 90% matches tasks.md T211 implementation
**Impact**: Consistent alert configuration

### I2 - Circular VM→KeyVault Dependency (HIGH) ✅ FIXED
**Location**: tasks.md Phase 3 (VM) and Phase 4 (KeyVault)
**Issue**:
- Phase 3 deploys VM with placeholder password
- Phase 4 deploys KeyVault with real password
- Phase 4 updates VM to use KeyVault password
- **Problem**: VM needs password at creation time, but KeyVault doesn't exist yet

**Resolution**: Restructured phases - moved KeyVault deployment to Phase 2 (Foundational)
- **New Flow**:
  1. Phase 2: Deploy KeyVault with random_password secret (tasks T039a-T039r)
  2. Phase 3: Deploy VM referencing KeyVault secret directly (updated T094, T102)
  3. Phase 4: Deploy Bastion only (removed KeyVault tasks T118-T138)
- Updated dependency flow documentation
- Added notes explaining architectural decision

**Impact**: Eliminated circular dependency, enables atomic deployments

### I1 - Alert Notification Conflict (HIGH) ✅ FIXED
**Location**: spec.md Clarifications vs tasks.md T205
**Issue**:
- Clarifications: "Azure Portal notifications only"
- Tasks T205: Configure email_receiver with email address

**Resolution**: Updated clarifications to allow email notifications via Action Group with justification: "Azure Portal notifications insufficient for production alerting"
**Rationale**: Email notifications are standard practice for critical alerts in production systems
**Impact**: Aligns spec with implementation, enables proper alerting

### U4 - VM Password Deployment Flow Unclear (HIGH) ✅ DOCUMENTED
**Location**: tasks.md T094, plan.md
**Issue**: Placeholder comment in T094 ("use placeholder for now") created ambiguity about VM deployment approach
**Resolution**:
- Removed placeholder comment from T094
- Updated T094 to directly reference KeyVault: "module.key_vault.secrets[var.vm_admin_secret_name].value from Phase 2"
- Updated T102 with explicit depends_on: [module.key_vault]
- Added architectural notes in Phase 2 and Phase 3 headers explaining KeyVault-first approach

**Impact**: Clear deployment flow documented, no ambiguity

---

## Medium/Low Issues - DOCUMENTED (Not Blocking)

### AzAPI Compliance (MEDIUM) - Resolved
**Finding C1**: Tasks T204-T218 originally used direct AzureRM resources for alerts instead of AVM modules or AzAPI
**Finding C2**: Tasks T084-T086 originally used direct AzureRM subnet associations
**Resolution**:
- C1: Use AzAPI `Microsoft.Insights/actionGroups` and `Microsoft.Insights/metricAlerts` resources.
- C2: Use the VNet module's subnet network security group input.

### Terminology Drift (MEDIUM) - Accepted
**Finding I3**: "NetBIOS name" (spec) vs "computer name" (tasks) used interchangeably
**Decision**: Both terms are technically accurate - "computer name" is primary, "NetBIOS name" used for context about 15-char limit
**Action**: No change required - terminology is clear in context

### Resource Group Naming (MEDIUM) ✅ FIXED
**Finding I6**: IC-005 referenced "rg-my-legacy-workload-prod-wus3", tasks use "rg-avmlegacy-prod-wus3"
**Resolution**: Updated IC-005 to use "rg-avmlegacy-prod-wus3" with note about "workload short name"
**Rationale**: Shorter name, consistent with naming convention throughout

### Phase 0 Research Tasks (MEDIUM) - Documented
**Finding COV7**: Plan describes 8 Phase 0 research tasks but tasks.md starts at Phase 1
**Resolution**: Added note to tasks.md header: "Phase 0 research tasks (8 tasks from plan.md) are offline prerequisites"
**Rationale**: Phase 0 is research/discovery phase completed before code implementation begins

### Missing Coverage for FR-022 (MEDIUM) - Accepted
**Finding COV4**: FR-022 requires "rich comments" in Terraform files but no explicit task
**Decision**: Implicit in all "Implement" and "Configure" tasks - developers add comments during implementation
**Action**: No task added - standard development practice

### Duplication of Validation Tasks (LOW) - Accepted
**Findings D2, D3**: "terraform fmt" and "terraform validate" repeated in every phase
**Decision**: Intentional repetition for phase independence - each phase can be validated independently
**Action**: No change - accepted duplication for workflow clarity

### Ambiguous AVM Module Name (MEDIUM) - Documented
**Finding A3**: Plan states alerting module name is "TBD"
**Resolution**: Tasks T204-T218 use direct AzAPI action group and metric alert resources
**Action**: Phase 0 research should confirm no AVM module exists

### Ambiguous Bastion SKU Selection (MEDIUM) - Documented
**Finding A6**: T142 says "Basic or Standard based on Phase 0 research"
**Decision**: Acceptable - Phase 0 research will determine SKU based on cost/features
**Action**: No change - research task will resolve

---

## Metrics

- **Total Requirements**: 25 Functional + 14 Security + 10 Infrastructure = 49
- **Total Tasks**: 269 (including new Phase 2 KeyVault tasks T039a-T039r)
- **Coverage %**: 98% (48/49 requirements have tasks)
- **Constitution Violations**: 0 (2 documented exceptions with justifications)
- **Blocking Issues**: 0 (all resolved)
- **Ambiguity Count**: 6 (4 resolved, 2 documented as acceptable)
- **Critical Issues Resolved**: 2/2
- **High Issues Resolved**: 4/4

---

## Coverage Summary

| Requirement | Coverage | Tasks | Notes |
|-------------|----------|-------|-------|
| FR-001 through FR-021 | ✅ Full | Multiple | All functional requirements covered |
| FR-022 (rich comments) | ⚠️ Implicit | None | Standard practice during implementation |
| FR-023 through FR-025 | ✅ Full | Multiple | All covered |
| SEC-001 through SEC-014 | ✅ Full | Multiple | All security requirements covered |
| IC-001 through IC-010 | ✅ Full | Multiple | All infrastructure constraints addressed |

**Weak Coverage**: FR-022 only (implicit in development tasks)

---

## Constitutional Alignment

### Principle I: Terraform-First ✅
All resources defined in Terraform. No violations.

### Principle II: AVM-First with AzAPI Fallback ✅
- Metric alerts (T204-T218) use AzAPI because no AVM metric alert module is available.
- NSG associations (T084-T086) use the VNet module interface.

### Principle III: Security & Reliability ✅
All security requirements met:
- Managed identities configured
- Secrets in KeyVault (Phase 2)
- NSGs with deny-by-default
- Diagnostic logging enabled
- Resource locks applied
- No hardcoded secrets

### Principle IV: Single-Template Pattern ✅
All resources in single root module. No violations.

### Principle V: Validation-First ✅
Validation gates enforced at every phase: fmt → validate → plan → review → apply

---

## Architectural Decisions

### Decision 1: KeyVault in Foundational Phase
**Rationale**: Eliminates circular dependency - VM requires password at creation time
**Impact**: Cleaner deployment flow, atomic infrastructure provisioning
**Trade-off**: KeyVault deployed before VM (minor cost if VM deployment fails)
**Benefit**: Simplified task sequencing, reduced error scenarios

### Decision 2: Direct AzAPI Resources for Alerts
**Rationale**: No AVM module available for metric alerts
**Impact**: Remains compliant without introducing AzureRM
**Validation**: Phase 0 research confirms no suitable AVM module
**Risk**: Minimal - metric alert resources are stable and well-documented

### Decision 3: Email Notifications for Production Alerts
**Rationale**: Portal-only notifications insufficient for production critical alerts
**Impact**: Updated spec clarifications to allow Action Group with email
**Best Practice**: Industry standard for production alerting

---

## Recommendations

### Before Implementation
1. ✅ **COMPLETED**: Resolve all CRITICAL/HIGH issues
2. ✅ **COMPLETED**: Document constitution exceptions
3. ✅ **COMPLETED**: Clarify deployment flow for KeyVault and VM
4. **REQUIRED**: Complete Phase 0 research tasks (8 tasks from plan.md)
  - Verify AVM module versions and interfaces
  - Confirm no AVM module exists for alerts
  - Verify VNet module NSG association capability
  - Document findings in research.md

### During Implementation
1. Follow phase sequence: Phase 0 (offline) → Phase 1 → Phase 2 (with KeyVault) → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7
2. Add rich comments to Terraform files (FR-022) during implementation
3. Validate between phases: fmt → validate → plan → review → apply
4. Run security scans (tfsec, checkov) before final deployment

### Post-Implementation
1. Verify all 13 success criteria (SC-001 through SC-013)
2. Document actual deployment time and cost
3. Test complete teardown and redeploy workflow
4. Archive analysis report with deployment artifacts

---

## Conclusion

**Status**: ✅ **READY FOR IMPLEMENTATION**

All blocking issues have been resolved:
- Task count corrected
- Subnet CIDR conflicts eliminated
- Alert threshold standardized
- Circular dependency removed via architectural restructuring
- Alert notifications aligned
- Deployment flow documented

The specification is internally consistent, fully traced to requirements, and compliant with constitution principles. Implementation can proceed through Phase 0 research followed by sequential phase execution.

**Estimated Implementation Time**: 20-30 hours
**Estimated Deployment Time**: ~30 minutes
**Estimated Monthly Cost**: <$200

---

## Appendix: All Findings

### Duplication (3 findings)
- D1: Secret name in FR-016 and SEC-002 (MEDIUM - accepted)
- D2: Repeated terraform fmt tasks (LOW - accepted for phase independence)
- D3: Repeated terraform validate tasks (LOW - accepted for phase independence)

### Ambiguity (6 findings)
- A1: Subnet CIDR conflict (HIGH - ✅ FIXED)
- A2: Disk alert threshold conflict (HIGH - ✅ FIXED)
- A3: AVM module name TBD (MEDIUM - resolved by using AzAPI)
- A4: Zone "-1" unclear (LOW - documentation wording improvement)
- A5: Storage account verification criteria missing (MEDIUM - accepted)
- A6: Bastion SKU selection unclear (MEDIUM - Phase 0 research will resolve)

### Underspecification (5 findings)
- U1: Deny-by-default not measurable (MEDIUM - tasks specify priority 4096 Deny rule)
- U2: Alert notification target unclear (MEDIUM - ✅ FIXED via email config)
- U3: Placeholder password not specified (MEDIUM - ✅ RESOLVED via Phase 2 KeyVault)
- U4: VM password deployment flow unclear (HIGH - ✅ DOCUMENTED)
- U5: Phase 0 tasks in plan but not tasks.md (MEDIUM - documented as prerequisite)

### Constitution (2 findings)
- C1: Direct AzureRM for alerts (MEDIUM - resolved with AzAPI)
- C2: Direct AzureRM for NSG associations (LOW - resolved with the VNet module interface)

### Coverage (7 findings)
- COV1: Task count mismatch (CRITICAL - ✅ FIXED)
- COV2: "At least 3 subnets" vs exactly 3 (MEDIUM - acceptable)
- COV3: NSG flow log verification missing (MEDIUM - manual verification acceptable)
- COV4: FR-022 no explicit task (MEDIUM - implicit in implementation)
- COV5: data-model.md creation not tasked (LOW - marked optional)
- COV6: quickstart.md creation not tasked (LOW - marked optional)
- COV7: research.md tasks not in tasks.md (LOW - documented as Phase 0 prerequisite)

### Inconsistency (7 findings)
- I1: Alert notification method conflict (HIGH - ✅ FIXED)
- I2: Circular VM→KeyVault dependency (HIGH - ✅ FIXED via restructure)
- I3: NetBIOS vs computer name terminology (MEDIUM - accepted)
- I4: terraform.tf vs versions.tf file naming (MEDIUM - acceptable variation)
- I5: random_string naming inconsistency (LOW - standardized in tasks)
- I6: Resource group naming mismatch (MEDIUM - ✅ FIXED)
- I7: Phase 0 in plan but not tasks (MEDIUM - documented as prerequisite)

### Traceability (1 finding)
- T1: Missing requirement IDs on tasks (MEDIUM - User Story labels present, requirement IDs optional enhancement)

---

**Report Generated**: 2026-02-18
**Next Action**: Begin Phase 0 research (offline prerequisite tasks)
**Ready for**: `/speckit.implement` command after Phase 0 completion
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture your analysis report, with a comment of something like Analysis report generated.

8. Implement

Spec Kit uses /speckit.implement to generate the solution template file along with its parameters file. The prompt doesn’t require any specific inputs as it analyzes the existing plan and tasks to generate the implementation code.

Info

To learn more about the implement step, see the Implement chapter in the Spec Kit article.

  1. Run the following prompt to generate the implementation for our example:
​
/speckit.implement
➕ Expand to see the results

During the Implement phase, Copilot acts based on the tasks.md file (checkboxes next to each completed task get marked with [X]). It validates all previously created checklists, such as the implementation-readiness.md, requirements.md files. As a result of this prompt execution, a number of files get generated, such as:main.bicep, main.bicepparam, bicepconfig.json, .gitignore

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​
Specify Bootstrap
Specify Bootstrap
Specify Bootstrap
metadata name = 'Legacy VM Workload Infrastructure'
metadata description = 'Bicep template for deploying a legacy Windows Server 2016 VM workload with secure access, storage, and monitoring'
metadata owner = 'Infrastructure Team'

targetScope = 'resourceGroup'

// ============================================================================
// PARAMETERS
// ============================================================================

@description('Azure region for resource deployment')
param location string = 'westus3'

@description('Virtual machine size (SKU)')
param vmSize string = 'Standard_D2s_v3'

@description('VM administrator username')
@minLength(1)
@maxLength(20)
param vmAdminUsername string = 'vmadmin'

@description('Key Vault secret name for VM administrator password')
param vmAdminPasswordSecretName string = 'vm-admin-password'

@description('Availability zone for zone-capable resources (1, 2, or 3)')
@allowed([1, 2, 3])
param availabilityZone int = 1

@description('File share quota in GiB')
@minValue(100)
@maxValue(102400)
param fileShareQuotaGiB int = 1024

@description('Log Analytics workspace data retention in days')
@minValue(30)
@maxValue(730)
param logAnalyticsRetentionDays int = 30

@description('Deployment timestamp for password generation uniqueness')
param deploymentTime string = utcNow('u')

// ============================================================================
// VARIABLES
// ============================================================================

// Generate unique suffix for resource naming (6 characters from resource group ID)
var suffix = substring(uniqueString(resourceGroup().id), 0, 6)

// Generate VM administrator password using multiple seeds for uniqueness
// NOTE: Using deploymentTime parameter makes deployment non-idempotent - password regenerates each deploy.
// Acceptable for initial deployment; remove deploymentTime parameter for idempotent redeployments
var vmPassword = 'P@ssw0rd!${uniqueString(resourceGroup().id, deployment().name, deploymentTime)}'

// Resource naming following pattern: {resourceType}-{purpose}-{randomSuffix}
var vnetName = 'vnet-legacyvm-${suffix}'
var vmName = 'vm-legacyvm-${suffix}'
var kvName = 'kv-legacyvm-${suffix}'
var lawName = 'law-legacyvm-${suffix}'
// Storage account: no hyphens, lowercase only, max 24 chars
var stName = 'st${replace(suffix, '-', '')}'
var bastionName = 'bas-legacyvm-${suffix}'
var natGatewayName = 'nat-legacyvm-${suffix}'

// NSG names for each subnet
var nsgVmName = 'nsg-vm-legacyvm-${suffix}'
var nsgBastionName = 'nsg-bastion-legacyvm-${suffix}'
var nsgPeName = 'nsg-pe-legacyvm-${suffix}'

// Private DNS zone and endpoint names
var privateDnsZoneName = 'privatelink.file.${environment().suffixes.storage}'
var privateEndpointName = 'pe-file-legacyvm-${suffix}'

// Alert names
var alertVmStoppedName = 'alert-vm-stopped-legacyvm-${suffix}'
var alertDiskSpaceName = 'alert-disk-space-legacyvm-${suffix}'
var alertKvFailureName = 'alert-kv-access-fail-legacyvm-${suffix}'

// VM computer name (NetBIOS limit: 15 characters max)
var computerName = 'vm-${substring(suffix, 0, 6)}'

// Network configuration
var vnetAddressPrefix = '10.0.0.0/24'
var subnetVmAddressPrefix = '10.0.0.0/27'
var subnetBastionAddressPrefix = '10.0.0.64/26'
var subnetPeAddressPrefix = '10.0.0.128/27'

// Tags for all resources
var tags = {
  workload: 'legacy-vm'
  environment: 'production'
  compliance: 'legacy-retention'
  managedBy: 'bicep-avm'
}

// ============================================================================
// PHASE 2: FOUNDATIONAL RESOURCES
// ============================================================================

// Log Analytics Workspace - centralized logging for all resources
module logAnalytics 'br/public:avm/res/operational-insights/workspace:0.15.0' = {
  name: 'deploy-log-analytics'
  params: {
    name: lawName
    location: location
    dataRetention: logAnalyticsRetentionDays
    tags: tags
  }
}

// ============================================================================
// PHASE 3: USER STORY 1 - CORE VM INFRASTRUCTURE
// ============================================================================

// Virtual Network with 3 subnets (VM, Bastion, Private Endpoint)
module virtualNetwork 'br/public:avm/res/network/virtual-network:0.7.2' = {
  name: 'deploy-vnet'
  params: {
    name: vnetName
    location: location
    addressPrefixes: [vnetAddressPrefix]
    subnets: [
      {
        name: 'snet-vm-legacyvm-${suffix}'
        addressPrefix: subnetVmAddressPrefix
        networkSecurityGroupResourceId: nsgVm.outputs.resourceId
        natGatewayResourceId: natGateway.outputs.resourceId
      }
      {
        name: 'AzureBastionSubnet' // Required name for Bastion
        addressPrefix: subnetBastionAddressPrefix
        networkSecurityGroupResourceId: nsgBastion.outputs.resourceId
      }
      {
        name: 'snet-pe-legacyvm-${suffix}'
        addressPrefix: subnetPeAddressPrefix
        networkSecurityGroupResourceId: nsgPe.outputs.resourceId
      }
    ]
    diagnosticSettings: [
      {
        workspaceResourceId: logAnalytics.outputs.resourceId
        logCategoriesAndGroups: [
          {
            categoryGroup: 'allLogs'
          }
        ]
        metricCategories: [
          {
            category: 'AllMetrics'
          }
        ]
      }
    ]
    tags: tags
  }
}

// Virtual Machine - Windows Server 2016
module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.21.0' = {
  name: 'deploy-vm'
  params: {
    name: vmName
    location: location
    computerName: computerName
    vmSize: vmSize
    availabilityZone: availabilityZone
    osType: 'Windows'
    imageReference: {
      publisher: 'MicrosoftWindowsServer'
      offer: 'WindowsServer'
      sku: '2016-Datacenter'
      version: 'latest'
    }
    osDisk: {
      createOption: 'FromImage'
      deleteOption: 'Delete'
      diskSizeGB: 127
      managedDisk: {
        storageAccountType: 'Standard_LRS' // HDD performance tier
      }
    }
    dataDisks: [
      {
        name: '${vmName}-datadisk-01'
        diskSizeGB: 500
        lun: 0
        createOption: 'Empty'
        managedDisk: {
          storageAccountType: 'Standard_LRS' // HDD performance tier
        }
      }
    ]
    adminUsername: vmAdminUsername
    adminPassword: vmPassword
    managedIdentities: {
      systemAssigned: true
    }
    nicConfigurations: [
      {
        name: '${vmName}-nic'
        ipConfigurations: [
          {
            name: 'ipconfig1'
            subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0]
            privateIPAllocationMethod: 'Dynamic'
          }
        ]
      }
    ]
    bootDiagnostics: true
    tags: tags
  }
}

// ============================================================================
// PHASE 4: USER STORY 2 - SECURE STORAGE AND DATA DISK
// ============================================================================

// Storage Account with file share
module storageAccount 'br/public:avm/res/storage/storage-account:0.31.0' = {
  name: 'deploy-storage'
  params: {
    name: stName
    location: location
    kind: 'StorageV2'
    skuName: 'Standard_LRS'
    accessTier: 'Hot'
    publicNetworkAccess: 'Disabled'
    minimumTlsVersion: 'TLS1_2'
    fileServices: {
      shares: [
        {
          name: 'fileshare'
          shareQuota: fileShareQuotaGiB
          accessTier: 'TransactionOptimized'
        }
      ]
      diagnosticSettings: [
        {
          workspaceResourceId: logAnalytics.outputs.resourceId
          metricCategories: [
            {
              category: 'Transaction'
            }
          ]
        }
      ]
    }
    tags: tags
  }
}

// Private DNS Zone for storage private endpoint
module privateDnsZone 'br/public:avm/res/network/private-dns-zone:0.8.0' = {
  name: 'deploy-private-dns-zone'
  params: {
    name: privateDnsZoneName
    location: 'global'
    virtualNetworkLinks: [
      {
        name: '${vnetName}-link'
        virtualNetworkResourceId: virtualNetwork.outputs.resourceId
      }
    ]
    tags: tags
  }
}

// Private Endpoint for storage account file share
module privateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.1' = {
  name: 'deploy-private-endpoint'
  params: {
    name: privateEndpointName
    location: location
    subnetResourceId: virtualNetwork.outputs.subnetResourceIds[2] // PE subnet
    privateLinkServiceConnections: [
      {
        name: '${privateEndpointName}-connection'
        properties: {
          privateLinkServiceId: storageAccount.outputs.resourceId
          groupIds: ['file']
        }
      }
    ]
    privateDnsZoneGroup: {
      privateDnsZoneGroupConfigs: [
        {
          privateDnsZoneResourceId: privateDnsZone.outputs.resourceId
        }
      ]
    }
    tags: tags
  }
}

// ============================================================================
// PHASE 5: USER STORY 3 - SECURE ACCESS AND SECRETS MANAGEMENT
// ============================================================================

// Key Vault for storing VM admin password (created first, VM identity assigned later)
module keyVault 'br/public:avm/res/key-vault/vault:0.13.3' = {
  name: 'deploy-keyvault'
  params: {
    name: kvName
    location: location
    sku: 'standard'
    enableRbacAuthorization: true
    softDeleteRetentionInDays: 90
    enablePurgeProtection: false // Not required for legacy workload
    publicNetworkAccess: 'Enabled' // Simplified for legacy workload
    secrets: [
      {
        name: vmAdminPasswordSecretName
        value: vmPassword
        contentType: 'text/plain'
      }
    ]
    diagnosticSettings: [
      {
        workspaceResourceId: logAnalytics.outputs.resourceId
        logCategoriesAndGroups: [
          {
            categoryGroup: 'allLogs'
          }
        ]
        metricCategories: [
          {
            category: 'AllMetrics'
          }
        ]
      }
    ]
    tags: tags
  }
}

// RBAC Assignment: Grant VM managed identity access to Key Vault secrets
// Note: Using guid with static strings (names) instead of outputs for deployment-time calculation
resource kvRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(kvName, vmName, 'Key Vault Secrets User')
  scope: resourceGroup()
  properties: {
    principalId: virtualMachine.outputs.?systemAssignedMIPrincipalId!
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User
    principalType: 'ServicePrincipal'
  }
}

// Azure Bastion for secure RDP access
module bastion 'br/public:avm/res/network/bastion-host:0.8.2' = {
  name: 'deploy-bastion'
  params: {
    name: bastionName
    location: location
    skuName: 'Basic'
    virtualNetworkResourceId: virtualNetwork.outputs.resourceId
    diagnosticSettings: [
      {
        workspaceResourceId: logAnalytics.outputs.resourceId
        logCategoriesAndGroups: [
          {
            categoryGroup: 'allLogs'
          }
        ]
      }
    ]
    tags: tags
  }
}

// ============================================================================
// PHASE 6: USER STORY 4 - INTERNET CONNECTIVITY AND NETWORK SECURITY
// ============================================================================

// NAT Gateway for outbound internet connectivity
module natGateway 'br/public:avm/res/network/nat-gateway:2.0.1' = {
  name: 'deploy-nat-gateway'
  params: {
    name: natGatewayName
    location: location
    availabilityZone: availabilityZone
    publicIPAddresses: [
      {
        name: 'pip-nat-legacyvm-${suffix}'
      }
    ]
    tags: tags
  }
}

// Network Security Group for VM subnet
module nsgVm 'br/public:avm/res/network/network-security-group:0.5.2' = {
  name: 'deploy-nsg-vm'
  params: {
    name: nsgVmName
    location: location
    securityRules: [
      {
        name: 'AllowBastionRdpInbound'
        properties: {
          priority: 100
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: subnetBastionAddressPrefix
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '3389'
        }
      }
      {
        name: 'DenyAllInbound'
        properties: {
          priority: 4096
          direction: 'Inbound'
          access: 'Deny'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
      {
        name: 'AllowInternetOutbound'
        properties: {
          priority: 100
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: 'Internet'
          destinationPortRange: '*'
        }
      }
      {
        name: 'AllowVnetOutbound'
        properties: {
          priority: 200
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: 'VirtualNetwork'
          destinationPortRange: '*'
        }
      }
      {
        name: 'DenyAllOutbound'
        properties: {
          priority: 4096
          direction: 'Outbound'
          access: 'Deny'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
    ]
    diagnosticSettings: [
      {
        workspaceResourceId: logAnalytics.outputs.resourceId
        logCategoriesAndGroups: [
          {
            categoryGroup: 'allLogs'
          }
        ]
      }
    ]
    tags: tags
  }
}

// Network Security Group for Bastion subnet
module nsgBastion 'br/public:avm/res/network/network-security-group:0.5.2' = {
  name: 'deploy-nsg-bastion'
  params: {
    name: nsgBastionName
    location: location
    securityRules: [
      // Inbound rules
      {
        name: 'AllowHttpsInbound'
        properties: {
          priority: 100
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: 'Internet'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '443'
        }
      }
      {
        name: 'AllowGatewayManagerInbound'
        properties: {
          priority: 110
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: 'GatewayManager'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '443'
        }
      }
      {
        name: 'AllowAzureLoadBalancerInbound'
        properties: {
          priority: 120
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: 'AzureLoadBalancer'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '443'
        }
      }
      {
        name: 'AllowBastionHostCommunication'
        properties: {
          priority: 130
          direction: 'Inbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: 'VirtualNetwork'
          sourcePortRange: '*'
          destinationAddressPrefix: 'VirtualNetwork'
          destinationPortRanges: ['8080', '5701']
        }
      }
      // Outbound rules
      {
        name: 'AllowSshRdpOutbound'
        properties: {
          priority: 100
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: 'VirtualNetwork'
          destinationPortRanges: ['22', '3389']
        }
      }
      {
        name: 'AllowAzureCloudOutbound'
        properties: {
          priority: 110
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: 'AzureCloud'
          destinationPortRange: '443'
        }
      }
      {
        name: 'AllowBastionCommunication'
        properties: {
          priority: 120
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: 'VirtualNetwork'
          sourcePortRange: '*'
          destinationAddressPrefix: 'VirtualNetwork'
          destinationPortRanges: ['8080', '5701']
        }
      }
      {
        name: 'AllowGetSessionInformation'
        properties: {
          priority: 130
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: 'Internet'
          destinationPortRange: '80'
        }
      }
    ]
    diagnosticSettings: [
      {
        workspaceResourceId: logAnalytics.outputs.resourceId
        logCategoriesAndGroups: [
          {
            categoryGroup: 'allLogs'
          }
        ]
      }
    ]
    tags: tags
  }
}

// Network Security Group for Private Endpoint subnet
module nsgPe 'br/public:avm/res/network/network-security-group:0.5.2' = {
  name: 'deploy-nsg-pe'
  params: {
    name: nsgPeName
    location: location
    securityRules: [
      {
        name: 'AllowVMSubnetInbound'
        properties: {
          priority: 100
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourceAddressPrefix: subnetVmAddressPrefix
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '445'
        }
      }
      {
        name: 'DenyAllInbound'
        properties: {
          priority: 4096
          direction: 'Inbound'
          access: 'Deny'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
      {
        name: 'AllowAllOutbound'
        properties: {
          priority: 100
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
    ]
    diagnosticSettings: [
      {
        workspaceResourceId: logAnalytics.outputs.resourceId
        logCategoriesAndGroups: [
          {
            categoryGroup: 'allLogs'
          }
        ]
      }
    ]
    tags: tags
  }
}

// ============================================================================
// PHASE 7: USER STORY 5 - MONITORING AND ALERTING
// ============================================================================

// Alert: VM Stopped/Deallocated
module alertVmStopped 'br/public:avm/res/insights/metric-alert:0.4.1' = {
  name: 'deploy-alert-vm-stopped'
  params: {
    name: alertVmStoppedName
    location: 'global'
    targetResourceType: 'Microsoft.Compute/virtualMachines'
    targetResourceRegion: location
    scopes: [
      virtualMachine.outputs.resourceId
    ]
    criteria: {
      'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
      allof: [
        {
          criterionType: 'StaticThresholdCriterion'
          name: 'cpu-low-threshold'
          metricName: 'Percentage CPU'
          metricNamespace: 'Microsoft.Compute/virtualMachines'
          operator: 'LessThan'
          threshold: 1
          timeAggregation: 'Average'
        }
      ]
    }
    windowSize: 'PT15M'
    evaluationFrequency: 'PT5M'
    severity: 0
    enabled: true
    autoMitigate: false
    alertDescription: 'Critical: VM appears to be stopped or deallocated'
    tags: tags
  }
}

// Alert: Disk Space Exceeded 85%
module alertDiskSpace 'br/public:avm/res/insights/metric-alert:0.4.1' = {
  name: 'deploy-alert-disk-space'
  params: {
    name: alertDiskSpaceName
    location: 'global'
    targetResourceType: 'Microsoft.Compute/virtualMachines'
    targetResourceRegion: location
    scopes: [
      virtualMachine.outputs.resourceId
    ]
    criteria: {
      'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
      allof: [
        {
          criterionType: 'StaticThresholdCriterion'
          name: 'disk-space-high-threshold'
          metricName: 'Available Memory Bytes'
          metricNamespace: 'Microsoft.Compute/virtualMachines'
          operator: 'LessThan'
          threshold: 1073741824 // 1GB in bytes
          timeAggregation: 'Average'
        }
      ]
    }
    windowSize: 'PT5M'
    evaluationFrequency: 'PT1M'
    severity: 0
    enabled: true
    autoMitigate: false
    alertDescription: 'Critical: Available memory below 1GB threshold'
    tags: tags
  }
}

// Alert: Key Vault Access Failures
module alertKvFailure 'br/public:avm/res/insights/metric-alert:0.4.1' = {
  name: 'deploy-alert-kv-failure'
  params: {
    name: alertKvFailureName
    location: 'global'
    targetResourceType: 'Microsoft.KeyVault/vaults'
    targetResourceRegion: location
    scopes: [
      keyVault.outputs.resourceId
    ]
    criteria: {
      'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
      allof: [
        {
          criterionType: 'StaticThresholdCriterion'
          name: 'kv-access-failure-threshold'
          metricName: 'ServiceApiResult'
          metricNamespace: 'Microsoft.KeyVault/vaults'
          operator: 'GreaterThan'
          threshold: 0
          timeAggregation: 'Count'
        }
      ]
    }
    windowSize: 'PT5M'
    evaluationFrequency: 'PT1M'
    severity: 1
    enabled: true
    autoMitigate: false
    alertDescription: 'Warning: Key Vault API result failures detected'
    tags: tags
  }
}

// ============================================================================
// OUTPUTS
// ============================================================================

@description('Resource group location')
output location string = location

@description('Virtual Network resource ID')
output vnetResourceId string = virtualNetwork.outputs.resourceId

@description('Virtual Network name')
output vnetName string = virtualNetwork.outputs.name

@description('VM subnet resource ID')
output vmSubnetResourceId string = virtualNetwork.outputs.subnetResourceIds[0]

@description('Bastion subnet resource ID')
output bastionSubnetResourceId string = virtualNetwork.outputs.subnetResourceIds[1]

@description('Private Endpoint subnet resource ID')
output peSubnetResourceId string = virtualNetwork.outputs.subnetResourceIds[2]

@description('Virtual Machine resource ID')
output vmResourceId string = virtualMachine.outputs.resourceId

@description('Virtual Machine name')
output vmName string = virtualMachine.outputs.name

@description('VM private IP address - Note: Available after deployment, query via Azure Portal or CLI')
output vmPrivateIP string = ''

@description('VM system-assigned managed identity principal ID')
output vmManagedIdentityPrincipalId string = virtualMachine.outputs.?systemAssignedMIPrincipalId ?? ''

@description('Key Vault resource ID')
output keyVaultResourceId string = keyVault.outputs.?resourceId ?? ''

@description('Key Vault name')
output keyVaultName string = keyVault.outputs.?name ?? ''

@description('Storage Account resource ID')
output storageAccountResourceId string = storageAccount.outputs.resourceId

@description('Storage Account name')
output storageAccountName string = storageAccount.outputs.name

@description('File share name')
output fileShareName string = 'fileshare'

@description('Private Endpoint resource ID')
output privateEndpointResourceId string = privateEndpoint.outputs.resourceId

@description('Azure Bastion resource ID')
output bastionResourceId string = bastion.outputs.resourceId

@description('Azure Bastion name')
output bastionName string = bastion.outputs.name

@description('NAT Gateway resource ID')
output natGatewayResourceId string = natGateway.outputs.resourceId

@description('NAT Gateway name')
output natGatewayName string = natGateway.outputs.name

@description('Log Analytics Workspace resource ID')
output logAnalyticsResourceId string = logAnalytics.outputs.resourceId

@description('Log Analytics Workspace name')
output logAnalyticsName string = logAnalytics.outputs.name

@description('VM Stopped Alert resource ID')
output alertVmStoppedResourceId string = alertVmStopped.outputs.resourceId

@description('Disk Space Alert resource ID')
output alertDiskSpaceResourceId string = alertDiskSpace.outputs.resourceId

@description('Key Vault Access Failure Alert resource ID')
output alertKvFailureResourceId string = alertKvFailure.outputs.resourceId
using './main.bicep'

// ============================================================================
// DEPLOYMENT PARAMETERS
// ============================================================================

// Azure region for resource deployment
param location = 'westus3'

// Virtual machine configuration
param vmSize = 'Standard_D2s_v3'
param vmAdminUsername = 'vmadmin'
param vmAdminPasswordSecretName = 'vm-admin-password'
param availabilityZone = 1

// Storage configuration
param fileShareQuotaGiB = 1024

// Monitoring configuration
param logAnalyticsRetentionDays = 30
Specify Bootstrap
/speckit.implement
➕ Expand to see the results

During the Implement phase, Copilot acts based on the tasks.md file (checkboxes next to each completed task get marked with [X]). It validates all previously created checklists, such as the implementation-readiness.md, requirements.md or plan-review.md files. As a result of this prompt execution, a number of files get generated, such as:main.tf, variables.tf, terraform.tf, terraform.tfvars, outputs.tf, .gitignore, .terraform.lock.hcl

Note: You may get different results, depending on a number of factors such as your inputs, LLM of choice, the current state of the Spec Kit, etc. This example is meant to give you an idea of what the outputs of this step typically look like.

Click through the tabs to see the details!

​
Specify Bootstrap
# =============================================================================
# main.tf — My Legacy Workload
#
# Provisions a single-VM Windows Server 2016 workload in West US 3 with:
#   - Isolated network (VNet, 3 subnets, 3 NSGs, NAT gateway, Azure Bastion)
#   - Key Vault (RBAC, no public access) storing the VM admin password
#   - Windows VM (no public IP, write-only password via Terraform 1.10+)
#   - Storage account (private endpoint via Azure Files, TLS 1.2)
#   - Log Analytics workspace receiving all diagnostic logs/metrics
#   - Three Azure Monitor alert rules (VM stopped, disk full, KV failures)
#
# All AVM modules use enable_telemetry = false per project policy.
# Contracts in specs/001-my-legacy-workload/contracts/ are authoritative for
# module versions and argument shapes.
# =============================================================================

# ─── Locals ──────────────────────────────────────────────────────────────────

locals {
  # CAF resource names — static (workload + env + region + instance suffix)
  resource_group_name   = "rg-${var.workload}-${var.environment}-westus3-001"
  vnet_name             = "vnet-${var.workload}-${var.environment}-westus3-001"
  nsg_vm_name           = "nsg-${var.workload}-vm-${var.environment}-westus3-001"
  nsg_bastion_name      = "nsg-${var.workload}-bastion-${var.environment}-westus3-001"
  nsg_pe_name           = "nsg-${var.workload}-pe-${var.environment}-westus3-001"
  nat_gateway_name      = "ng-${var.workload}-${var.environment}-westus3-001"
  nat_gw_pip_name       = "pip-ng-${var.workload}-${var.environment}-westus3-001"
  bastion_name          = "bas-${var.workload}-${var.environment}-westus3-001"
  vm_name               = "vm-${var.workload}-${var.environment}-westus3-001"
  vm_nic_name           = "nic-${var.workload}-${var.environment}-westus3-001"
  vm_os_disk_name       = "osdisk-${var.workload}-${var.environment}-westus3-001"
  vm_data_disk_name     = "datadisk-${var.workload}-${var.environment}-westus3-001"
  log_analytics_name    = "law-${var.workload}-${var.environment}-westus3-001"
  private_dns_zone_name = "privatelink.file.core.windows.net"
  pe_storage_name       = "pep-${var.workload}-file-${var.environment}-westus3-001"

  # Alert names (no region suffix — alerts scope to resource, not region)
  alert_vm_stopped_name  = "alert-${var.workload}-vm-stopped-${var.environment}"
  alert_disk_full_name   = "alert-${var.workload}-disk-full-${var.environment}"
  alert_kv_failures_name = "alert-${var.workload}-kv-failures-${var.environment}"

  # Globally-unique names (6-char random suffix appended)
  # Key Vault: region token OMITTED to stay within the 24-character KV name limit
  key_vault_name       = "kv-${var.workload}-${var.environment}-${random_string.unique_suffix.result}"
  storage_account_name = "st${var.workload}${var.environment}${random_string.unique_suffix.result}"

  # Common tag map: base tags merged with mandatory workload/env/managedBy/region labels
  common_tags = merge(var.tags, {
    workload    = var.workload
    environment = var.environment
    managedBy   = "Terraform"
    region      = var.location
  })
}

# ─── Data Sources ─────────────────────────────────────────────────────────────

# Required to obtain tenant_id for Key Vault RBAC authorization
data "azapi_client_config" "current" {}

# ─── Random Resources ────────────────────────────────────────────────────────

# 6-char lowercase alphanumeric suffix to make storage and KV names globally unique
resource "random_string" "unique_suffix" {
  length  = 6
  special = false
  upper   = false
}

# VM local admin password — 20 chars with mixed complexity.
# IMPORTANT: random_password.result IS stored in Terraform state (unavoidable
# for generated values).  The value is ALSO written to Key Vault via the KV
# module secrets_value argument.  The VM resource itself uses a write-only
# argument (Terraform 1.10+) so the password does NOT appear in the virtual
# machine resource state.  See SC-003.
resource "random_password" "vm_admin_password" {
  length           = 20
  special          = true
  override_special = "!@#$%^&*()"
  min_lower        = 2
  min_upper        = 2
  min_numeric      = 2
  min_special      = 2
}

# ─── Foundation ──────────────────────────────────────────────────────────────

# Single production resource group — all workload resources land here
module "resource_group" {
  source  = "Azure/avm-res-resources-resourcegroup/azurerm"
  version = "0.2.2"

  name             = local.resource_group_name
  location         = var.location
  tags             = local.common_tags
  enable_telemetry = false
}

# Log Analytics workspace — MUST be created first; all diagnostic settings
# reference module.log_analytics_workspace.resource_id.
module "log_analytics_workspace" {
  source  = "Azure/avm-res-operationalinsights-workspace/azurerm"
  version = "0.5.1"

  name                = local.log_analytics_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name

  log_analytics_workspace_sku               = "PerGB2018"
  log_analytics_workspace_retention_in_days = var.log_analytics_retention_days

  tags             = local.common_tags
  enable_telemetry = false

  depends_on = [module.resource_group]
}

# ─── Networking (US1) ────────────────────────────────────────────────────────

# NSG for VM subnet — permits RDP only from Azure Bastion subnet CIDR (FR-003)
module "nsg_vm" {
  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
  version = "0.5.1"

  name                = local.nsg_vm_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name

  security_rules = {
    # Allow RDP only from Bastion subnet — no direct RDP from internet
    allow_rdp_from_bastion = {
      name                       = "Allow-RDP-From-BastionSubnet"
      priority                   = 100
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_address_prefix      = var.subnet_bastion_cidr
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "3389"
    }
    deny_all_inbound = {
      name                       = "Deny-All-Inbound"
      priority                   = 4096
      direction                  = "Inbound"
      access                     = "Deny"
      protocol                   = "*"
      source_address_prefix      = "*"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "*"
    }
  }

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false
}

# NSG for Azure Bastion subnet — minimum required rules for Bastion Standard SKU
# See: https://learn.microsoft.com/azure/bastion/bastion-nsg
module "nsg_bastion" {
  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
  version = "0.5.1"

  name                = local.nsg_bastion_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name

  security_rules = {
    # Inbound: HTTPS from Internet (portal connectivity)
    allow_https_inbound = {
      name                       = "Allow-HTTPS-Internet-Inbound"
      priority                   = 100
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_address_prefix      = "Internet"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "443"
    }
    # Inbound: Azure Gateway Manager control plane traffic
    allow_gateway_manager = {
      name                       = "Allow-GatewayManager-Inbound"
      priority                   = 110
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_address_prefix      = "GatewayManager"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "443"
    }
    # Inbound: Azure Load Balancer health probe
    allow_azure_lb = {
      name                       = "Allow-AzureLoadBalancer-Inbound"
      priority                   = 120
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_address_prefix      = "AzureLoadBalancer"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "443"
    }
    # Inbound: Bastion host-to-host communication (data plane)
    allow_bastion_host_comm = {
      name                       = "Allow-BastionHostComm-Inbound"
      priority                   = 130
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "*"
      source_address_prefix      = "VirtualNetwork"
      source_port_range          = "*"
      destination_address_prefix = "VirtualNetwork"
      destination_port_ranges    = toset(["8080", "5701"])
    }
    deny_all_inbound = {
      name                       = "Deny-All-Inbound"
      priority                   = 4096
      direction                  = "Inbound"
      access                     = "Deny"
      protocol                   = "*"
      source_address_prefix      = "*"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "*"
    }
    # Outbound: SSH/RDP sessions to target VMs in the VNet
    allow_ssh_rdp_outbound = {
      name                       = "Allow-SSH-RDP-Outbound"
      priority                   = 100
      direction                  = "Outbound"
      access                     = "Allow"
      protocol                   = "*"
      source_address_prefix      = "*"
      source_port_range          = "*"
      destination_address_prefix = "VirtualNetwork"
      destination_port_ranges    = toset(["22", "3389"])
    }
    # Outbound: Azure Cloud endpoints (telemetry, diagnostics)
    allow_azure_cloud_outbound = {
      name                       = "Allow-AzureCloud-Outbound"
      priority                   = 110
      direction                  = "Outbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_address_prefix      = "*"
      source_port_range          = "*"
      destination_address_prefix = "AzureCloud"
      destination_port_range     = "443"
    }
    # Outbound: Bastion host-to-host communication (data plane)
    allow_bastion_comm_outbound = {
      name                       = "Allow-BastionComm-Outbound"
      priority                   = 120
      direction                  = "Outbound"
      access                     = "Allow"
      protocol                   = "*"
      source_address_prefix      = "VirtualNetwork"
      source_port_range          = "*"
      destination_address_prefix = "VirtualNetwork"
      destination_port_ranges    = toset(["8080", "5701"])
    }
    # Outbound: Session info retrieval (required by Bastion control plane)
    allow_get_session_info_outbound = {
      name                       = "Allow-GetSessionInfo-Outbound"
      priority                   = 130
      direction                  = "Outbound"
      access                     = "Allow"
      protocol                   = "*"
      source_address_prefix      = "*"
      source_port_range          = "*"
      destination_address_prefix = "Internet"
      destination_port_ranges    = toset(["80", "443"])
    }
  }

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false
}

# NSG for private-endpoint subnet — allows HTTPS from VNet only
module "nsg_pe" {
  source  = "Azure/avm-res-network-networksecuritygroup/azurerm"
  version = "0.5.1"

  name                = local.nsg_pe_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name

  security_rules = {
    allow_https_from_vnet = {
      name                       = "Allow-HTTPS-From-VNet"
      priority                   = 100
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_address_prefix      = "VirtualNetwork"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "443"
    }
    deny_all_inbound = {
      name                       = "Deny-All-Inbound"
      priority                   = 4096
      direction                  = "Inbound"
      access                     = "Deny"
      protocol                   = "*"
      source_address_prefix      = "*"
      source_port_range          = "*"
      destination_address_prefix = "*"
      destination_port_range     = "*"
    }
  }

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false
}

# NAT gateway — provides controlled outbound internet access for the VM subnet.
# The VM subnet has no public IP route other than this gateway (FR-011, FR-016).
# StandardV2 SKU required for zone-redundant public IP behaviour.
module "nat_gateway" {
  source  = "Azure/avm-res-network-natgateway/azurerm"
  version = "0.3.2"

  name      = local.nat_gateway_name
  location  = module.resource_group.location
  parent_id = module.resource_group.resource_id

  sku_name = "StandardV2"

  # Allocate a static Standard public IP for outbound SNAT
  public_ips = {
    nat_gw_pip = {
      name = local.nat_gw_pip_name
    }
  }

  # StandardV2 SKU requires all 3 zones (module precondition enforces this)
  public_ip_configuration = {
    nat_gw_pip = {
      allocation_method       = "Static"
      sku                     = "StandardV2"
      idle_timeout_in_minutes = 4
      zones                   = ["1", "2", "3"]
    }
  }

  # NOTE: Diagnostic settings are NOT applied to the NAT gateway — the Azure
  # Insights API for Microsoft.Network/natGateways diagnostic sub-resources does
  # not respond in westus3, causing a perpetual timeout.  NAT gateway byte/packet
  # metrics remain viewable in Azure Monitor without an explicit diagnostic setting.

  tags             = local.common_tags
  enable_telemetry = false
}

# Virtual network — three subnets, each bound to its own NSG.
# NOTE: module version 0.17.1 (contracts/virtual-network.md is authoritative).
# pe_subnet: private_endpoint_network_policies = "Enabled" is required for
# private endpoint policies to function correctly in this subnet.
module "virtual_network" {
  source  = "Azure/avm-res-network-virtualnetwork/azurerm"
  version = "0.17.1"

  name          = local.vnet_name
  location      = module.resource_group.location
  parent_id     = module.resource_group.resource_id
  address_space = var.vnet_address_space

  subnets = {
    # AzureBastionSubnet: name must be exactly "AzureBastionSubnet" (Azure requirement)
    bastion_subnet = {
      name             = "AzureBastionSubnet"
      address_prefixes = [var.subnet_bastion_cidr]
      network_security_group = {
        id = module.nsg_bastion.resource_id
      }
    }
    # VM subnet: outbound via NAT gateway only (no default outbound access)
    vm_subnet = {
      name             = "snet-${var.workload}-vm-${var.environment}-westus3-001"
      address_prefixes = [var.subnet_vm_cidr]
      network_security_group = {
        id = module.nsg_vm.resource_id
      }
      nat_gateway = {
        id = module.nat_gateway.resource_id
      }
      default_outbound_access_enabled = false
    }
    # Private-endpoint subnet: policies enabled so NSG rules apply to PE traffic
    pe_subnet = {
      name             = "snet-${var.workload}-pe-${var.environment}-westus3-001"
      address_prefixes = [var.subnet_pe_cidr]
      network_security_group = {
        id = module.nsg_pe.resource_id
      }
      private_endpoint_network_policies = "Enabled"
      default_outbound_access_enabled   = false
    }
  }

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false
}

# Azure Bastion Standard SKU — Standard is required for NSG compatibility and
# tunneling support (FR-024).  No file copy (disabled for security).
module "bastion" {
  source  = "Azure/avm-res-network-bastionhost/azurerm"
  version = "0.9.0"

  name      = local.bastion_name
  location  = module.resource_group.location
  parent_id = module.resource_group.resource_id

  sku = "Standard"

  ip_configuration = {
    name                   = "ipconfig-${local.bastion_name}"
    subnet_id              = module.virtual_network.subnets["bastion_subnet"].resource_id
    create_public_ip       = true
    public_ip_address_name = "pip-${local.bastion_name}"
  }

  # westus3 does not support Azure Bastion with Availability Zones
  # (BastionRegionAzNotSupported) — override module default ["1","2","3"]
  zones = []

  # Standard SKU features — tunneling enables native client (SSH/RDP) connectivity
  copy_paste_enabled = true
  tunneling_enabled  = true
  file_copy_enabled  = false # File copy disabled for security hardening

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false

  depends_on = [module.virtual_network]
}

# ─── Key Vault + VM (US2) ────────────────────────────────────────────────────

# Key Vault — RBAC authorization only (FR-019); legacy Access Policies disabled.
# Public network access is disabled; no private endpoint required for this
# workload (deployment agent accesses KV over service tags).
# The VM admin password is generated by random_password and written here via
# secrets_value.  It is NOT read back from KV state — the VM write-only
# argument receives the value directly from random_password.result (SC-003).
module "key_vault" {
  source  = "Azure/avm-res-keyvault-vault/azurerm"
  version = "0.10.2"

  name                = local.key_vault_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name
  tenant_id           = data.azapi_client_config.current.tenant_id

  sku_name = var.kv_sku

  # Enable public access so the deploy agent (local workstation) can write the
  # KV secret via the data plane.  Default action remains Deny — only the
  # deployer IP is explicitly allowed.  Private endpoints can be added later
  # to lock this down further for steady-state operations.
  public_network_access_enabled = true

  # Enforce deny-by-default network ACL; allow Azure services for diagnostics.
  # ip_rules: CIDR block for the deployment workstation — required because
  # 'public_network_access_enabled = false' would block ALL public traffic
  # including the Terraform runner (ForbiddenByConnection).
  network_acls = {
    bypass         = "AzureServices"
    default_action = "Deny"
    ip_rules       = ["174.127.190.39/32"]
  }

  # RBAC authorization is the default in this AVM module (legacy_access_policies_enabled
  # defaults to false).  Legacy Access Policies are explicitly prohibited (FR-019).

  # Grant the deploying principal permission to manage secrets during deployment.
  # Without this, Terraform cannot write the vm_admin_password secret (403 ForbiddenByRbac).
  role_assignments = {
    deploying_principal = {
      role_definition_id_or_name = "Key Vault Secrets Officer"
      principal_id               = data.azapi_client_config.current.object_id
    }
  }

  # Soft-delete enabled with 7-day retention; purge protection off to allow
  # clean teardown in non-prod (set true in regulated prod environments)
  soft_delete_retention_days = 7
  purge_protection_enabled   = false

  # Secret placeholder — value is supplied via secrets_value below
  secrets = {
    vm_admin_password = {
      name = var.vm_admin_password_secret_name
    }
  }

  # Sensitive value — random_password.result is stored in random_password state
  # and forwarded to KV; it does NOT appear in key_vault resource state
  secrets_value = {
    vm_admin_password = random_password.vm_admin_password.result
  }

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false
}

# Windows Server 2016 VM — no public IP assigned (FR-011, FR-013).
# Password is passed via write-only account_credentials argument (Terraform
# 1.10+ GA feature) — the value is applied to Azure but is NOT stored in the
# virtual machine resource state entry (SC-003).
module "virtual_machine" {
  source  = "Azure/avm-res-compute-virtualmachine/azurerm"
  version = "0.20.0"

  name                = local.vm_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name

  os_type       = "Windows"
  computer_name = var.vm_computer_name # NetBIOS name <= 15 chars per FR-013
  sku_size      = var.vm_sku_size
  zone          = tostring(var.vm_availability_zone) # zone must be string; var is number

  # OS image — Windows Server 2016 Datacenter (FR-007)
  source_image_reference = {
    publisher = var.vm_image_publisher
    offer     = var.vm_image_offer
    sku       = var.vm_image_sku
    version   = "latest"
  }

  # Write-only credentials — password NOT stored in VM resource state (SC-003)
  # generate_admin_password_or_ssh_key = false disables module auto-generation so
  # the custom random_password is used instead (required when supplying a password)
  account_credentials = {
    admin_credentials = {
      username                           = var.vm_admin_username
      password                           = random_password.vm_admin_password.result
      generate_admin_password_or_ssh_key = false
    }
  }

  # OS disk — Standard HDD (FR-008, FR-009)
  os_disk = {
    caching              = "ReadWrite"
    storage_account_type = var.vm_os_disk_type
    name                 = local.vm_os_disk_name
  }

  # Data disk — 500 GB Standard HDD at LUN 0 (FR-009)
  data_disk_managed_disks = {
    data_disk_0 = {
      name                 = local.vm_data_disk_name
      storage_account_type = var.vm_data_disk_type
      disk_size_gb         = var.vm_data_disk_size_gb
      lun                  = 0
      caching              = "None"
    }
  }

  # Single NIC — private IP only, no public IP assigned (FR-011, FR-013)
  network_interfaces = {
    nic_0 = {
      name = local.vm_nic_name
      ip_configurations = {
        ipconfig_0 = {
          name                          = "ipconfig0"
          private_ip_subnet_resource_id = module.virtual_network.subnets["vm_subnet"].resource_id
          private_ip_allocation_method  = "Dynamic"
        }
      }
    }
  }

  diagnostic_settings = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false

  depends_on = [
    module.key_vault,
    module.virtual_network,
  ]
}

# ─── Storage (US3) ───────────────────────────────────────────────────────────

# Private DNS zone for Azure Files private endpoints.
# Domain = "privatelink.file.core.windows.net" (canonical zone for Azure Files).
# Autoregistration is disabled — only the storage PE record is registered (FR-023).
module "private_dns_zone" {
  source  = "Azure/avm-res-network-privatednszone/azurerm"
  version = "0.5.0"

  domain_name = local.private_dns_zone_name
  parent_id   = module.resource_group.resource_id

  virtual_network_links = {
    workload_vnet_link = {
      name               = "link-${local.vnet_name}"
      virtual_network_id = module.virtual_network.resource_id
      autoregistration   = false
    }
  }

  tags             = local.common_tags
  enable_telemetry = false

  depends_on = [module.virtual_network]
}

# Storage account — Standard LRS, StorageV2, TLS 1.2, no public access (FR-020–FR-023).
# Access via private endpoint only; shared-key (SAS) access disabled.
# NOTE: shared_access_key_enabled = false requires Kerberos/AADKERB for SMB
# authentication from the VM.  See quickstart.md Step 9 for mapping instructions.
module "storage_account" {
  source  = "Azure/avm-res-storage-storageaccount/azurerm"
  version = "0.6.7"

  name                = local.storage_account_name
  location            = module.resource_group.location
  resource_group_name = module.resource_group.name

  account_kind             = "StorageV2"
  account_tier             = "Standard" # Standard tier — FR-020
  account_replication_type = "LRS"      # Locally-redundant storage — FR-020
  min_tls_version          = "TLS1_2"   # Minimum TLS 1.2 enforced — FR-020

  # Disable all public network access — FR-021; access via private endpoint only
  public_network_access_enabled = false

  # Network rules — deny all public traffic; allow Azure services for diagnostics
  network_rules = {
    bypass         = ["AzureServices"]
    default_action = "Deny"
  }

  # Shared key (SAS) access is disabled. See FR-020 and quickstart.md Step 9.
  shared_access_key_enabled = false

  # Azure Files share — 100 GB quota (FR-022)
  shares = {
    workload_share = {
      name  = var.storage_file_share_name
      quota = var.storage_file_share_quota_gb
    }
  }

  # Private endpoint for the "file" sub-resource only — FR-023
  private_endpoints = {
    file_pe = {
      name                          = local.pe_storage_name
      subnet_resource_id            = module.virtual_network.subnets["pe_subnet"].resource_id
      subresource_name              = "file"
      private_dns_zone_resource_ids = toset([module.private_dns_zone.resource_id])
    }
  }

  # Storage account–level diagnostics (metrics only — storage accounts
  # do not support log categories at the account level)
  diagnostic_settings_storage_account = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
      metric_categories     = ["AllMetrics"]
    }
  }

  # Azure Files service–level diagnostics (logs + metrics)
  diagnostic_settings_file = {
    to_law = {
      name                  = "diag-to-law"
      workspace_resource_id = module.log_analytics_workspace.resource_id
    }
  }

  tags             = local.common_tags
  enable_telemetry = false

  depends_on = [module.private_dns_zone]
}

# ─── Observability (US4) ─────────────────────────────────────────────────────

# All diagnostic settings are declared inline with each module call above.
# This section contains only the three native AzAPI alert rule resources
# for which no AVM module exists (Constitution Principle II).

# ─── Alerts ──────────────────────────────────────────────────────────────────

# Alert 1: VM stopped / deallocated (FR-027)
# VmAvailabilityMetric = 1 when running, 0 when stopped.  A platform metric —
# no Azure Monitor Agent required.  Fires within alert_vm_metric_window_size
# of the VM transitioning to stopped/deallocated.
resource "azapi_resource" "vm_stopped_metric_alert" {
  type      = "Microsoft.Insights/metricAlerts@2018-03-01"
  name      = local.alert_vm_stopped_name
  parent_id = module.resource_group.resource_id
  location  = "global"
  tags      = local.common_tags
  body = {
    properties = {
      description         = "Alert fires when the VM is in a stopped/deallocated state."
      severity            = 1
      enabled             = true
      scopes              = [module.virtual_machine.resource_id]
      evaluationFrequency = "PT1M"
      windowSize          = var.alert_vm_metric_window_size
      criteria = {
        "odata.type" = "Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria"
        allOf = [
          {
            name            = "vm_availability"
            criterionType   = "StaticThresholdCriterion"
            metricNamespace = "Microsoft.Compute/virtualMachines"
            metricName      = "VmAvailabilityMetric"
            operator        = "LessThan"
            threshold       = 1
            timeAggregation = "Average"
          }
        ]
      }
      actions = []
    }
  }

  response_export_values = []
}

# Alert 2: Disk free space < threshold (FR-028)
# PREREQUISITE: Azure Monitor Agent (AMA) + Data Collection Rule (DCR) with
# "LogicalDisk % Free Space" counter must be deployed on the VM before this
# alert produces results (FR-030 exception — AMA is a manual post-deploy step,
# see quickstart.md Step 10).
resource "azapi_resource" "disk_low_scheduled_query_alert" {
  type      = "Microsoft.Insights/scheduledQueryRules@2023-12-01"
  name      = local.alert_disk_full_name
  parent_id = module.resource_group.resource_id
  location  = module.resource_group.location
  tags      = local.common_tags
  body = {
    properties = {
      displayName         = local.alert_disk_full_name
      description         = "Alert fires when VM disk free space drops below ${var.alert_disk_free_threshold_pct}%."
      severity            = 2
      enabled             = true
      scopes              = [module.log_analytics_workspace.resource_id]
      evaluationFrequency = var.alert_disk_query_window
      windowSize          = var.alert_disk_query_window
      criteria = {
        allOf = [
          {
            query           = <<-QUERY
              Perf
              | where ObjectName == "LogicalDisk"
                  and CounterName == "% Free Space"
                  and InstanceName != "_Total"
                  and InstanceName != "HarddiskVolume3"
              | where CounterValue < ${var.alert_disk_free_threshold_pct}
              | project TimeGenerated, Computer, InstanceName, CounterValue
            QUERY
            timeAggregation = "Count"
            threshold       = 0
            operator        = "GreaterThan"
            failingPeriods = {
              minimumFailingPeriodsToAlert = 1
              numberOfEvaluationPeriods    = 1
            }
          }
        ]
      }
      actions = {
        actionGroups = []
      }
    }
  }

  response_export_values = []
}

# Alert 3: Key Vault access failures (FR-029)
# Fires on any non-200 KV API response (auth failures, authorization denials,
# throttling).  Requires KV audit diagnostic logs enabled (done inline above).
resource "azapi_resource" "key_vault_access_failures_metric_alert" {
  type      = "Microsoft.Insights/metricAlerts@2018-03-01"
  name      = local.alert_kv_failures_name
  parent_id = module.resource_group.resource_id
  location  = "global"
  tags      = local.common_tags
  body = {
    properties = {
      description         = "Alert fires when Key Vault API requests result in failure responses."
      severity            = 2
      enabled             = true
      scopes              = [module.key_vault.resource_id]
      evaluationFrequency = "PT5M"
      windowSize          = var.alert_kv_metric_window_size
      criteria = {
        "odata.type" = "Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria"
        allOf = [
          {
            name            = "key_vault_access_failures"
            criterionType   = "StaticThresholdCriterion"
            metricNamespace = "Microsoft.KeyVault/vaults"
            metricName      = "ServiceApiResult"
            operator        = "GreaterThan"
            threshold       = 0
            timeAggregation = "Count"
            dimensions = [
              {
                name     = "StatusCode"
                operator = "Exclude"
                values   = ["200"]
              }
            ]
          }
        ]
      }
      actions = []
    }
  }

  response_export_values = []
}
# =============================================================================
# variables.tf — Input variable declarations for My Legacy Workload
#
# Workload : My Legacy Workload (001-my-legacy-workload)
# Variables are grouped by concern to match the section structure in
# terraform.tfvars.  All defaults reflect the single production environment
# targeted by this configuration (westus3 / prod / legacy).
# =============================================================================

# ─── Global ──────────────────────────────────────────────────────────────────

variable "location" {
  type        = string
  default     = "westus3"
  description = "Azure region for all resources in this workload."
}

variable "environment" {
  type        = string
  default     = "prod"
  description = "Deployment environment label used in resource names and tags (e.g. prod, dev, staging)."
}

variable "workload" {
  type        = string
  default     = "legacy"
  description = "Short workload identifier used in resource names and tags."
}

variable "tags" {
  type        = map(string)
  default     = { environment = "prod", workload = "legacy" }
  description = "Base tag map merged with workload/environment/managedBy/region tags for every resource."
}

# ─── Networking ──────────────────────────────────────────────────────────────

variable "vnet_address_space" {
  type        = list(string)
  default     = ["10.0.0.0/16"]
  description = "CIDR address space assigned to the virtual network."
}

variable "subnet_bastion_cidr" {
  type        = string
  default     = "10.0.0.0/26"
  description = "Address prefix for the AzureBastionSubnet.  Must be /26 or larger to satisfy Azure Bastion requirements."
}

variable "subnet_vm_cidr" {
  type        = string
  default     = "10.0.1.0/24"
  description = "Address prefix for the VM subnet.  VMs are deployed here with NAT outbound only — no public IPs."
}

variable "subnet_pe_cidr" {
  type        = string
  default     = "10.0.2.0/24"
  description = "Address prefix for the private-endpoint subnet.  Private endpoints for storage are placed here."
}

# ─── Virtual Machine ─────────────────────────────────────────────────────────

variable "vm_sku_size" {
  type        = string
  default     = "Standard_D2s_v3"
  description = "Azure VM SKU — must provide >= 2 vCPU and >= 8 GB RAM (FR-008)."
}

variable "vm_admin_username" {
  type        = string
  default     = "vmadmin"
  description = "Local administrator username for the Windows VM."

  validation {
    condition     = length(var.vm_admin_username) > 0
    error_message = "vm_admin_username must not be empty."
  }
}

variable "vm_image_publisher" {
  type        = string
  default     = "MicrosoftWindowsServer"
  description = "Publisher of the VM source image."
}

variable "vm_image_offer" {
  type        = string
  default     = "WindowsServer"
  description = "Offer of the VM source image."
}

variable "vm_image_sku" {
  type        = string
  default     = "2016-Datacenter"
  description = "SKU of the VM source image (FR-007: Windows Server 2016)."
}

variable "vm_os_disk_type" {
  type        = string
  default     = "Standard_LRS"
  description = "Storage type for the OS disk (Standard_LRS = Standard HDD, FR-008)."
}

variable "vm_data_disk_size_gb" {
  type        = number
  default     = 500
  description = "Size of the data disk in GB (FR-009: 500 GB)."

  validation {
    condition     = var.vm_data_disk_size_gb >= 1
    error_message = "vm_data_disk_size_gb must be at least 1 GB."
  }
}

variable "vm_data_disk_type" {
  type        = string
  default     = "Standard_LRS"
  description = "Storage type for the data disk (Standard_LRS = Standard HDD, FR-009)."
}

variable "vm_computer_name" {
  type        = string
  default     = "leg-prod-001"
  description = "Windows computer (NetBIOS) name for the VM.  Must be <= 15 characters (FR-013).  The Azure resource name is controlled by local.vm_name."

  # CHK032: computer name must fit in NetBIOS 15-char limit and follow DNS rules
  validation {
    condition     = length(var.vm_computer_name) <= 15 && can(regex("^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$", var.vm_computer_name))
    error_message = "vm_computer_name must be 15 characters or fewer, contain only alphanumeric characters and hyphens, and must not start or end with a hyphen (FR-013, CHK032)."
  }
}

variable "vm_availability_zone" {
  type        = number
  default     = 1
  description = "Availability zone number (1, 2, or 3) for the VM and NAT gateway public IP (FR-014)."

  # CHK033: zone 0 and -1 are explicitly prohibited
  validation {
    condition     = contains([1, 2, 3], var.vm_availability_zone)
    error_message = "vm_availability_zone must be 1, 2, or 3. Values 0 and -1 are explicitly prohibited (FR-015, CHK033)."
  }
}

# ─── Key Vault ───────────────────────────────────────────────────────────────

variable "kv_sku" {
  type        = string
  default     = "standard"
  description = "Key Vault SKU tier (standard or premium)."
}

variable "vm_admin_password_secret_name" {
  type        = string
  default     = "vm-admin-password"
  description = "Name of the Key Vault secret that holds the VM administrator password (FR-018)."
}

# ─── Storage ─────────────────────────────────────────────────────────────────

variable "storage_file_share_name" {
  type        = string
  default     = "share-legacy-prod"
  description = "Name of the Azure Files file share."
}

variable "storage_file_share_quota_gb" {
  type        = number
  default     = 100
  description = "Quota of the file share in GB."
}

# ─── Log Analytics ───────────────────────────────────────────────────────────

variable "log_analytics_retention_days" {
  type        = number
  default     = 30
  description = "Number of days to retain logs in the Log Analytics workspace (minimum 30 for compliance)."
}

# ─── Alert Thresholds ────────────────────────────────────────────────────────

variable "alert_disk_free_threshold_pct" {
  type        = number
  default     = 10
  description = "Disk available percentage below which the disk-full alert fires (FR-028)."
}

variable "alert_vm_metric_window_size" {
  type        = string
  default     = "PT5M"
  description = "ISO 8601 evaluation window for the VM availability metric alert (FR-027)."
}

variable "alert_disk_query_window" {
  type        = string
  default     = "PT15M"
  description = "ISO 8601 evaluation frequency and window for the disk-space scheduled query alert (FR-028)."
}

variable "alert_kv_metric_window_size" {
  type        = string
  default     = "PT15M"
  description = "ISO 8601 evaluation window for the Key Vault access-failure metric alert (FR-029)."
}
# Global
location    = "westus3"
environment = "prod"
workload    = "legacy"
tags = {
  environment = "prod"
  workload    = "legacy"
}

# Networking
vnet_address_space  = ["10.0.0.0/16"]
subnet_bastion_cidr = "10.0.0.0/26"
subnet_vm_cidr      = "10.0.1.0/24"
subnet_pe_cidr      = "10.0.2.0/24"

# Virtual machine
vm_sku_size          = "Standard_D2s_v3"
vm_admin_username    = "vmadmin"
vm_image_publisher   = "MicrosoftWindowsServer"
vm_image_offer       = "WindowsServer"
vm_image_sku         = "2016-Datacenter"
vm_os_disk_type      = "Standard_LRS"
vm_data_disk_size_gb = 500
vm_data_disk_type    = "Standard_LRS"
vm_computer_name     = "leg-prod-001"
vm_availability_zone = 1

# Key Vault
kv_sku                        = "standard"
vm_admin_password_secret_name = "vm-admin-password"

# Storage
storage_file_share_name     = "share-legacy-prod"
storage_file_share_quota_gb = 100

# Log Analytics
log_analytics_retention_days = 30

# Alert thresholds
alert_disk_free_threshold_pct = 10
alert_vm_metric_window_size   = "PT5M"
alert_disk_query_window       = "PT15M"
alert_kv_metric_window_size   = "PT15M"
# =============================================================================
# outputs.tf — Output declarations for My Legacy Workload
#
# Outputs expose the resource IDs and names that downstream consumers
# (pipelines, runbooks, or child modules) need.  Credential values are never
# output — the VM password lives only in random_password state and in Key
# Vault; it is never surfaced here.
# =============================================================================

# ─── Resource Group ──────────────────────────────────────────────────────────

output "resource_group_id" {
  description = "Resource ID of the workload resource group."
  value       = module.resource_group.resource_id
}

# ─── Networking ──────────────────────────────────────────────────────────────

output "virtual_network_id" {
  description = "Resource ID of the virtual network."
  value       = module.virtual_network.resource_id
}

output "subnet_vm_id" {
  description = "Resource ID of the VM subnet."
  value       = module.virtual_network.subnets["vm_subnet"].resource_id
}

output "subnet_bastion_id" {
  description = "Resource ID of the Azure Bastion subnet."
  value       = module.virtual_network.subnets["bastion_subnet"].resource_id
}

output "subnet_pe_id" {
  description = "Resource ID of the private-endpoint subnet."
  value       = module.virtual_network.subnets["pe_subnet"].resource_id
}

# ─── Key Vault ───────────────────────────────────────────────────────────────

output "key_vault_id" {
  description = "Resource ID of the Key Vault (not a credential — safe to share downstream)."
  value       = module.key_vault.resource_id
  sensitive   = false
}

output "key_vault_name" {
  description = "Name of the Key Vault."
  value       = local.key_vault_name
}

# ─── Storage ─────────────────────────────────────────────────────────────────

output "storage_account_id" {
  description = "Resource ID of the storage account (not a credential — safe to share downstream)."
  value       = module.storage_account.resource_id
  sensitive   = false
}

output "storage_account_name" {
  description = "Name of the storage account."
  value       = local.storage_account_name
}

# ─── Virtual Machine ─────────────────────────────────────────────────────────

output "vm_id" {
  description = "Resource ID of the virtual machine."
  value       = module.virtual_machine.resource_id
}

output "vm_name" {
  description = "Azure resource name of the virtual machine."
  value       = local.vm_name
}

# ─── Observability ───────────────────────────────────────────────────────────

output "log_analytics_workspace_id" {
  description = "Resource ID of the Log Analytics workspace."
  value       = module.log_analytics_workspace.resource_id
}

# ─── Bastion ─────────────────────────────────────────────────────────────────

output "bastion_name" {
  description = "Azure resource name of the Bastion host."
  value       = local.bastion_name
}
# =============================================================================
# terraform.tf — Provider and Terraform version requirements
#
# Workload : My Legacy Workload (001-my-legacy-workload)
# Region   : West US 3 (westus3)
# This file declares the minimum Terraform version and every provider required
# by this configuration.  AVM modules that use azapi or time internally will
# inherit these constraints automatically.
# =============================================================================

terraform {
  required_version = ">= 1.10, < 2.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.37"
    }
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.12"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
    time = {
      source  = "hashicorp/time"
      version = ">= 0.9.0, < 1.0.0"
    }
  }
}

# -----------------------------------------------------------------------------
# Providers used by the solution.
# -----------------------------------------------------------------------------
provider "azapi" {
  enable_preflight = true
}

provider "azurerm" {
  features {}
  storage_use_azuread = true
}
  1. Review and approve all changes suggested by Copilot by clicking on the “Keep” button or tweak them as necessary!
  2. It is recommended to make a commit now to capture your implementation results, with a comment of something like Implementation complete.

Next Steps

Congratulations! You’ve walked through a complete Spec Kit workflow for building Azure infrastructure using Azure Verified Modules. By following this structured approach, you’ve created a deployable IaC solution that is:

  • Well-documented: Every design decision is captured in the specification and plan.
  • Secure by default: The constitution enforces security baselines from the start.
  • Reproducible: The generated IaC template can be deployed consistently across environments.
  • Maintainable: Clear task breakdowns and checklists make future updates straightforward.

From here, you can ask Copilot to help you with the deployment and further enhancements, or you can manually take the following steps to deploy and manage your solution:

​
  1. Validate with What-If: Run az deployment group what-if to preview changes before deployment.
  2. Deploy to Azure: Use the Azure CLI or Bicep CLI to deploy your generated main.bicep to a subscription:
    az deployment group create \
      --resource-group <your-resource-group> \
      --template-file main.bicep \
      --parameters main.bicepparam
  3. Integrate into CI/CD: Add the generated templates to your Azure DevOps or GitHub Actions pipelines.
  4. Extend the solution: Iterate on the specification to add new capabilities while maintaining alignment with your constitution.
  1. Initialize: Run terraform init to download the required providers and modules.
  2. Validate with Plan: Run terraform plan to preview changes before deployment.
  3. Deploy to Azure: Use the Terraform CLI to deploy your generated configuration to a subscription:
    terraform apply
  4. Integrate into CI/CD: Add the generated templates to your Azure DevOps or GitHub Actions pipelines.
  5. Extend the solution: Iterate on the specification to add new capabilities while maintaining alignment with your constitution.

For more information on Spec Kit and the underlying methodology, see the Spec Kit overview or explore the Specification-Driven Development concepts.

Specification-Driven Development (SDD)

Overview

Specification-Driven Development (SDD) is a development paradigm where the specification becomes the single source of truth, and code is generated, validated, and continuously regenerated from that specification. The key idea: you define intent upfront and unambiguously, and both humans and AI agents produce the implementation from it.

In this new model, specifications serve as a machine-enforceable contract between:

  • Solution builders who compose IaC solution templates for their workload’s requirements
  • AI assistants that generate code following these requirements
  • Governance teams who can trust that deployed infrastructure meets organizational requirements

This contract ensures that as requirements change, Azure evolves or best practices advance, updates to specifications automatically propagate through AI-assisted development, keeping all solutions aligned with current standards without requiring manual intervention across thousands of code repositories.

Core Principles

  1. The Specification becomes the system: SDD flips the traditional hierarchy: instead of writing code and using specs as optional documentation, code now serves the specification, not the other way around. Specs no longer describe the system - they define it.
  2. Architecture becomes executable: Architecture and requirements aren’t advisory; platforms can enforce them, regenerate code, and detect drift via continuous validation and schema checks.
  3. Intent > Implementation: Human authority shifts “upward,” focusing on intent, policy, constraints, and ethics, while automation handles consistent implementation.
  4. Parallelization and consistency: Because every team consumes the same precise blueprint, SDD eliminates ambiguity and reduces rework.
  5. AI-native development workflow: AI coding agents (e.g., GitHub Copilot with Spec Kit) rely on specifications to generate architecture plans, tests, tasks, and code in a deterministic, repeatable way.

Paradigm Shift

Historically, infrastructure development has been an iterative process of trial and error - developers write code, test it, encounter issues, consult documentation, refine the approach, and repeat. This cycle is time-consuming and error-prone, with each developer potentially interpreting best practices differently, leading to inconsistent implementations across teams and projects.

Specification-driven development represents a fundamental shift in how we approach infrastructure coding. Rather than developers manually translating requirements into code while attempting to remember and apply countless best practices, this approach leverages comprehensive, machine-readable specifications that define exactly how infrastructure should be structured, configured, and implemented.

This paradigm shift elevates the developer’s role from code writer to solution architect. Instead of spending time ensuring compliance with specifications manually, developers can:

  • Design at a higher level: Focus on business requirements and architectural decisions
  • Compose solutions faster: Leverage pre-validated patterns and modules
  • Maintain quality effortlessly: Specifications are automatically applied through AI assistance
  • Scale best practices: Consistent, high-quality implementations across the entire organization

New Development Workflow

Specification-driven development enabled by AI transforms the traditional workflow into a systematic, compliance-first process:

flowchart LR
    A[**Express intent**:<br/>Describe what you want to achieve in natural language] --> B[**AI interprets specifications**:<br/>Copilot consults specifications to understand the compliant implementation path]
    B --> C[**Generate compliant code**:<br/>Produce IaC that adheres to all relevant standards and patterns]
    C --> D[**Validate automatically**:<br/>Built-in awareness of specifications enables immediate validation against requirements]
    D --> E[**Iterate with confidence**:<br/>Modifications and enhancements maintain compliance throughout the development lifecycle]

How Infrastructure-as-Code (IaC) Changes with SDD

When GitHub Copilot is equipped with specifications, AI doesn’t just suggest code - it becomes a compliance engine that understands and enforces the intricate rules, patterns, and best practices defined in the specifications. This carries several advantages.

1. IaC moves from code-first to specification-first

Today’s IaC flow often tries to encode architecture through Bicep/Terraform solution templates. In SDD:

  • The infrastructure specification sits above the IaC language.
  • IaC (Bicep, Terraform solution templates) becomes generated artifacts.
  • Updating the infrastructure means updating the specification, and IaC regeneration ensures consistency. Code is “the last-mile expression” of the spec.

This reduces cognitive load as focus shifts from “How do I implement this correctly?” to “What do I want to accomplish?”

2. Eliminates drift between architecture documents and IaC

  • SDD enforces consistency through continuous schema validation, contract testing, and automated detection of spec-to-code mismatches.
  • This means no more documentation vs solution code mismatches: code is always aligned with the specification.

3. IaC solution templates become generated, not hand-coded

  • Your specification becomes the authoritative source (constraints, principles, etc.).
  • Bicep/Terraform solution templates are generated from specs, by referencing AVM modules - removing human variation.
  • Quality improves as every parameter, and configuration follows the same high-quality standards
  • Refactoring becomes updating specification, not rewriting code
  • Template structure, testing, and documentation become deterministic output.
  • Developers gain access to expert-level knowledge embedded in the specifications without needing to memorize hundreds of pages of requirements

4. Stronger governance built-in from day 1

  • Specs can encode: Well-Architected principles, compliance constraints, naming, tagging, and security baselines
  • IaC code is generated to comply automatically as SDD encodes governance in specifications, providing governance-first enforcement from the beginning.

5. AI agents can automate infra decisions reliably

“Ad-hoc” AI-generated IaC often lacks correctness or compliance; with SDD:

  • Correctness by design is embedded by design, as AI agents use the specifications as guardrails.
  • Generated templates follow a deterministic architecture plan, not LLM “best guesses.”
  • Changes are applied by updating the specification, not patching IaC manually.

This is transformative for large-scale infrastructure modernization.

6. Cross-organizational alignment becomes much easier
IaC solution developers work with various teams, often in different organizations - everyone reads specifications differently. SDD solves this as specifications are versioned, reviewable, and auditable, with decisions and trade-offs stored in specifications. This means fewer misinterpretations, such as requirement mismatches or lifecycle ambiguities.

7. Infrastructure testing and validation become automated

Specifications become the basis for testing, including deployment validations, compliance checks, etc. IaC test automation becomes spec-driven and auto-generated.

The Future is Specification-Driven

As AI capabilities continue to advance, the value of comprehensive, well-defined specifications only increases. The combination of AVM’s rigorous specification framework, the principles of spec-driven development, and GitHub Copilot’s AI intelligence represents not just an incremental improvement, but a fundamental re-imagining of how cloud infrastructure development can and should work in the AI era.

Quickstart Guide

This QuickStart guide offers step-by-step instructions for integrating Azure Verified Modules (AVM) into your solutions. It includes the initial setup, essential tools, and configurations required to deploy and manage your Azure resources efficiently using AVM.

The AVM Key Vault resource module, used as an example in this chapter, simplifies the deployment and management of Azure Key Vaults, ensuring secure storage and access to your secrets, keys, and certificates.

Leveraging Azure Verified Modules

Using AVM ensures that your infrastructure-as-code deployments follow Microsoft’s best practices and guidelines, providing a consistent and reliable foundation for your cloud solutions. AVM helps accelerate your development process, reduce the risk of misconfigurations, and enhance the security and compliance of your applications.

Using default values

The default values provided by AVM are generally safe, as they follow best practices and ensure a secure and reliable setup. However, it is important to review these values to ensure they meet your specific requirements and compliance needs. Customizing the default values may be necessary to align with your organization’s policies and the specific needs of your solution.

Exploring examples and module features

You can find examples and detailed documentation for each AVM module in their respective code repository’s README.MD file, which details features, input parameters, and outputs. The module’s documentation also provides comprehensive usage examples, covering various scenarios and configurations. Additionally, you can explore the module’s source code repository. This information will help you understand the full capabilities of the module and how to effectively integrate it into your solutions.

Subsections of Quickstart

Bicep Quickstart Guide

Introduction

This guide explains how to use an Azure Verified Modules (AVM) in your Bicep workflow. By leveraging AVM modules, you can rapidly deploy and manage Azure infrastructure without having to write extensive code from scratch.

In this guide, you will deploy a Key Vault resource and a Personal Access Token as a secret.

This article is intended for a typical ‘infra-dev’ user (cloud infrastructure professional) who has a basic understanding of Azure and Bicep but is new to Azure Verified Modules and wants to learn how to deploy a module in the easiest way using AVM.

For additional Bicep learning resources use the Bicep documentation on the Microsoft Learn platform, or leverage the Fundamentals of Bicep learning path.

Prerequisites

You will need the following tools and components to complete this guide:

Make sure you have these tools set up before proceeding.

Module Discovery

Find your module

In this scenario, you need to deploy a Key Vault resource and some of its child resources, such as a secret. Let’s find the AVM module that will help us achieve this.

There are two primary ways for locating published Bicep Azure Verified Modules:

  • Option 1 (preferred): Using IntelliSense in the Bicep extension of Visual Studio Code, and
  • Option 2: browsing the AVM Bicep module index.

Option 1: Use the Bicep Extension in VS Code

  1. In VS Code, create a new file called main.bicep.
  2. Start typing module, then give your module a symbolic name, such as myModule.
  3. Use IntelliSense to select br/public.
  4. The list of all AVM modules published in the Bicep Public Registry will show up. Use this to explore the published modules.
    Note

    The Bicep VSCode extension is reading metadata through this JSON file. All modules are added to this file, as part of the publication process. This lists all the modules marked as Published or Orphaned on the AVM Bicep module index pages.

  5. Select the module you want to use and the version you want to deploy. Note how you can type full or partial module names to filter the list.
  6. Right click on the module’s path and select Go to definition or hit F12 to see the module’s source code. You can toggle between the Bicep and the JSON view.
  7. Hover over the module’s symbolic name to view its documentation URL. By clicking on it, you will be directed to the module’s GitHub folder in the bicep-registry-modules (BRM) repository. There, you can access the source code and documentation, as illustrated below.

Option 2: Use the AVM Bicep Module Index

Searching the Azure Verified Modules indexes is the most complete way to discover published as well as planned (proposed) modules. As shown in the video above, use the following steps to locate a specific module on the AVM website:

  1. Open the AVM website in your favorite web browser: https://aka.ms/avm.
  2. Expand the Module Indexes menu item and select the Bicep sub-menu item.
  3. Select the menu item for the module type you are searching for: Resource, Pattern, or Utility.
    Note

    Since the Key Vault module used as an example in this guide is published as an AVM resource module, it can be found under the resource modules section in the AVM Bicep module index.

  4. A detailed description of module classification types can be found under the related section here.
  5. Select the Published modules link from the table of contents at the top of the page.
  6. Use the in-page search feature of your browser. In most Windows browsers you can access it using the CTRL + F keyboard shortcut.
  7. Enter a search term to find the module you are looking for - e.g., Key Vault.
  8. Move through the search results until you locate the desired module. If you are unable to find a published module, return to the table of contents and expand the All modules link to search both published and proposed modules - i.e., modules that are planned, likely in development but not published yet.
  9. After finding the desired module, click on the module’s name. This link will lead you to the module’s folder in the bicep-registry-modules (BRM) repository, where the module’s source code and documentation can be found, including usage examples.

Module details and examples

In the module’s documentation, you can find detailed information about the module’s functionality, components, input parameters, outputs and more. The documentation also provides comprehensive usage examples, covering various scenarios and configurations.

Explore the Key Vault module’s documentation for usage examples and to understand its functionality, input parameters, and outputs.

  1. Note the mandatory and optional parameters in the Parameters section.

  2. Review the Usage examples section. AVM modules include multiple tests that can be found under the tests folder. These tests are also used as the basis of the usage examples ensuring they are always up-to-date and deployable.

In this example, you will deploy a secret in a new Key Vault instance with minimal input. AVM provides default parameter values with security and reliability being core principles. These settings apply the recommendations of the Well Architected Framework where possible and appropriate.

Note how Example 2 does most of what you need to achieve.

Create your new solution using AVM

In this section, you will develop a Bicep template that references the AVM Key Vault module and its child resources and features. These include secret and role based access control configurations that grant permissions to a user.

  1. Start VSCode (make sure the Bicep extension is installed) and open a folder in which you want to work.
  2. Create a main.bicep and a dev.bicepparam file, which will hold parameters for your Key Vault deployment.
  3. Copy the content below into your main.bicep file. We have included comments to distinguish between the two different occurrences of the names attribute.
module myKeyVault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: // the name of the module's deployment
  params: {
    name: '<keyVaultName>' // the name of the Key Vault instance - length and character limits apply
  }
}
Note

For Azure Key Vaults, the name must be globally unique. When you deploy the Key Vault, ensure you select a name that is alphanumeric, twenty-four characters or less, and unique enough to ensure no one else has used the name for their Key Vault. If the name has been previously taken, you will get an error.

After setting the values for the required properties, the module can be deployed. This minimal configuration automatically applies the security and reliability recommendations of the Well Architected Framework where possible and appropriate. These settings can be overridden if needed.

Bicep-specific configuration

It is recommended to create a bicepconfig.json file, and enable use-recent-module-versions, which warns you to use the latest available version of the AVM module.

// This is a Bicep configuration file. It can be used to control how Bicep operates and to customize
// validation settings for the Bicep linter. The linter relies on these settings when evaluating your
// Bicep files for best practices. For further information, please refer to the official documentation at:
// https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-config
{
  "analyzers": {
    "core": {
      "rules": {
        "use-recent-module-versions": {
          "level": "warning",
          "message": "The module version is outdated. Please consider updating to the latest version."
        }
      }
    }
  }
}

Define the Key Vault instance

In this scenario - and every other real-world setup - there is more that you need to configure. You can open the module’s documentation by hovering over its symbolic name to see all of the module’s capabilities - including supported parameters.

Note

The Bicep extension facilitates code-completion, enabling you to easily locate and utilize the Azure Verified Module. This feature also provides the necessary properties for a module, allowing you to begin typing and leverage IntelliSense for completion.

  1. Add parameters and values to the main.bicep file to customize your configuration. These parameters are used for passing in the Key Vault name and enabling purge protection. You might not want to enable the latter in a non-production environment, as it makes it harder to delete and recreate resources.

The main.bicep file will now look like this:

// the scope, the deployment deploys resources to
targetScope = 'resourceGroup'

// parameters and default values
param keyVaultName string

@description('Disable for development deployments.')
param enablePurgeProtection bool = true

// the resources to deploy
module myKeyVault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: 'key-vault-deployment'
  params: {
    name: keyVaultName
    enablePurgeProtection: enablePurgeProtection
    // more properties are not needed, as AVM provides default values
  }
}

Note that the Key Vault instance will be deployed within a resource group scope in our example.

  1. Create a dev.bicepparam file (this is optional) and set parameter values for your environment. You can now pass these values by referencing this file at the time of deployment (using PowerShell or Azure CLI).
using 'main.bicep'

// environment specific values
param keyVaultName = '<keyVaultName>'
param enablePurgeProtection = false

Create a secret and set permissions

Add a secret to the Key Vault instance and grant permissions to a user to work with the secret. Sample role assignments can be found in Example 3: Using large parameter set. See Parameter: roleAssignments for a list of pre-defined roles that you can reference by name instead of a GUID. This is a key benefit of using AVM, as the code is easy to read and increases the maintainability.

You can also leverage User-defined data types and simplify the parameterization of the modules instead of guessing or looking up parameters. Therefore, first import UDTs from the Key Vault and common types module and leverage the UDTs in your Bicep and parameter files.

For a role assignment, the principal ID is needed, that will be granted a role (specified by its name) on the resource. Your own ID can be found out with az ad signed-in-user show --query id.

// the scope, the deployment deploys resources to
targetScope = 'resourceGroup'

// parameters and default values
param keyVaultName string
// the PAT token is a secret and should not be stored in the Bicep(parameter) file.
// It can be passed via the commandline, if you don't use a parameter file.
@secure()
param patToken string = newGuid()

@description('Enabled by default. Disable for development deployments')
param enablePurgeProtection bool = true

import { roleAssignmentType } from 'br/public:avm/utl/types/avm-common-types:0.4.0'
// the role assignments are optional in the Key Vault module
param roleAssignments roleAssignmentType[]?

// the resources to deploy
module myKeyVault 'br/public:avm/res/key-vault/vault:0.11.0' = {
  name: 'key-vault-deployment'
  params: {
    name: keyVaultName
    enablePurgeProtection: enablePurgeProtection
    secrets: [
      {
        name: 'PAT'
        value: patToken
      }
    ]
    roleAssignments: roleAssignments
  }
}

The secrets parameter references a UDT (User-defined data type) that is part of the Key Vault module and enables code completion for easy usage. There is no need to look up what attributes the secret object might have. Start typing and tab-complete what you need from the content offered by the Bicep extension’s integration with AVM.

The bicep parameter file now looks like this:

// reference to the Bicep file to set the context
using 'main.bicep'

// environment specific values
param keyVaultName = '<keyVaultName>'
param enablePurgeProtection = false
// for security reasons, the secret value must not be stored in this file.
// You can change it later in the deployed Key Vault instance, where you also renew it after expiration.

param roleAssignments = [
  {
    principalId: '<principalId>'
    // using the name of the role instead of looking up the GUID (which can also be used)
    roleDefinitionIdOrName: 'Key Vault Secrets Officer'
  }
]
Note

The display names for roleDefinitionIdOrName can be acquired the following two ways:

  • From the parameters section of the module’s documentation.
  • From the builtInRoleNames variable in the module’s source code. To get there, hit F12 while the cursor is on the part of the module path starting with br/public:.

Boost your development with VS Code IntelliSense

Leverage the IntelliSense feature in VS Code to speed up your development process. IntelliSense provides code completion, possible parameter values and structure. It helps you write code more efficiently by providing context-aware suggestions as you type.

Here is how quickly you can deliver the solution detailed in this section:

Deploy your solution

Now that your template and parameter file is ready, you can deploy your solution to Azure. Use PowerShell or the Azure CLI to deploy your solution.

Deploy with
# Log in to Azure
Connect-AzAccount

# Select your subscription
Set-AzContext -SubscriptionId '<subscriptionId>'

# Deploy a resource group
New-AzResourceGroup -Name 'avm-quickstart-rg' -Location 'germanywestcentral'

# Invoke your deployment
New-AzResourceGroupDeployment -DeploymentName 'avm-quickstart-deployment' -ResourceGroupName 'avm-quickstart-rg' -TemplateParameterFile 'dev.bicepparam' -TemplateFile 'main.bicep'
# Log in to Azure
az login

# Select your subscription
az account set --subscription '<subscriptionId>'

# Deploy a resource group
az group create --name 'avm-quickstart-rg' --location 'germanywestcentral'

# Invoke your deployment
az deployment group create --name 'avm-quickstart' --resource-group 'avm-quickstart-rg' --template-file 'main.bicep' --parameters 'dev.bicepparam'

Use the Azure portal, Azure PowerShell, or the Azure CLI to verify that the Key Vault instance and secret have been successfully created with the correct configuration.

Clean up your environment

When you are ready, you can remove the infrastructure deployed in this example. The following commands will remove all resources created by your deployment:

Clean up with
# Delete the resource group
Remove-AzResourceGroup -Name "avm-quickstart-rg" -Force

# Purge the Key Vault
Remove-AzKeyVault -VaultName "<keyVaultName>" -Location "germanywestcentral" -InRemovedState -Force
# Delete the resource group
az group delete --name 'avm-quickstart-rg' --yes --no-wait

# Purge the Key Vault
az keyvault purge --name '<keyVaultName>' --no-wait

Congratulations, you have successfully leveraged an AVM Bicep module to deploy resources in Azure!

Tip

We welcome your contributions and feedback to help us improve the AVM modules and the overall experience for the community!

Next Steps

For developing a more advanced solution, please see the lab titled “Introduction to using Azure Verified Modules for Bicep”.

Terraform Quickstart Guide

Introduction

This guide explains how to use an Azure Verified Modules (AVM) in your Terraform workflow. With AVM modules, you can quickly deploy and manage Azure infrastructure without writing extensive code from scratch.

In this guide, you will deploy a Key Vault resource and generate and store a key.

This article is intended for a typical ‘infra-dev’ user (cloud infrastructure professional) who is new to Azure Verified Modules and wants to learn how to deploy a module in the easiest way using AVM. The user has a basic understanding of Azure and Terraform.

For additional Terraform resources, try a tutorial on the HashiCorp website or study the detailed documentation.

Prerequisites

You will need the following tools and components to complete this guide:

Before you begin, ensure you have these tools installed in your development environment.

Module Discovery

Find your module

In this scenario, you need to deploy a Key Vault resource and some of its child resources, such as a key. Let’s find the AVM module that will help us achieve this.

There are two primary ways for locating published Terraform Azure Verified Modules:

Use the Terraform Registry

The easiest way to find published AVM Terraform modules is by searching the Terraform Registry. Follow these steps to locate a specific module, as shown in the video above.

  • Use your web browser to go to the HashiCorp Terraform Registry
  • In the search bar at the top of the screen type avm. Optionally, append additional search terms to narrow the search results. (e.g., avm key vault for AVM modules with Key Vault in the name.)
  • Select see all to display the full list of published modules matching your search criteria.
  • Find the module you wish to use and select it from the search results.
Note

It is possible to discover other unofficial modules with avm in the name using this search method. Look for the Partner tag in the module title to determine if the module is part of the official set.

Use the AVM Terraform Module Index

Searching the Azure Verified Modules indexes is the most complete way to discover published as well as planned modules - shown as proposed. As presented in the video above, use the following steps to locate a specific module on the AVM website:

  • Use your web browser to open the AVM website at https://aka.ms/avm.
  • Expand the Module Indexes menu item and select the Terraform sub-menu item.
  • Select the menu item for the module type you are searching for: Resource, Pattern, or Utility.
    Note

    Since the Key Vault module used as an example in this guide is published as an AVM resource module, it can be found under the resource modules section in the AVM Terraform module index.

  • A detailed description of each module classification type can be found under the related section here.
  • Select the Published modules link from the table of contents at the top of the page.
  • Use the in-page search feature of your browser (in most Windows browsers you can access it using the CTRL + F keyboard shortcut).
  • Enter a search term to find the module you are looking for - e.g., Key Vault.
  • Move through the search results until you locate the desired module. If you are unable to find a published module, return to the table of contents and expand the All modules link to search both published and proposed modules - i.e., modules that are planned, likely in development but not published yet.
  • After finding the desired module, click on the module’s name. This link will lead you to the official HashiCorp Terraform Registry page for the module where you can find the module’s documentation and examples.

Module details and examples

Once you have identified the AVM module in the Terraform Registry you can find detailed information about the module’s functionality, components, input parameters, outputs and more. The documentation also provides comprehensive usage examples, covering various scenarios and configurations.

Explore the Key Vault module’s documentation and usage examples to understand its functionality, input variables, and outputs.

  • Note the Examples drop-down list and explore each example
  • Review the Readme tab to see module provider minimums, a list of resources and data sources used by the module, a nicely formatted version of the inputs and outputs, and a reference to any submodules that may be called.
  • Explore the Inputs tab and observe how each input has a detailed description and a type definition for you to use when adding input values to your module configuration.
  • Explore the Outputs tab and review each of the outputs that are exported by the AVM module for use by other modules in your deployment.
  • Finally, review the Resources tab to get a better understanding of the resources defined in the module.

In this example, you will deploy a secret in a new Key Vault instance without needing to provide other parameters. The AVM Key Vault resource module provides these capabilities and does so with security and reliability being core principles. The default settings of the module also apply the recommendations of the Well Architected Framework where possible and appropriate.

Note how the create-key example seems to do what you need to achieve.

Create your new solution using AVM

Now that you have found the module details, you can use the content from the Terraform Registry to speed up your development in the following ways:

  1. Option 1: Create a solution using AVM module examples: duplicate a module example and edit it for your needs. This is useful if you are starting without any existing infrastructure and need to create supporting resources like resource groups as part of your deployment.
  2. Option 2: Create a solution by changing the AVM module input values: add the AVM module to an existing solution that already includes other resources. This method requires some knowledge of the resource(s) being deployed so that you can make choices about optional features configured in your solution’s version of the module.

Each deployment method includes a section below so that you can choose the method which best fits your needs.

Note

For Azure Key Vaults, the name must be globally unique. When you deploy the Key Vault, ensure you select a name that is alphanumeric, twenty-four characters or less, and unique enough to ensure no one else has used the name for their Key Vault. If the name has been used previously, you will get an error.

Option 1: Create a solution using AVM module examples

Leverage the following steps as a template for how to leverage examples for bootstrapping your new solution code. The Key Vault resource module is used here as an example, but in practice you may choose any module that applies to your scenario.

  • Locate and select the Examples drop down menu in the middle of the Key Vault module page.
  • From the drop-down list select an example whose name most closely aligns with your scenario - e.g., create-key.
  • When the example page loads, read the example description to determine if this is the desired example. If it is not, return to the module main page, and select a different example until you are satisfied that the example covers the scenario you are trying to deploy. If you are unable to find a suitable example, leverage the last two steps in the option 2 instructions to modify the inputs of the selected example to match your requirements.
  • Scroll to the code block for the example and select the Copy button on the top right of the block to copy the content to the clipboard.
➕ Click here to copy the sample code from the video.
provider "azapi" {}

terraform {
  required_version = "~> 1.9"
  required_providers {
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.9"
    }
    http = {
      source  = "hashicorp/http"
      version = "~> 3.4"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.5"
    }
  }
}

module "regions" {
  source  = "Azure/avm-utl-regions/azure"
  version = "0.5.0"
}

# This allows us to randomize the region for the resource group.
resource "random_integer" "region_index" {
  max = length(module.regions.regions) - 1
  min = 0
}

data "azapi_client_config" "current" {}

# Deploy the resource group via the AVM resource module.
module "resource_group" {
  source   = "Azure/avm-res-resources-resourcegroup/azure"
  name     = "<your-resource-group-name>"
  location = module.regions.regions[random_integer.region_index.result].name
}

# Get current IP address for use in KV firewall rules
data "http" "ip" {
  url = "https://api.ipify.org/"
  retry {
    attempts     = 5
    max_delay_ms = 1000
    min_delay_ms = 500
  }
}

module "key_vault" {
  source                        = "Azure/avm-res-keyvault-vault/azure"
  name                          = "<your-keyvault-name>"
  location                      = module.resource_group.resource.location
  enable_telemetry              = var.enable_telemetry
  parent_id                     = module.resource_group.resource_id
  tenant_id                     = data.azapi_client_config.current.tenant_id
  public_network_access_enabled = true
  keys = {
    cmk_for_storage_account = {
      key_opts = [
        "decrypt",
        "encrypt",
        "sign",
        "unwrapKey",
        "verify",
        "wrapKey"
      ]
      key_type: "RSA"
      name     = "cmk-for-storage-account"
      key_size = 2048
    }
  }
  role_assignments = {
    deployment_user_kv_admin = {
      role_definition_id_or_name = "Key Vault Administrator"
      principal_id               = data.azapi_client_config.current.object_id
    }
  }
  wait_for_rbac_before_key_operations = {
    create = "60s"
  }
  network_acls = {
    bypass   = "AzureServices"
    ip_rules = ["${data.http.ip.response_body}/32"]
  }
}
  • In your IDE - Visual Studio Code in our example - create the main.tf file for your new solution.

  • Paste the content from the clipboard into main.tf.

  • AVM examples frequently use naming and/or region selection AVM utility modules to generate deployment region and/or naming values as well as any default values for required fields. If you want to use a specific region name or other custom resource values, remove the existing region and naming module calls and replace example input values with the new desired custom input values.

  • Once supporting resources such as resource groups have been modified, locate the module call for the AVM module - i.e., module "keyvault".

  • AVM module examples use dot notation for a relative reference that is useful during module testing. However, you will need to replace the relative reference with a source reference that points to the Terraform Registry source location. In most cases, this source reference has been left as a comment in the module example to simplify replacing the existing source dot reference. Perform the following two actions to update the source:

    • Delete the existing source definition that uses a dot reference - i.e., source = "../../".
    • Uncomment the Terraform Registry source reference by deleting the # sign at the start of the commented source line - i.e., source = "Azure/avm-res-keyvault-vault/azure".
    Note

    If the module example does not include a commented Terraform Registry source reference, you will need to copy it from the module’s main documentation page. Use the following steps to do so:

    • Use the breadcrumbs to leave the example documentation and return to the module’s primary Terraform Registry documentation page.
    • Locate the Provision Instructions box on the right side of the module’s Terraform Registry page in your web browser.
    • Select the second line that starts with source = from the code block - e.g., source = "Azure/avm-res-keyvault-vault/azure". Copy it onto the clipboard.
    • Return to your code solution and Paste the clipboard’s content where you previously deleted the source dot reference - e.g., source = "../../".
  • AVM module examples use a variable to enable or disable the telemetry collection. Update the enable_telemetry input value to true or false. - e.g. enable_telemetry = true

  • Save your main.tf file changes and then proceed to the guide section for running your solution code.

Option 2: Create a solution by changing the AVM module input values

Click here to copy the sample code from the video.
module "avm-res-keyvault-vault" {
  source                        = "Azure/avm-res-keyvault-vault/azure"
  version                       = "1.0.0"
  name                          = "<custom_name_here>"
  parent_id                     = module.resource_group.resource_id
  location                      = module.resource_group.resource.location
  tenant_id                     = data.azapi_client_config.current.tenant_id

  keys = {
    cmk_for_storage_account = {
      key_opts = [
        "decrypt",
        "encrypt",
        "sign",
        "unwrapKey",
        "verify",
        "wrapKey"
      ]
      key_type: "RSA"
      name     = "cmk-for-storage-account"
      key_size = 2048
    }
  }
  role_assignments = {
    deployment_user_kv_admin = {
      role_definition_id_or_name = "Key Vault Administrator"
      principal_id               = data.azapi_client_config.current.object_id
    }
  }
  wait_for_rbac_before_key_operations = {
    create = "60s"
  }
}

Use the following steps as a guide for the custom implementation of an AVM Module in your solution code. This instruction path assumes that you have an existing Terraform file that you want to add the AVM module to.

  • Locate the Provision Instructions box on the right side of the module’s Terraform Registry page in your web browser.
  • Select the module template code from the code block and Copy it onto the clipboard.
  • Switch to your IDE and Paste the contents of the clipboard into your solution’s .tf Terraform file - main.tf in our example.
  • Return to the module’s Terraform Registry page in the browser and select the Inputs tab.
  • Review each input and add the inputs with the desired target value to the solution’s code - i.e., name = "custom_name".
  • Once you are satisfied that you have included all required inputs and any optional inputs, Save your file and continue to the next section.

Deploy your solution

After completing your solution development, you can move to the deployment stage. Follow these steps for a basic Terraform workflow:

  • Open the command line and login to Azure using the Azure cli

    az login
  • If your account has access to multiple tenants, you may need to modify the command to az login --tenant <tenant id> where “<tenant id>” is the guid for the target tenant.

  • After logging in, select the target subscription from the list of subscriptions that you have access to.

  • Change the path to the directory where your completed terraform solution files reside.

    Note

    AVM Terraform modules use the AzAPI provider, which expects an authenticated Azure context (provided by az login above) and reads the active subscription from your environment. If you need to target a specific subscription, set ARM_SUBSCRIPTION_ID to its GUID. For Unix based systems (Linux or MacOS) you can configure this by running export ARM_SUBSCRIPTION_ID=<your subscription guid> on the command line. On Microsoft Windows, you can perform the same operation by running set ARM_SUBSCRIPTION_ID="<your subscription guid>" from the Windows command prompt or by running $env:ARM_SUBSCRIPTION_ID="<your subscription guid>" from a powershell prompt. Replace the “<your subscription id>” notation in each command with your Azure subscription’s unique id value.

  • Initialize your Terraform project. This command downloads the necessary providers and modules to the working directory.

    terraform init
  • Before applying the configuration, it is good practice to validate it to ensure there are no syntax errors.

    terraform validate
  • Create a deployment plan. This step shows what actions Terraform will take to reach the desired state defined in your configuration.

    terraform plan
  • Review the plan to ensure that only the desired actions are in the plan output.

  • Apply the configuration and create the resources defined in your configuration file. This command will prompt you to confirm the deployment prior to making changes. Type yes to create your solution’s infrastructure.

    terraform apply
    Info

    If you are confident in your changes, you can add the -auto-approve switch to bypass manual approval: terraform apply -auto-approve

  • Once the deployment completes, validate that the infrastructure is configured as desired.

    Info

    A local terraform.tfstate file and a state backup file have been created during the deployment. The use of local state is acceptable for small temporary configurations, but production or long-lived installations should use a remote state configuration where possible. Configuring remote state is out of scope for this guide, but you can find details on using an Azure storage account for this purpose in the Microsoft Learn documentation.

Clean up your environment

When you are ready, you can remove the infrastructure deployed in this example. Use the following command to delete all resources created by your deployment:

terraform destroy
Note

Most Key Vault deployment examples activate soft-delete functionality as a default. The terraform destroy command will remove the Key Vault resource but does not purge a soft-deleted vault. You may encounter errors if you attempt to re-deploy a Key Vault with the same name during the soft-delete retention window. If you wish to purge the soft-delete for this example you can run az keyvault purge -n <keyVaultName> -l <regionName> using the Azure CLI, or Remove-AzKeyVault -VaultName "<keyVaultName>" -Location "<regionName>" -InRemovedState using Azure PowerShell.

Congratulations, you have successfully leveraged Terraform and AVM to deploy resources in Azure!

Tip

We welcome your contributions and feedback to help us improve the AVM modules and the overall experience for the community!

Next Steps

For developing a more advanced solution, please see the lab titled “Introduction to using Azure Verified Modules for Terraform”.