Module Structure¶
Building a module is a great way to reuse code and share it with others. This guide will help you understand the basic concepts and best practices for structuring your Terraform modules used with Azure NoOps.
Terraform modules¶
- Overlay modules should be used to create reusable, composable, and configurable infrastructure components. They should be designed to be used in a variety of scenarios and should be as specific as possible. For example, a module that creates a Key Vault should also create a Storage account if it is needed as part of the use case.
- A overlay module should not create just a single resource. Exceptions can be made if that resource requires complex configuration or a stringent set of predefined parameters.
Module structure¶
In Azure NoOps overlay modules, the Terraform configurations are structured in the following way:
-
resources.<azure_resource>.tf
- contains the resources that are created in the module and the configuration of those resources. Example:resources.key.vault.tf
-
modules.<azure_resource>.tf
- contains the NoOps modules that are used in the module. Example:modules.key.vault.tf
which is the Key Vault module -
variables.<azure_resource>.tf
- contains declarations of variables used inresources.tf
-
outputs.tf
- contains outputs from the resources created inresources.tf
-
versions.tf
- contains version requirements for Terraform and providers -
parameters.tfvars
should not be used anywhere except composition.
Control plane and data plane¶
-
A module should only perform control plane operations (e.g., managing Storage account or Key vault), not data plane operations (e.g., managing Storage container or Key vault secret). See control plane and data plane in Microsoft docs.
-
Performing data plane operations usually require workarounds for dealing with firewalls when run from an automated pipeline that deviate from the deterministic approach promoted by Terraform (e.g, temporarily disabling firewall or temporarily adding own IP to firewall).
-
This may lead to the decision of disabling a resource firewall because it is preventing data plane operations from a pipeline, lowering the security of the resource.
-
Data plane operations should be handled outside of Terraform. For example, use a script to upload a file to a storage container or use a pipeline to add a secret to a key vault. This way, the data plane operations can be performed in a deterministic way and the security of the resource can be maintained.
Note Might be irrelevant depending on the implementation of github/roadmap#614.
-
Resources¶
- By default, configure resources based on Microsoft security recommendations, e.g. Security recommendations for Blob storage.
Repeatable resources¶
-
For named repeatable resources (resources that support argument
name
), use a variable of typemap(object())
to dynamically create the resources, where setting the value to{}
will not create any resources.variable "firewall_rules" { description = "A map of SQL firewall rules to create." type = map(object({ name = string start_ip_address = string end_ip_address = string })) default = {} } resource "azurerm_mssql_firewall_rule" "this" { for_each = var.firewall_rules name = each.value.name start_ip_address = each.value.start_ip_address end_ip_address = each.value.end_ip_address }
-
For unnamed repeatable resources (usually a resources that link other resources together), use a variable of type
list(object())
to dynamically create the resources, where setting the value to[]
will not create any resources.variable "job_schedules" { description = "A list of Automation job schedules to create." type = list(object({ runbook_name = string schedule_name = string })) default = [] } resource "azurerm_automation_job_schedule" "this" { count = length(var.job_schedules) runbook_name = var.job_schedules[count.index].runbook_name schedule_name = var.job_schedules[count.index].schedule_name }
Repeatable nested blocks¶
-
For repeatable nested blocks, use a variable of type
list(object())
to dynamically create the nested blocks, where setting the value to[]
will not create any nested blocks:variable "auth_settings_active_directory" { description = "A list of authentication settings using the Active Directory provider to configure for this Linux web app." type = list(object({ client_id = string client_secret_setting_name = string })) default = [] } resource "azurerm_linux_web_app" "this" { # omitted auth_settings { enabled = length(var.auth_settings_active_directory) == 0 ? false : true dynamic "active_directory" { for_each = var.auth_settings_active_directory content { client_id = active_directory.value["client_id"] client_secret_setting_name = active_directory.value["client_secret_setting_name"] } } } }
Non-repeatable nested blocks¶
- For non-repeatable nested blocks, use a variable of type
object()
to dynamically create the nested block, where setting the value tonull
will not create the nested block.
Note: This is important because the nested block may not be supported in certain scenarios. For example, blob_properties
for azurerm_storage_account
is only supported if account_kind
is set to StorageV2
or BlobStorage
.
```terraform
variable "account_kind" {
description = "The kind of storage account to create."
type = string
default = "StorageV2"
}
variable "blob_properties" {
description = "The blob properties for this storage account."
type = object({
versioning_enabled = optional(bool, true)
change_feed_enabled = optional(bool, true)
})
default = {}
}
resource "azurerm_storage_account" "this" {
# omitted
account_kind = var.account_kind
dynamic "blob_properties" {
for_each = var.blob_properties != null ? [var.blob_properties] : []
content {
versioning_enabled = blob_properties.value["versioning_enabled"]
change_feed_enabled = blob_properties.value["change_feed_enabled"]
}
}
}
```
> Known exceptions to this rule would be:
>
> - Blocks that are defined as required by the provider (e.g. the `site_config` block for the `azurerm_linux_web_app` resource).
> - Blocks that are optional but requires an argument to enable/disable its functionality (e.g. the `auth_settings` block for the `azurerm_linux_web_app` resource which requires an argument `enabled`).
Variables¶
- All arguments should be made available as variables with sensible default values to make the module as generic as possible. Default values should be the most generic and secure values possible.
Example generic value:
account_kind
set toStorageV2
instead ofBlobStorage
forstorage
module.Example secure value:
min_tls_version
set to1.2
instead of1.0
forstorage
module.
- Required variables (variables without default values) should be placed first in
variables.tf
.
Variable naming convention¶
Variables and outputs should follow a common naming convention <resource>_<block>_<argument>
, where <resource>
and/or <block>
can be omitted if not applicable.
- Use
description
to explain the use case of variables and outputs.
variable "vault_name" {
description = "The name of this key vault."
type = string
}
variable "diagnostic_setting_name" {
description = "The name of this diagnostic setting."
type = string
}
variable "network_acls_virtual_network_subnet_ids" {
description = "A list of virtual network subnet IDs that should be able to bypass the network ACL and access this key vault."
type = list(string)
default = []
}
resource "azurerm_key_vault" "this" {
name = var.vault_name
# omitted
network_acls {
# omitted
virtual_network_subnet_ids = var.network_acls_virtual_network_subnet_ids
}
}
resource "azurerm_monitor_diagnostic_setting" "this" {
name = var.diagnostic_setting_name
target_resource_id = azurerm_key_vault.this.id
# omitted
}
Known exceptions to this rule:
- Variable names that contain the module name. For example, in module
storage
the variablestorage_account_name
should be namedaccount_name
instead.
Lifecycle meta-arguments¶
-
The
prevent_destroy
lifecycle meta-argument should be used sparingly. ACanNotDelete
lock should be used instead. -
The
ignore_changes
lifecycle meta-argument should be used sparingly, as heavy use could lead to configuration drift.