Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure NetApp Volume Group (Oracle) Terraform Module

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. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🧱 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 nested volume blocks, as "forces a new Application Volume Group and data will be lost".
  • ✅ Enforces the Oracle-specific rule at plan: proximity_placement_group_id and zone are mutually exclusive — a volume is pinned by one mechanism or the other, never both.
  • 🔢 network_features here 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/0 refused, rule_index unique, every rule must cover a protocol, root_access_enabled defaulting to false.
  • 📤 Emits everything keyed by role (volume_spec_name), including volumes_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-volume instances 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.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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.

✅ Provider / Versions

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 volume blocks — 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_name is the role and must be unique: ora-data1 … ora-data8, ora-log, ora-log-mirror, ora-binary, ora-backup.
  • proximity_placement_group_id and zone are 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_rule uses nfsv3_enabled / nfsv41_enabled booleans, not the protocol list the standalone volume's export rules take.
  • export_policy_rule is 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_id takes the pool's Resource ID, whereas the standalone volume takes a pool name.
  • network_features has four values here — Basic, Basic_Standard, Standard, Standard_Basic — and it is required when using customer-managed keys.
  • proximity_placement_group_id is 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 volume block.
  • Creation and deletion are slow — up to twelve volumes in one operation.

🔑 Required Azure RBAC Roles / Permissions

  • Contributor on the resource group holding the NetApp account, or a custom role covering Microsoft.NetApp/netAppAccounts/volumeGroups/* and Microsoft.NetApp/netAppAccounts/capacityPools/volumes/*.
  • Microsoft.Network/virtualNetworks/subnets/join/action on 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.

Azure Prerequisites

  • An existing NetApp account and one or more capacity pools, in the same region as the group.
  • A subnet carrying the Microsoft.NetApp/volumes delegation, with free address space for every volume — up to twelve here.
  • A proximity placement group, and compute placed against it. Note PPG and zone are 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.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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_names is 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 for expression 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_protocols is 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. ⚠️ Note network_features is 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_id
output "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 inside volume. 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 takes protocol = ["NFSv4.1"]. That inconsistency is why a protocol conversion breaks quietly: change protocols without 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 # ⚠️ deliberate
output "root_access_review" {
  value = module.oracle_volumes.volumes_granting_root_access # expect []
}

🔒 Defaults to false in 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_id and service_level are both per volume, so data and redo log can sit on Ultra while the backup volume draws from a cheaper Standard pool. 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 from terraform-azurerm-netapp-volume will 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-backup uses NFSv3 not because its role permits it (Oracle has no such rule) but because that suits a backup volume. And the two pools exist because capacity_pool_id is per volume. Both review outputs are expected empty — no_ppg especially, 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.


📥 Inputs

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.

🧾 Outputs

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.

🧠 Architecture Notes

  • 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-zone exclusion 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_protocols is emitted for review rather than to enforce anything.
  • network_features has 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_group exists to be asserted empty.
  • volume is 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, and volume_spec_name already 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 while ora-data3 is not.
  • The export-policy shape differs from the standalone volume's — nfsv3_enabled / nfsv41_enabled booleans versus a protocol list — 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/0 refused, rule_index unique and in range, non-empty client lists, a rule must cover a protocol, root_access_enabled defaulting to false. Consistency across the library matters more than per-resource variation.
  • application_identifier is 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_features sets, and one having a protocol rule the other lacks.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

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)

🚀 Runbook

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 / apply from 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/volumes and that you hold subnets/join/action.
  • Raise create and delete timeouts — 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_group and volumes_granting_root_access are both empty.

🧪 Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises every validation {} block: the 2–12 volume count; the twelve-member volume_spec_name value set and uniqueness; the PPG-versus-zone exclusion; single-protocol-only; the security_style, service_level, four-value network_features and zone value sets; positive quota and throughput; the 1–5 export-rule count with unique in-range rule_index; non-empty allowed_clients; the 0.0.0.0/0 refusal; the covers-a-protocol rule; the customer-managed-key pairing both ways; and the replication frequency enum.
  • terraform fmt -check enforces 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_group is emitted as the closest available proxy.

💬 Example Output

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 = []

🔍 Troubleshooting

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.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."