Provisions an Oracle Application Volume Group (
azurerm_netapp_volume_group_oracle) — every volume an Oracle database needs, created as one resource so the service can co-place them. Targetshashicorp/azurerm ~> 4.0.
- 🧱 Provisions the whole set of volumes an Oracle database needs — up to eight data volumes plus log, log-mirror, binary and backup — as one keystone resource named
this. ⚠️ This is not an ordinary force-new resource. The provider documents nearly every field, including every field inside the nestedvolumeblocks, as "forces a new Application Volume Group and data will be lost".- ✅ Enforces the Oracle-specific rule at plan:
proximity_placement_group_idandzoneare mutually exclusive — a volume is pinned by one mechanism or the other, never both. - 🔢
network_featureshere has four values (Basic,Basic_Standard,Standard,Standard_Basic) — a wider set than the SAP HANA volume group's two. - 🔒 Applies the family's export-policy guardrails per volume —
0.0.0.0/0refused,rule_indexunique, every rule must cover a protocol,root_access_enableddefaulting to false. - 📤 Emits everything keyed by role (
volume_spec_name), includingvolumes_without_proximity_placement_group, because PPG is optional to the provider and effectively required here.
💡 Why it matters: The reason to use a volume group instead of several
terraform-azurerm-netapp-volumeinstances is co-placement — the service puts the volumes next to the compute running the database. That depends on a proximity placement group the schema marks optional, so a group built without one applies cleanly and silently fails to deliver the only thing it was chosen for. Unlike SAP HANA, Oracle imposes no protocol restriction by role — but it does forbid combining PPG with a zone, which is the constraint this module checks instead.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
rg["terraform-azurerm-resource-group"]
kv["terraform-azurerm-key-vault"]
vnet["terraform-azurerm-virtual-network: subnet delegated to Microsoft.NetApp/volumes"]
acct["terraform-azurerm-netapp-account"]
enc["terraform-azurerm-netapp-account-encryption: who holds the key"]
pool["terraform-azurerm-netapp-pool: the BILLED capacity"]
vol["terraform-azurerm-netapp-volume: what clients mount"]
snap["terraform-azurerm-netapp-snapshot: one on-demand, in the pool"]
spol["terraform-azurerm-netapp-snapshot-policy: the schedule"]
vault["terraform-azurerm-netapp-backup-vault: the destination"]
bpol["terraform-azurerm-netapp-backup-policy: the retention"]
vghana["terraform-azurerm-netapp-volume-group-sap-hana"]
vgora["terraform-azurerm-netapp-volume-group-oracle"]
rg -->|"resource_group_name, location"| acct
kv -->|"encryption_key URI"| enc
acct -->|"id, BY ID"| enc
acct -->|"account_name, BY NAME"| pool
acct -->|"account_name, BY NAME"| vault
acct -->|"account_name, BY NAME"| bpol
acct -->|"account_name, BY NAME"| spol
acct -->|"account_name, BY NAME"| vghana
acct -->|"account_name, BY NAME"| vgora
pool -->|"pool_name, BY NAME"| vol
pool -->|"capacity_pool_id, BY ID"| vghana
pool -->|"capacity_pool_id, BY ID"| vgora
vnet -->|"subnet_id, must be delegated"| vol
vol -->|"name plus pool_name, BY NAME"| snap
spol -->|"snapshot_policy_id, BY ID"| vol
vault -->|"backup_vault_id, BY ID"| vol
bpol -->|"backup_policy_id, BY ID"| vol
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class acct keystone;
class enc,pool,vol,snap,spol,vault,bpol,vghana,vgora me;
class rg,kv,vnet sib;
flowchart TB
harsh["READ FIRST: nearly every field, INCLUDING every field inside the volume blocks, forces a new volume group AND DATA IS LOST"]
why["the point of a volume group: the service places the volumes TOGETHER for latency"]
addr["name, resource_group_name, account_name, location, application_identifier the Oracle SID, group_description"]
vols["volume: a LIST of 2 to 12, more than SAP HANA's 5 because Oracle allows eight data volumes"]
spec["volume_spec_name is the ROLE: ora-data1 to ora-data8, ora-log, ora-log-mirror, ora-binary, ora-backup. Unique."]
noprot["unlike SAP HANA, Oracle places NO protocol restriction by role"]
ppgzone["proximity_placement_group_id and zone are MUTUALLY EXCLUSIVE: validated at plan"]
netf["network_features has FOUR values here: Basic, Basic_Standard, Standard, Standard_Basic"]
poolid["capacity_pool_id takes the pool's ID here, NOT its name like the standalone volume"]
req["required here but optional on a standalone volume: throughput_in_mibps, security_style, snapshot_directory_visible"]
export["export_policy_rule REQUIRED, 1 to 5 per volume, using nfsv3_enabled and nfsv41_enabled BOOLEANS not a protocol list"]
guard["validated: no 0.0.0.0/0, unique rule_index, and a rule must cover at least one protocol"]
absent["NOT available on a volume-group volume: backup policy, cool access, ARP, quota rules, buckets"]
this["terraform-azurerm-netapp-volume-group-oracle"]
vg["azurerm_netapp_volume_group_oracle.this"]
out["outputs KEYED BY ROLE: mount addresses, paths, ids, plus root-access and missing-PPG reviews"]
harsh -->|"decided at creation"| this
why -->|"the reason to use it"| this
addr -->|"identity"| this
vols -->|"the set"| this
spec -->|"identifies each"| vols
noprot -->|"contrast with HANA"| spec
ppgzone -->|"pick one placement mechanism"| vols
netf -->|"wider than HANA's two"| vols
poolid -->|"note the asymmetry"| vols
req -->|"stricter here"| vols
export -->|"access control"| vols
guard -->|"enforced"| export
absent -->|"schema fact, not omission"| this
this -->|"creates"| vg
vg -->|"emits"| out
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class vg keystone;
class harsh,why,addr,vols,spec,noprot,ppgzone,netf,poolid,req,export,guard,absent,out sib;
Resource inventory
| Resource | Count | Role |
|---|---|---|
azurerm_netapp_volume_group_oracle.this |
1 | The keystone volume group, with 2–12 volume blocks, each carrying export_policy_rule plus optional data_protection_replication and data_protection_snapshot_policy, and the group's timeouts block. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite (verified against the live provider schema):
- Nearly every field — including every field inside the
volumeblocks — forces a new Application Volume Group and loses the data on all of its volumes. - Between 2 and 12 volumes — more than the SAP HANA group's 5, because Oracle supports up to eight data volumes.
volume_spec_nameis the role and must be unique:ora-data1…ora-data8,ora-log,ora-log-mirror,ora-binary,ora-backup. proximity_placement_group_idandzoneare mutually exclusive — the provider documents this explicitly for Oracle. Validated at plan here.- Unlike the SAP HANA group, Oracle places no protocol restriction by volume role — NFSv3 and NFSv4.1 are both available on any role.
- The nested
export_policy_ruleusesnfsv3_enabled/nfsv41_enabledbooleans, not theprotocollist the standalone volume's export rules take. export_policy_ruleis required, 1–5 per volume.- Fields the standalone volume treats as optional are required here:
throughput_in_mibps,security_style,snapshot_directory_visible,volume_spec_name. capacity_pool_idtakes the pool's Resource ID, whereas the standalone volume takes a pool name.network_featureshas four values here —Basic,Basic_Standard,Standard,Standard_Basic— and it is required when using customer-managed keys.proximity_placement_group_idis optional in the schema but effectively required for Oracle.- The volume group has no tags of its own; tags live on each volume inside the
volumeblock. - Creation and deletion are slow — up to twelve volumes in one operation.
Contributoron the resource group holding the NetApp account, or a custom role coveringMicrosoft.NetApp/netAppAccounts/volumeGroups/*andMicrosoft.NetApp/netAppAccounts/capacityPools/volumes/*.Microsoft.Network/virtualNetworks/subnets/join/actionon every delegated subnet used by a volume in the group.- Read access on the NetApp account and on each capacity pool referenced.
- Read access on the proximity placement group, to place volumes against it.
- For a customer-managed key: permission to read the Key Vault private endpoint, plus the key configuration already on the account.
- An existing NetApp account and one or more capacity pools, in the same region as the group.
- A subnet carrying the
Microsoft.NetApp/volumesdelegation, with free address space for every volume — up to twelve here. - A proximity placement group, and compute placed against it. Note PPG and
zoneare mutually exclusive, so a group is pinned by one mechanism or the other. - Pool headroom for the sum of every volume's
storage_quota_in_gb. - Awareness of the Oracle sizing and placement requirements, which are a product constraint rather than a Terraform one.
- The caller configures the
provider "azurerm" { features {} }block, auth, and subscription.
terraform-azurerm-netapp-volume-group-oracle/
├── providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf # 2-12 volume list, PPG-vs-zone exclusion, export guardrails, timeouts tail
├── main.tf # keystone azurerm_netapp_volume_group_oracle.this; nested dynamic blocks
├── outputs.tf # everything keyed by volume_spec_name, plus root-access and PPG reviews
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
provider "azurerm" {
features {}
}
module "oracle_volumes" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-volume-group-oracle.git?ref=v1.0.0"
name = "vg-oracle-or1"
resource_group_name = module.anf_rg.name
account_name = module.anf_account.name # BY NAME
location = module.anf_rg.location
application_identifier = "OR1" # the Oracle SID
group_description = "Oracle OR1 production volumes"
volume = [
{
volume_spec_name = "ora-data1"
name = "vg-oracle-or1-data1"
volume_path = "oracle-or1-data1"
capacity_pool_id = var.anf_pool_id # BY ID here, not by name
subnet_id = module.anf_vnet.subnet_ids["anf"]
service_level = "Ultra"
storage_quota_in_gb = 1024
throughput_in_mibps = 400
protocols = ["NFSv4.1"]
security_style = "unix"
snapshot_directory_visible = true
# PPG or zone — never both.
proximity_placement_group_id = var.oracle_ppg_id
export_policy_rule = [{
rule_index = 1
allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = false
nfsv41_enabled = true
unix_read_write = true
}]
},
{
volume_spec_name = "ora-log"
name = "vg-oracle-or1-log"
volume_path = "oracle-or1-log"
capacity_pool_id = var.anf_pool_id
subnet_id = module.anf_vnet.subnet_ids["anf"]
service_level = "Ultra"
storage_quota_in_gb = 512
throughput_in_mibps = 250
protocols = ["NFSv4.1"]
security_style = "unix"
snapshot_directory_visible = true
proximity_placement_group_id = var.oracle_ppg_id
export_policy_rule = [{
rule_index = 1
allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = false
nfsv41_enabled = true
unix_read_write = true
}]
},
]
}
⚠️ Minimum two volumes. Every field above is immutable — changing any of them destroys the data on all the volumes in the group.
ℹ️ The caller owns the provider, its authentication, and the mandatory
features {}block. This module never declares them.
Consumes
| Input | Type | Source module |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
account_name |
string |
terraform-azurerm-netapp-account (name) |
location |
string |
caller / terraform-azurerm-netapp-account (location) |
volume[*].capacity_pool_id |
string |
terraform-azurerm-netapp-pool (id) |
volume[*].subnet_id |
string |
terraform-azurerm-virtual-network (delegated subnet) |
volume[*].proximity_placement_group_id |
string |
caller's compute composition |
volume[*].data_protection_snapshot_policy.snapshot_policy_id |
string |
terraform-azurerm-netapp-snapshot-policy (id) |
volume[*].key_vault_private_endpoint_id |
string |
terraform-azurerm-private-endpoint |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Volume group Resource ID (first) | diagnostics, RBAC |
name |
Volume group name | operational review |
account_name / resource_group_name / location |
Addressing | composition wiring |
application_identifier |
The Oracle SID | operational review |
volume_mount_ip_addresses |
Map of role → mount addresses | client configuration — keyed by role |
volume_paths |
Map of role → export path | client mount strings |
volume_ids |
Map of role → volume Resource ID | audit inventories, diagnostics |
volume_spec_names |
The roles present | review — complete or partial layout |
volumes_granting_root_access |
Roles enabling root access | security review — should normally be empty |
volume_protocols |
Map of role → protocol | review |
volumes_without_proximity_placement_group |
Roles with no PPG | latency review — non-empty is a risk |
The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:
variable "anf_pool_id" {
description = "id of an existing anf pool that these examples reference but do not create."
type = string
}
variable "oracle_ppg_id" {
description = "id of an existing oracle ppg that these examples reference but do not create."
type = string
}1 · A four-volume Oracle layout
volume = [
{ volume_spec_name = "ora-data1", /* ... */ },
{ volume_spec_name = "ora-log", /* ... */ },
{ volume_spec_name = "ora-binary", /* ... */ },
{ volume_spec_name = "ora-backup", /* ... */ },
]💡 A typical starting layout: one data volume, the redo log, the Oracle binaries, and a backup volume.
volume_spec_namesis emitted so a review can confirm whether a group is complete or deliberately partial (the minimum is 2).
2 · Scaling out to eight data volumes
volume = concat(
[for i in range(1, 9) : {
volume_spec_name = "ora-data${i}"
name = "vg-oracle-or1-data${i}"
volume_path = "oracle-or1-data${i}"
capacity_pool_id = module.pool_ultra.id
subnet_id = module.anf_vnet.subnet_ids["anf"]
service_level = "Ultra"
storage_quota_in_gb = 1024
throughput_in_mibps = 400
protocols = ["NFSv4.1"]
security_style = "unix"
snapshot_directory_visible = true
proximity_placement_group_id = var.oracle_ppg_id
export_policy_rule = [{
rule_index = 1, allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = false, nfsv41_enabled = true, unix_read_write = true
}]
}],
[ /* ora-log, ora-log-mirror, ora-binary, ora-backup */ ],
)💡 Eight data volumes is what distinguishes Oracle from SAP HANA here, and the 12-volume ceiling exists to accommodate them plus the four supporting roles. Generating the data volumes with a
forexpression keeps them consistent — but remember the whole list is force-new, so this is a creation-time shape, not something to grow later.
3 · PPG and zone are mutually exclusive
{
volume_spec_name = "ora-data1"
proximity_placement_group_id = var.oracle_ppg_id
zone = "1" # ❌
}Error: Invalid value for variable
volume[*].proximity_placement_group_id and zone are mutually exclusive — supply at most one
per volume.
⚠️ This is the Oracle-specific constraint, documented by the provider and enforced here at plan. The two are alternative placement mechanisms: PPG pins the volumes next to specific compute, a zone pins them to an availability zone. Choosing both is contradictory, and the service rejects it.
4 · No protocol restriction by role
{ volume_spec_name = "ora-data1", protocols = ["NFSv3"] } # ✅ legal
{ volume_spec_name = "ora-data1", protocols = ["NFSv4.1"] } # ✅ also legalℹ️ A real difference from the SAP HANA volume group, which restricts NFSv3 to its backup volumes only. Oracle has no such rule, so there is no equivalent validation here —
volume_protocolsis emitted for review rather than to check a constraint. Choose the protocol on the database's requirements, not on the volume's role.
5 · Four network-feature values, not two
network_features = "Standard_Basic" # legal here; rejected by the SAP HANA volume group# The full Oracle set:
Basic | Basic_Standard | Standard | Standard_Basic
ℹ️ The two mixed modes exist for migration scenarios where the volume and its peers are moving between feature sets.
⚠️ Notenetwork_featuresis required when using customer-managed keys — an easy dependency to miss, since the field otherwise looks like a pure networking choice.
6 · The PPG the schema calls optional
proximity_placement_group_id = var.oracle_ppg_idoutput "latency_risk" {
value = module.oracle_volumes.volumes_without_proximity_placement_group # expect []
}
⚠️ PPG is what pins the volumes next to the compute running the database — the entire reason to use a volume group. The schema marks it optional, so a group built without it applies cleanly and silently delivers none of that benefit. The output exists to be asserted empty.
7 · Any edit destroys the data
storage_quota_in_gb = 2048 # was 1024
⚠️ The provider's own wording: "Changing this forces a new Application Volume Group to be created and data will be lost." That covers the quota, throughput, service level, subnet — nearly everything, including fields nested insidevolume. A plan showing a replacement here is a data-loss event on every volume in the group. Size at creation.
8 · Export rules use booleans, not a protocol list
export_policy_rule = [{
rule_index = 1
allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = false
nfsv41_enabled = true # ← booleans here
unix_read_write = true
}]
⚠️ The standalone volume module instead takesprotocol = ["NFSv4.1"]. That inconsistency is why a protocol conversion breaks quietly: changeprotocolswithout flipping these booleans and the export covers a protocol the volume no longer speaks. Update both in the same change.
9 · `0.0.0.0/0` is refused, and so is a rule granting nothing
allowed_clients = ["0.0.0.0/0"] # ❌nfsv3_enabled = false
nfsv41_enabled = false # ❌🔒 The same guardrails as every other place this library renders a NetApp export policy. On an NFS export the client list is the whole of the network restriction, and a rule covering neither protocol matches clients and serves nothing — accepted by the provider, and almost never intended.
10 · Root access is opt-in, and reviewable
root_access_enabled = true # ⚠️ deliberateoutput "root_access_review" {
value = module.oracle_volumes.volumes_granting_root_access # expect []
}🔒 Defaults to
falsein every rule. Oracle installations sometimes need it on the binary volume — which is exactly why the output lists the roles that have it, so the exception is visible rather than buried in a nested list.
11 · Mixed tiers across the group
volume = [
{ volume_spec_name = "ora-data1", capacity_pool_id = module.pool_ultra.id, service_level = "Ultra", /* ... */ },
{ volume_spec_name = "ora-log", capacity_pool_id = module.pool_ultra.id, service_level = "Ultra", /* ... */ },
{ volume_spec_name = "ora-backup", capacity_pool_id = module.pool_standard.id, service_level = "Standard", /* ... */ },
]💡
capacity_pool_idandservice_levelare both per volume, so data and redo log can sit onUltrawhile the backup volume draws from a cheaperStandardpool. That is usually the right shape, and it is only possible because the pool is specified per volume rather than for the group.
12 · Outputs are keyed by role, not position
output "oracle_mounts" {
value = module.oracle_volumes.volume_mount_ip_addresses
}{
"ora-data1" = ["10.40.1.4"]
"ora-log" = ["10.40.1.5"]
"ora-binary" = ["10.40.1.6"]
"ora-backup" = ["10.40.1.7"]
}
💡 The input is a list because that is the provider's collection type, but a list index means nothing to a reader — so every output is keyed by
volume_spec_name. With up to twelve volumes, that difference matters more here than in the SAP HANA group.
13 · What a volume-group volume cannot do
# ❌ None of these exist inside a volume-group `volume` block:
# data_protection_backup_policy = { ... }
# cool_access = { ... }
# data_protection_advanced_ransomware = { ... }
# quota_rules / buckets
⚠️ A reader arriving fromterraform-azurerm-netapp-volumewill expect these. Their absence is a schema fact, not an omission: a volume-group volume supports neither backup policies, cool access, Advanced Ransomware Protection, quota rules, nor buckets. If a workload needs them it needs standalone volumes — and loses co-placement.
14 · Timeouts, and why they are long
timeouts = {
create = "4h"
delete = "4h"
}
⚠️ Up to twelve volumes provision in a single operation, so this is the slowest resource in the family. Raise the timeouts generously, and note that a failed create can leave a partially-provisioned group that must be removed before retrying.
15 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
# 1 · The resource group.
module "anf_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-oracle-eastus2"
location = "eastus2"
}
# 2 · The delegated subnet.
module "anf_vnet" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
name = "vnet-oracle-eastus2"
resource_group_name = module.anf_rg.name
location = module.anf_rg.location
address_space = ["10.40.0.0/16"]
subnets = {
anf = {
address_prefixes = ["10.40.1.0/24"]
delegations = [{
name = "netapp"
service_delegation = {
name = "Microsoft.NetApp/volumes"
actions = ["Microsoft.Network/networkinterfaces/*", "Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}]
}
}
}
# 3 · The account and two pools, so data/log and backup can sit on different tiers.
module "anf_account" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-account.git?ref=v1.0.0"
name = "anf-oracle-eastus2"
resource_group_name = module.anf_rg.name
location = module.anf_rg.location
}
module "pool_ultra" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-pool.git?ref=v1.0.0"
name = "pool-oracle-ultra"
resource_group_name = module.anf_rg.name
account_name = module.anf_account.name
location = module.anf_rg.location
service_level = "Ultra"
size_in_tb = 8
}
module "pool_standard" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-pool.git?ref=v1.0.0"
name = "pool-oracle-standard"
resource_group_name = module.anf_rg.name
account_name = module.anf_account.name
location = module.anf_rg.location
service_level = "Standard"
size_in_tb = 4
}
# 4 · A snapshot policy for the data and log volumes.
module "anf_snapshot_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-snapshot-policy.git?ref=v1.0.0"
name = "sp-oracle-critical"
resource_group_name = module.anf_rg.name
account_name = module.anf_account.name
location = module.anf_rg.location
hourly_schedule = { minute = 0, snapshots_to_keep = 12 }
daily_schedule = { hour = 3, minute = 0, snapshots_to_keep = 7 }
}
# 5 · The volume group — this module. PPG on every volume, NO zone anywhere (they are
# mutually exclusive), mixed tiers, snapshot policy on the critical volumes.
module "oracle_volumes" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-netapp-volume-group-oracle.git?ref=v1.0.0"
name = "vg-oracle-or1"
resource_group_name = module.anf_rg.name
account_name = module.anf_account.name
location = module.anf_rg.location
application_identifier = "OR1"
group_description = "Oracle OR1 production volumes"
volume = [
{
volume_spec_name = "ora-data1"
name = "vg-oracle-or1-data1"
volume_path = "oracle-or1-data1"
capacity_pool_id = module.pool_ultra.id
subnet_id = module.anf_vnet.subnet_ids["anf"]
service_level = "Ultra"
storage_quota_in_gb = 2048
throughput_in_mibps = 500
protocols = ["NFSv4.1"]
security_style = "unix"
snapshot_directory_visible = true
network_features = "Standard"
# PPG, and therefore NO zone — the two are mutually exclusive.
proximity_placement_group_id = var.oracle_ppg_id
export_policy_rule = [{
rule_index = 1
allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = false
nfsv41_enabled = true
unix_read_write = true
}]
data_protection_snapshot_policy = { snapshot_policy_id = module.anf_snapshot_policy.id }
},
{
volume_spec_name = "ora-log"
name = "vg-oracle-or1-log"
volume_path = "oracle-or1-log"
capacity_pool_id = module.pool_ultra.id
subnet_id = module.anf_vnet.subnet_ids["anf"]
service_level = "Ultra"
storage_quota_in_gb = 512
throughput_in_mibps = 300
protocols = ["NFSv4.1"]
security_style = "unix"
snapshot_directory_visible = true
network_features = "Standard"
proximity_placement_group_id = var.oracle_ppg_id
export_policy_rule = [{
rule_index = 1
allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = false
nfsv41_enabled = true
unix_read_write = true
}]
data_protection_snapshot_policy = { snapshot_policy_id = module.anf_snapshot_policy.id }
},
{
volume_spec_name = "ora-backup"
name = "vg-oracle-or1-backup"
volume_path = "oracle-or1-backup"
capacity_pool_id = module.pool_standard.id # cheaper tier
subnet_id = module.anf_vnet.subnet_ids["anf"]
service_level = "Standard"
storage_quota_in_gb = 4096
throughput_in_mibps = 100
protocols = ["NFSv3"] # legal on ANY role in an Oracle group
security_style = "unix"
snapshot_directory_visible = false
network_features = "Standard"
proximity_placement_group_id = var.oracle_ppg_id
export_policy_rule = [{
rule_index = 1
allowed_clients = ["10.40.2.0/24"]
nfsv3_enabled = true
nfsv41_enabled = false
unix_read_write = true
}]
},
]
timeouts = { create = "4h", delete = "4h" }
}
output "oracle_storage" {
description = "Mount targets by role, plus the two reviews that matter."
value = {
mounts = module.oracle_volumes.volume_mount_ip_addresses
paths = module.oracle_volumes.volume_paths
protocols = module.oracle_volumes.volume_protocols
root_access = module.oracle_volumes.volumes_granting_root_access # expect []
no_ppg = module.oracle_volumes.volumes_without_proximity_placement_group # expect []
}
}💡 Three things this composition demonstrates. No volume sets
zone, because every one sets a PPG and the two are mutually exclusive — the module rejects combining them. The protocol choice is free here:ora-backupuses NFSv3 not because its role permits it (Oracle has no such rule) but because that suits a backup volume. And the two pools exist becausecapacity_pool_idis per volume. Both review outputs are expected empty —no_ppgespecially, since a non-empty list means the co-placement you chose a volume group for is not happening. Output names on sibling modules are illustrative; match them to the versions you pin.
Required: name, resource_group_name, account_name, location, application_identifier, group_description, volume (2–12 entries).
Per volume — required: volume_spec_name, name, volume_path, capacity_pool_id, subnet_id, service_level, storage_quota_in_gb, throughput_in_mibps, protocols, security_style, snapshot_directory_visible, export_policy_rule.
Per volume — optional: proximity_placement_group_id (effectively required; excludes zone), zone, network_features (four values), encryption_key_source, key_vault_private_endpoint_id, tags, data_protection_replication, data_protection_snapshot_policy.
Universal tail: timeouts. The group has no tags of its own — tags live per volume.
Full object() schemas
variable "name" { type = string } # ⚠️ force-new AND DATA IS LOST
variable "resource_group_name" { type = string } # ⚠️ same
variable "account_name" { type = string } # ⚠️ same; the NAME, not the Resource ID
variable "location" { type = string } # ⚠️ same
variable "application_identifier" {
# The Oracle SID. VALIDATED non-empty only — the 3-character limit the provider documents for
# SAP HANA volume groups is NOT documented for Oracle, so this module does not invent one.
type = string
}
variable "group_description" { type = string } # required by the provider; also force-new
variable "volume" {
# A LIST of 2-12 — more than SAP HANA's 5, because Oracle allows eight data volumes. A list,
# matching the provider's own collection type: a volume group is ONE service-side object whose
# members are submitted together, and `volume_spec_name` already carries each volume's role.
# The OUTPUTS are keyed by volume_spec_name instead.
#
# ⚠️ EVERY field below is immutable — changing any of them forces a new volume group and loses
# the data on ALL of its volumes.
#
# VALIDATED: the 2-12 count; the twelve-member volume_spec_name value set AND uniqueness;
# **the PPG-versus-zone mutual exclusion**; single-protocol only; security_style, service_level,
# the FOUR-value network_features set, and zone value sets; positive quota and throughput;
# 1-5 export rules with unique in-range rule_index; non-empty allowed_clients; the 0.0.0.0/0
# refusal; a rule must cover at least one protocol; the CMK pairing both ways; and the
# replication frequency enum.
#
# NOTE: unlike the SAP HANA group, there is NO protocol restriction by role here.
type = list(object({
volume_spec_name = string # ora-data1..ora-data8 | ora-log | ora-log-mirror |
# ora-binary | ora-backup
name = string
volume_path = string
capacity_pool_id = string # the pool's ID — NOT its name
subnet_id = string # must carry the Microsoft.NetApp/volumes delegation
service_level = string
storage_quota_in_gb = number
throughput_in_mibps = number # REQUIRED here, optional on a standalone volume
protocols = list(string) # exactly one
security_style = string # REQUIRED here
snapshot_directory_visible = bool # REQUIRED here
proximity_placement_group_id = optional(string) # effectively required; EXCLUDES zone
zone = optional(string) # EXCLUDES proximity_placement_group_id
network_features = optional(string) # Basic | Basic_Standard | Standard |
# Standard_Basic — required with a CMK
encryption_key_source = optional(string)
key_vault_private_endpoint_id = optional(string)
tags = optional(map(string))
export_policy_rule = list(object({
rule_index = number
allowed_clients = set(string) # 🔒 the access control; 0.0.0.0/0 REFUSED
nfsv3_enabled = bool # ⚠️ booleans, NOT the standalone volume's protocol list
nfsv41_enabled = bool
unix_read_only = optional(bool)
unix_read_write = optional(bool)
root_access_enabled = optional(bool, false) # 🔒 opt-in
}))
data_protection_replication = optional(object({ /* ... */ }))
data_protection_snapshot_policy = optional(object({ snapshot_policy_id = string }))
}))
}
variable "timeouts" {
# Up to twelve volumes in one operation — the slowest resource in the family.
type = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })
default = null
}
# NOTE: the group has no `tags` of its own. Per-volume tags are inside the `volume` object.
# NOTE: a volume-group volume supports NO backup policy, cool access, ARP, quota rules, or buckets.| Output | Description | Notes |
|---|---|---|
id |
The volume group's Resource ID. | Emitted first. |
name |
The volume group name. | |
account_name / resource_group_name / location |
Addressing. | |
application_identifier |
The Oracle SID. | |
volume_mount_ip_addresses |
Role → mount addresses. | Keyed by role, not list position. |
volume_paths |
Role → export path. | With the mounts, the full target per role. |
volume_ids |
Role → volume Resource ID. | |
volume_spec_names |
Roles present. | Confirms a complete vs partial layout. |
volumes_granting_root_access |
Roles enabling root access. | Assert empty. |
volume_protocols |
Role → protocol. | For review — Oracle has no per-role rule. |
volumes_without_proximity_placement_group |
Roles with no PPG. | Assert empty — non-empty means no co-placement. |
No secret is emitted; this resource carries none. Customer-managed keys are referenced by private-endpoint ID, not by material.
- This resource punishes editing more than any other in the library. The provider marks nearly every field — including every field nested inside
volume— as forcing a new volume group and losing the data. The documentation is therefore shaped around getting it right at creation, and a plan proposing replacement should be read as a data-loss event rather than a routine diff. - The PPG-versus-
zoneexclusion is the Oracle-specific check. The two are alternative placement mechanisms — PPG pins volumes next to specific compute, a zone pins them to an availability zone — and the provider documents them as incompatible. Validated at plan, because the combination is contradictory rather than merely unsupported. - Oracle imposes no protocol restriction by role, which is a genuine difference from the SAP HANA group and worth stating explicitly: a reader who knows that module will look for the equivalent rule and there isn't one.
volume_protocolsis emitted for review rather than to enforce anything. network_featureshas four values here rather than two, including two mixed modes for migration scenarios — and it is required when using customer-managed keys, a dependency that is easy to miss because the field otherwise reads as a pure networking choice.- PPG is optional in the schema and effectively required in practice, the sharpest mismatch in both volume-group modules. Co-placing the volumes next to the database compute is the reason to choose a volume group, and a group built without one applies cleanly while delivering none of it.
volumes_without_proximity_placement_groupexists to be asserted empty. volumeis a list, and that is a deliberate departure from this suite's keyed-map convention. A volume group is one service-side object whose members are submitted together, andvolume_spec_namealready carries each volume's role. The outputs are keyed by role — which matters more here than in the SAP HANA group, since a twelve-entry list index is meaningless to a reader whileora-data3is not.- The export-policy shape differs from the standalone volume's —
nfsv3_enabled/nfsv41_enabledbooleans versus aprotocollist — and that difference is why a protocol conversion breaks quietly if only one side is updated. - The same export guardrails apply as everywhere else in this library:
0.0.0.0/0refused,rule_indexunique and in range, non-empty client lists, a rule must cover a protocol,root_access_enableddefaulting to false. Consistency across the library matters more than per-resource variation. application_identifieris checked only for non-emptiness. The 3-character limit is documented for SAP HANA volume groups and not for Oracle, so this module validates what is stated rather than guessing a constraint and rejecting valid input.- This and the SAP HANA module are deliberately separate rather than one module with an application switch: distinct ARM resource types with genuinely different constraints — volume counts, role names,
network_featuressets, and one having a protocol rule the other lacks. features {}dependence. The module carries noprovider {}block. If it appears not to initialize in isolation, the cause is a missing caller-sideprovider "azurerm" { features {} }.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Network access | export_policy_rule is required per volume — nothing is implicit |
write the rules |
| Export breadth | 0.0.0.0/0 refused at plan |
— (no opt-out) |
| Ineffective rules | a rule covering neither protocol refused | — (no opt-out) |
| Root on the client | root_access_enabled = false per rule |
set true (surfaced in an output) |
| Placement coherence | PPG-versus-zone exclusion validated at plan |
— (no opt-out) |
| Rule ordering | rule_index unique and 1–5 |
— (no opt-out) |
| Co-placement | volumes_without_proximity_placement_group emitted |
omit PPG (visible in the output) |
| Encryption at rest | always on; CMK pairing validated both ways | supply a CMK |
| Invented constraints | application_identifier checked only non-empty — no guessed length limit |
— (deliberate) |
| Posture visibility | role-keyed outputs plus root-access and PPG reviews | — (no opt-out) |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module with
?ref=v1.0.0— never a branch. - This library is plan-only during authoring; a human runs
terraform plan/applyfrom CI against real credentials. - Treat any plan that replaces this resource as data loss on every volume in the group. Get the sizing, tiers, protocols and subnets right at creation.
- Decide PPG or zone before applying — they cannot be combined, and both are force-new.
- Confirm every delegated subnet carries
Microsoft.NetApp/volumesand that you holdsubnets/join/action. - Raise
createanddeletetimeouts — up to twelve volumes provision in one operation. A failed create can leave a partial group that must be removed before retrying. - After apply, assert
volumes_without_proximity_placement_groupandvolumes_granting_root_accessare both empty.
terraform validateproves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises everyvalidation {}block: the 2–12 volume count; the twelve-membervolume_spec_namevalue set and uniqueness; the PPG-versus-zoneexclusion; single-protocol-only; thesecurity_style,service_level, four-valuenetwork_featuresandzonevalue sets; positive quota and throughput; the 1–5 export-rule count with unique in-rangerule_index; non-emptyallowed_clients; the0.0.0.0/0refusal; the covers-a-protocol rule; the customer-managed-key pairing both ways; and the replication frequency enum.terraform fmt -checkenforces canonical formatting.- Neither command calls Azure. Only
terraform plan(run by a human, from CI) exercises the ARM API — the module ships without any cloud apply. - What only apply exercises: the subnet delegations, pool headroom for the summed quotas, regional support for the chosen
network_features, and whether the PPG accepts the volumes. - What nothing exercises: whether the layout satisfies Oracle's own sizing and placement requirements, and whether the PPG actually contains the database compute. Both are product-level facts outside Terraform's view — which is why
volumes_without_proximity_placement_groupis emitted as the closest available proxy.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
id = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-oracle-eastus2/providers/Microsoft.NetApp/netAppAccounts/anf-oracle-eastus2/volumeGroups/vg-oracle-or1"
name = "vg-oracle-or1"
application_identifier = "OR1"
volume_spec_names = ["ora-backup", "ora-data1", "ora-log"]
volume_mount_ip_addresses = {
"ora-backup" = ["10.40.1.6"]
"ora-data1" = ["10.40.1.4"]
"ora-log" = ["10.40.1.5"]
}
volume_paths = {
"ora-backup" = "oracle-or1-backup"
"ora-data1" = "oracle-or1-data1"
"ora-log" = "oracle-or1-log"
}
volume_protocols = {
"ora-backup" = "NFSv3"
"ora-data1" = "NFSv4.1"
"ora-log" = "NFSv4.1"
}
volumes_granting_root_access = []
volumes_without_proximity_placement_group = []
| Symptom | Cause | Fix |
|---|---|---|
Provider configuration not present / features error |
No caller-side provider "azurerm" { features {} }. |
Add the provider block with features {} in the root module. |
| Plan error: PPG and zone are mutually exclusive | A volume set both. | Choose one placement mechanism per volume. |
| Plan proposes replacing the volume group | Almost any field changed — including nested ones. | This is data loss. Do not apply casually; plan a migration. |
| Plan error: requires between 2 and 12 volumes | Too few or too many. | An Oracle group takes 2–12. |
Plan error: volume_spec_name must be one of |
A role outside the twelve-member set. | Use ora-data1–ora-data8, ora-log, ora-log-mirror, ora-binary, ora-backup. |
Plan error: volume_spec_name must be unique |
Two volumes share a role. | Each role appears at most once. |
Plan error: 0.0.0.0/0 refused |
An all-addresses export. | Narrow the CIDR. |
Plan error: must set nfsv3_enabled or nfsv41_enabled |
A rule covering neither protocol. | Set the flag matching the volume's protocol. |
| Apply fails: customer-managed key rejected | network_features was not set; it is required with a CMK. |
Set network_features on the volumes using a CMK. |
| Latency is poor despite Ultra tier | No proximity placement group, so the volumes are not co-placed. | Check volumes_without_proximity_placement_group; add the PPG (force-new). |
| Apply fails: subnet not delegated | A subnet lacks Microsoft.NetApp/volumes. |
Add the delegation and subnets/join/action. |
| Apply fails: insufficient pool capacity | Summed storage_quota_in_gb exceeds the pools' free space. |
Grow the pools (size_in_tb updates in place). |
| Mounts stop after a protocol change | The export rule booleans still reflect the old protocol. | Update nfsv3_enabled / nfsv41_enabled in the same change. |
| Cannot add a backup policy to a volume | Volume-group volumes do not support one. | Use standalone volumes if backup policies are required. |
| Apply times out | Up to twelve volumes provision in one operation. | Raise create; remove any partial group before retrying. |
- azurerm provider —
azurerm_netapp_volume_group_oracle - Understand application volume groups for Oracle
- Requirements and considerations for application volume group for Oracle
- Deploy an application volume group for Oracle
- Proximity placement groups in Azure
- Sibling modules:
terraform-azurerm-netapp-account,terraform-azurerm-netapp-pool,terraform-azurerm-netapp-volume,terraform-azurerm-netapp-snapshot-policy,terraform-azurerm-netapp-volume-group-sap-hana,terraform-azurerm-virtual-network,terraform-azurerm-private-endpoint. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."