Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Oracle Exadata Infrastructure Terraform Module

Provisions Cloud Exadata Infrastructure β€” the rack capacity Oracle Database@Azure VM clusters are placed onto (azurerm_oracle_exadata_infrastructure). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Immutability

🧩 Overview

  • πŸ–₯️ Provisions the Exadata rack that VM clusters are placed onto. The bottom of the family β€” nothing else in it can exist without one.
  • ⚠️ This is not a metadata record. It is provisioned hardware with a price attached.
  • πŸ”’ Every argument is force-new except tags β€” compute count, storage count, shape, zones, display_name, the maintenance window, customer contacts. A plan showing a replacement rebuilds a rack, taking every cluster and database on it.
  • 🚫 lifecycle is not valid inside a module block, so a caller cannot add ignore_changes to guard against that. Reading the plan is the only safeguard.
  • ⏱️ patching_mode = "NonRolling" takes the system down. Rolling is the service default and this module's default, and uses_nonrolling_patching is emitted so a change review can find the exception.
  • πŸ“… The maintenance window is force-new in its entirety, so it cannot be tuned later.
  • 🐌 Provisioning takes hours. Cancelling part-way leaves state to reconcile on both clouds.

πŸ’‘ Why it matters: Most modules in this library reward you for iterating β€” change a value, plan, adjust. This one punishes it. The numbers here are a purchasing decision with no scale-up, the maintenance window is set once, and even a cosmetic rename costs a rack. The documentation is arranged around that: what cannot be changed, said before what can.

❀️ 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"]
  vnet["terraform-azurerm-virtual-network: the subnet must be DELEGATED for Oracle Database@Azure"]
  kv["terraform-azurerm-key-vault: holds the admin_password, read at plan time"]
  nsg["terraform-azurerm-network-security-group: the only thing guarding the SCAN listener ports"]
  anchor["terraform-azurerm-oracle-resource-anchor: links the resource group to an OCI COMPARTMENT. No location input, it is computed."]
  exa["terraform-azurerm-oracle-exadata-infrastructure: the RACK. Every argument force-new except tags."]
  vault["terraform-azurerm-oracle-exascale-database-storage-vault: sized ONCE, there is NO grow operation"]
  cluster["terraform-azurerm-oracle-cloud-vm-cluster: the GI cluster. Use hostname_actual, not hostname, for connections."]
  dbsrv["azurerm_oracle_db_servers DATA SOURCE: OCI OCIDs that do not exist until the rack does"]
  adb["terraform-azurerm-oracle-autonomous-database: keystone plus for_each BACKUPS. Network posture is a ONE-WAY DOOR."]
  cfb["terraform-azurerm-oracle-autonomous-database-clone-from-backup: a POINT-IN-TIME restore into a NEW database"]
  cfd["terraform-azurerm-oracle-autonomous-database-clone-from-database: a clone of CURRENT state, optionally refreshable"]
  note["PEERS, NOT CHILDREN: both clones import to the SAME Oracle.Database/autonomousDatabases path the keystone uses, so each IS an autonomous database"]

  rg -->|"resource_group_name"| anchor
  rg -->|"resource_group_name, location"| exa
  rg -->|"resource_group_name, location"| vault
  rg -->|"resource_group_name, location"| cluster
  rg -->|"resource_group_name, location"| adb
  anchor -->|"REQUIRED FIRST, but NO module consumes its id, so use depends_on"| exa
  anchor -->|"same invisible dependency"| adb
  exa -->|"id as cloud_exadata_infrastructure_id"| cluster
  exa -->|"read AFTER it exists"| dbsrv
  dbsrv -->|"OCIDs as db_servers"| cluster
  vault -->|"NO Terraform link exists. Aligned by REGION and ZONE only."| cluster
  vnet -->|"id plus a delegated subnet id"| cluster
  vnet -->|"id plus subnet id, the PRIVATE posture"| adb
  nsg -->|"restricts the unencrypted SCAN port 1521"| cluster
  kv -->|"secret read as admin_password"| adb
  kv -->|"secret read as admin_password"| cfb
  kv -->|"secret read as admin_password"| cfd
  adb -->|"id as source_autonomous_database_id"| cfb
  adb -->|"id as source_autonomous_database_id"| cfd
  note -->|"why they are separate modules"| cfb
  note -->|"why they are separate modules"| cfd

  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 exa keystone;
  class anchor,vault,cluster,adb,cfb,cfd me;
  class rg,vnet,kv,nsg,dbsrv,note sib;
Loading

🧬 What this module builds

flowchart TB
  what["THE RACK. Exadata hardware capacity, and the bottom of the family: nothing else in it can exist without one."]
  notmeta["THIS IS NOT A METADATA RECORD. It is provisioned hardware with a price attached."]
  forcenew["AND EVERY ARGUMENT IS FORCE-NEW EXCEPT TAGS: compute count, storage count, shape, zones, display_name, the maintenance window, customer_contacts, the server model types."]
  plan["so a plan showing a replacement TEARS DOWN AND REBUILDS A RACK, taking every VM cluster and database on it"]
  nolifecycle["and lifecycle is NOT valid inside a module block, so a caller CANNOT add ignore_changes to protect against it. READING THE PLAN IS THE ONLY SAFEGUARD."]
  capacity["compute_count and storage_count are a PURCHASING DECISION with NO scale-up. Their legal ranges depend on shape, so only the shape of the number is validated."]
  shapeenum["shape itself is deliberately NOT validated: the catalogue gains new hardware generations, so a hard-coded list would reject a valid current shape"]
  patch["patching_mode is the one field here with a genuinely dangerous value: NonRolling patches the whole rack AT ONCE and takes the system DOWN"]
  rolling["Rolling patches node by node and is both the service default and this module's default when a window is given without one, so the safe value is VISIBLE in the rendered config rather than implied by absence"]
  window["and the maintenance window is force-new IN ITS ENTIRETY, so it cannot be tuned later. Getting it wrong and correcting it is a FAR LARGER outage than any maintenance run."]
  weeks["weeks_of_month accepts 1 to 4 only. Weeks start on the 1st, 8th, 15th and 22nd, and the FIFTH week of a month is not schedulable."]
  contacts["customer_contacts is force-new too, so a typo in a notification address is permanent in practice. Prefer a TEAM ALIAS over an individual."]
  zones["zones are force-new, and VM CLUSTERS AND EXASCALE VAULTS MUST MATCH THIS ZONE. Nothing in any plan checks that alignment across modules."]
  ocids["the db_server OCIDs a VM cluster needs DO NOT EXIST until this resource does, and they are OCI OCIDs not Azure Resource IDs. A composition reads them with a data source afterwards."]
  slow["provisioning takes HOURS. Cancelling a partially-applied operation leaves state to reconcile on BOTH clouds, and this is the most expensive resource in the family to leave half-created."]
  this["terraform-azurerm-oracle-exadata-infrastructure"]
  keystone["azurerm_oracle_exadata_infrastructure.this"]

  what -->|"read this first"| notmeta
  notmeta -->|"and"| forcenew
  forcenew -->|"consequence"| plan
  plan -->|"no guard rail"| nolifecycle
  nolifecycle -->|"lifecycle"| this
  capacity -->|"permanent"| shapeenum
  shapeenum -->|"why no enum"| this
  patch -->|"contrast"| rolling
  rolling -->|"default"| window
  window -->|"and"| weeks
  weeks -->|"validated"| this
  contacts -->|"permanent"| this
  zones -->|"unchecked across modules"| this
  ocids -->|"discovered later"| this
  slow -->|"expectation"| this
  this -->|"creates"| keystone

  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 keystone keystone;
  class what,notmeta,forcenew,plan,nolifecycle,capacity,shapeenum,patch,rolling,window,weeks,contacts,zones,ocids,slow sib;
Loading

Resource inventory

Resource Count Notes
azurerm_oracle_exadata_infrastructure.this 1 The keystone. Every argument force-new except tags.
maintenance_window block 0..1 Optional, force-new in its entirety.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Oracle.Database β€” a third-party namespace
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite β€” confirmed against the live provider schema and its documentation:

  • Every argument is force-new except tags. The provider documents "Changing this forces a new Cloud Exadata Infrastructure to be created" on compute_count, display_name, location, name, resource_group_name, shape, storage_count, zones, database_server_type, storage_server_type, customer_contacts and maintenance_window.
  • display_name is force-new, unlike most display_name fields in this library.
  • customer_contacts is force-new, so a wrong notification address is permanent in practice.
  • maintenance_window is force-new in its entirety.
  • patching_mode is Rolling or NonRolling, defaulting to Rolling. Oracle's documentation states that non-rolling patching involves system down time.
  • weeks_of_month accepts 1 to 4. Weeks start on the 1st, 8th, 15th and 22nd; maintenance cannot be scheduled for the fifth week of a month.
  • lead_time_in_weeks accepts 1 to 4.
  • compute_count and storage_count have no scale-up, and their legal ranges depend on shape.
  • Oracle.Database must be registered on the subscription, and RBAC is written against Oracle.Database/*.
  • lifecycle is not valid inside a module block, so a caller cannot add ignore_changes for a resource inside a module.

πŸ”‘ Required Azure RBAC Roles / Permissions

Scope Role / permission Why
The resource group Contributor, or a custom role with Oracle.Database/cloudExadataInfrastructures/* Creating the infrastructure is a write against the Oracle.Database provider.

⚠️ Least-privilege custom roles for this family are written against Oracle.Database/*, not Microsoft.*. A role scoped to Microsoft providers grants nothing here.

⚠️ This is the most consequential write permission in the family. Whoever can apply this configuration can provision β€” or destroy β€” Exadata rack capacity. Treat it as a spend authorisation as much as an access grant.

Azure Prerequisites

  • The Oracle.Database resource provider registered on the subscription.
  • Oracle Database@Azure onboarding completed for the tenant β€” the marketplace subscription and the Azure-to-Oracle account link.
  • An Oracle Resource Anchor in the target resource group β€” terraform-azurerm-oracle-resource-anchor.
  • A region where Oracle Database@Azure is offered, with the chosen shape available in it. The supported region set is smaller than Azure's footprint, and shape availability varies within it.
  • Quota and commercial approval for the requested capacity. compute_count and storage_count are not adjustable later, so agree the numbers before the apply.
  • Time. Provisioning takes hours.

πŸ“ Module Structure

terraform-azurerm-oracle-exadata-infrastructure/
β”œβ”€β”€ providers.tf   # required_version + the pinned azurerm provider. No provider block.
β”œβ”€β”€ variables.tf   # name, resource_group_name, location, display_name, shape, zones, counts,
β”‚                  # server types, maintenance_window, customer_contacts, tags, timeouts
β”œβ”€β”€ main.tf        # the keystone rack + its optional maintenance window
β”œβ”€β”€ outputs.tf     # id, name, location, zones, shape, counts, three derived posture flags
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "exadata" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-exadata-infrastructure.git?ref=v1.0.0"

  name                = "exa-oracle-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  display_name  = "Oracle Production Exadata"
  shape         = "Exadata.X9M"
  zones         = ["2"]
  compute_count = 2
  storage_count = 3

  customer_contacts = ["oracle-platform@example.com"] # a team alias β€” force-new

  depends_on = [module.oracle_anchor] # the ordering Terraform cannot infer
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

⚠️ Every value above except the tags is permanent. See example 1 before running this for real.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group β†’ name
location string caller decision β€” an Oracle Database@Azure region offering the shape
shape string caller decision β€” a hardware shape from the service catalogue
compute_count / storage_count number caller decision β€” a purchasing decision
zones set(string) caller decision β€” must match the clusters and vaults that will use it
maintenance_window object({...}) caller decision β€” patching_mode decides whether patching causes an outage
customer_contacts list(string) caller decision β€” role addresses, passed to Oracle

Emits

Output Description Consumed by
id The infrastructure's Resource ID. terraform-azurerm-oracle-cloud-vm-cluster β†’ cloud_exadata_infrastructure_id
name The name. Force-new. review
location The Azure region. Force-new. review
zones The availability zones. Force-new. placement review
shape The hardware shape. Force-new. review
compute_count / storage_count Provisioned capacity. Force-new. cost review
uses_nonrolling_patching Derived β€” true means patching takes the system down. change review
has_custom_maintenance_window Derived β€” whether Oracle's own scheduling was overridden. review
notifies_customer_contacts Derived β€” whether Oracle has anywhere to send notifications. review

πŸ“š Example Library

Values these examples reference but do not create are declared inputs:

variable "subnet_id" {
  description = "subnet id of an existing resource these examples reference."
  type        = string
}

variable "virtual_network_id" {
  description = "virtual network id of an existing resource these examples reference."
  type        = string
}

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 "vnet_id" {
  description = "id of an existing vnet that these examples reference but do not create."
  type        = string
}

variable "vnet_subnet_ids" {
  description = "subnet ids of an existing vnet that these examples reference but do not create."
  type        = map(string)
}
1 · ⚠️ Read this before your first apply: everything is permanent
Force-new (a replacement rebuilds the rack):
  name  resource_group_name  location  display_name  shape  zones
  compute_count  storage_count  database_server_type  storage_server_type
  customer_contacts  maintenance_window (in its entirety)

Mutable:
  tags

⚠️ A plan showing a replacement here is a plan that tears down and rebuilds a rack, taking every VM cluster and database on it. There is no in-place scale-up, no re-zoning, and no cosmetic rename.

⚠️ You cannot guard against it with ignore_changes. lifecycle is not valid inside a module block, so a caller has no way to protect a field of a resource inside a module. Reading the plan is the only safeguard that exists.

πŸ’‘ The practical discipline: treat this module's inputs as a purchase order rather than a configuration file. Agree the numbers, apply once, and change only tags afterwards.

2 Β· Capacity is a purchasing decision with no scale-up
shape         = "Exadata.X9M"
compute_count = 2  # ⚠️ no scale-up β€” sizing is permanent
storage_count = 3  # ⚠️ same

⚠️ Neither count can be increased later. Running out of compute means provisioning a second rack, not growing this one.

ℹ️ The legal minimum and maximum for each depend on shape, so this module validates only that they are positive whole numbers. A count the shape does not support fails at apply β€” deliberately, because a guessed range would reject a valid configuration.

Error: Invalid value for variable

  compute_count must be a positive whole number of compute servers.

πŸ’‘ Size for the workload you expect over the rack's life, not the workload on day one. The cost of over-provisioning is money; the cost of under-provisioning is a second rack and a migration.

3 Β· `shape` is deliberately not validated
shape = "Exadata.X9M"

ℹ️ No enum. The shape catalogue gains new hardware generations over time, so a hard-coded list in this module would eventually reject a valid current shape β€” the worse failure of the two, because it blocks work that should succeed.

πŸ’‘ Which shapes are offered varies by region as well as over time. Confirm availability in your target region before committing to a shape, since location and shape are both force-new and have to be right together.

⚠️ A wrong shape fails at apply. Given how long provisioning takes, that is worth getting right on paper first.

4 · ⚠️ `NonRolling` patching takes the system down
# βœ… The default, and the safe choice: node-by-node patching, no downtime.
maintenance_window = {
  preference         = "CustomPreference"
  patching_mode      = "Rolling"
  lead_time_in_weeks = 2
  days_of_week       = ["Sunday"]
  hours_of_day       = [4]
  weeks_of_month     = [2]
}
# ⚠️ Patches the whole rack at once. Oracle documents this as involving system down time.
patching_mode = "NonRolling"

⚠️ NonRolling is the one field in this module with a genuinely dangerous value. It is not faster in a way that helps; it trades availability for patching simplicity.

πŸ’‘ This module supplies Rolling when a maintenance window is given without a patching_mode. The service default is the same, so nothing changes functionally β€” but the safe value becomes visible in the rendered configuration instead of being implied by an absence, which is what a plan reviewer actually reads.

πŸ”’ uses_nonrolling_patching is emitted so a change review can find every rack that has opted into downtime, without reading each maintenance window.

Error: Invalid value for variable

  maintenance_window.patching_mode must be "Rolling" or "NonRolling" -- TITLE
  CASE, and the provider's check is case-sensitive, so "Rolling" and "rolling"
  are both refused. NonRolling patches the whole rack at once and involves
  system downtime; Rolling patches node by
  node and is the default.
5 Β· The maintenance window cannot be tuned later
  # module.exadata.azurerm_oracle_exadata_infrastructure.this must be replaced
-/+ resource "azurerm_oracle_exadata_infrastructure" "this" {
      ~ maintenance_window {
          ~ hours_of_day = [4] -> [8] # forces replacement
        }
    }

⚠️ Moving a maintenance window by four hours rebuilds the rack. The window is force-new in its entirety, so correcting a schedule costs a far larger outage than any maintenance run it was meant to avoid.

πŸ’‘ Which means: get the window right the first time, or do not set one at all. Leaving maintenance_window null lets Oracle schedule maintenance itself β€” a genuinely reasonable choice, and one that cannot be got wrong.

ℹ️ has_custom_maintenance_window is emitted so a review can see which racks made that choice deliberately.

6 Β· The fifth week of a month is not schedulable
weeks_of_month = [2]       # βœ… the second week β€” the 8th to the 14th
weeks_of_month = [1, 3]    # βœ…
weeks_of_month = [5]       # ❌ rejected at plan
lead_time_in_weeks = 6     # ❌ rejected at plan β€” 1 to 4
Error: Invalid value for variable

  maintenance_window.weeks_of_month entries must be between 1 and 4 β€” weeks start
  on the 1st, 8th, 15th and 22nd, and the fifth week of a month is not
  schedulable.

ℹ️ Weeks are calendar-date based, not day-of-week based. Week 2 means the 8th to the 14th regardless of which day that starts on, and months longer than 28 days simply have no schedulable fifth week.

πŸ’‘ These two ranges are validated where shape is not, and the distinction is deliberate: 1-to-4 is fixed by the service and documented, so a hard-coded bound here cannot become wrong. That is the test this suite applies before validating anything.

7 Β· `customer_contacts` is permanent β€” use a team alias
# βœ… Survives someone leaving.
customer_contacts = ["oracle-platform@example.com"]
# ⚠️ Permanent in practice, and wrong the moment this person changes role.
customer_contacts = ["priya.patel@example.com"]

⚠️ The list is force-new, so correcting a notification address costs a rack rebuild. In practice that means a wrong address stays wrong β€” nobody rebuilds a rack to fix a typo, so the notifications simply go nowhere.

πŸ”’ These addresses are passed to Oracle for operational notifications, so they leave Azure. A role address is the better choice for that reason too.

πŸ’‘ notifies_customer_contacts is emitted because an empty list is easy to ship and permanent once shipped. A rack that cannot notify anyone about a hardware fault is worth catching in review rather than during an incident.

8 · ⚠️ Zone alignment is not checked across modules
module "exadata" {
  # ...
  zones = ["2"]
}

# FRAGMENT: only the zone-alignment argument is shown; the cluster's own eleven required inputs
# are in that module's Quick Start.
module "vm_cluster" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-cloud-vm-cluster.git?ref=v1.0.0"

  db_servers      = [for s in data.azurerm_oracle_db_servers.this.db_servers : s.ocid]

  ssh_public_keys = [file("~/.ssh/oracle_prod.pub")]
  # ⚠️ must be placed in zone 2 as well β€” nothing here or in any plan verifies that
  cloud_exadata_infrastructure_id = module.exadata.id
}

module "storage_vault" {
  source = "git::.../terraform-azurerm-oracle-exascale-database-storage-vault.git?ref=v1.0.0"

  additional_flash_cache_percentage = 1

  display_name                 = "storage-vault-example"

  location                     = "eastus2"

  name                         = "storage-vault-example"

  resource_group_name          = "rg-example"

  total_storage_size_in_gb     = 1
  zones  = ["2"] # ⚠️ must match, and there is NO Terraform link to this rack at all
}

⚠️ A VM cluster or vault in a different zone from this rack cannot be placed, and no plan reports the mismatch β€” the cluster consumes the rack's id but not its zone, and the vault has no link to the rack whatsoever.

πŸ’‘ Drive all three from one local so the alignment is structural rather than remembered:

locals { oracle_zone = ["2"] }

ℹ️ zones is emitted from this module so a composition can wire the cluster and vault from it directly rather than repeating a literal.

9 Β· The db-server OCIDs do not exist until this does
module "exadata" { /* ... */ }

# OCI OCIDs, readable only AFTER the rack exists.
data "azurerm_oracle_db_servers" "this" {
  resource_group_name              = module.rg.name
  cloud_exadata_infrastructure_name = module.exadata.name
}

# FRAGMENT: only the two arguments that depend on the rack are shown -- the OCIDs from the data
# source, and the rack's own ID. The cluster's eleven required inputs are in its Quick Start.
module "vm_cluster" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-cloud-vm-cluster.git?ref=v1.0.0"

  ssh_public_keys                 = [file("~/.ssh/oracle_prod.pub")]
  cloud_exadata_infrastructure_id = module.exadata.id
  db_servers                      = [for s in data.azurerm_oracle_db_servers.this.db_servers : s.ocid]
}

ℹ️ These are OCI OCIDs, not Azure Resource IDs, and this module does not emit them β€” they are a property of the provisioned hardware and do not exist at plan time.

⚠️ Which means a first-time apply of a whole family cannot be done in one pass with a fully-known plan: the data source has nothing to read until the rack is created. Expect to apply the rack, then the cluster.

πŸ’‘ That is also a reason to apply the rack on its own deliberately, rather than discovering the two-phase shape mid-apply.

10 Β· Provisioning takes hours β€” do not cancel
module "exadata" {
  # ...

  # Rarely needed β€” the provider's defaults are already generous.
  timeouts = {
    create = "12h"
  }
}

⚠️ A long apply is expected, not a hang. This is hardware provisioning across two clouds.

⚠️ Cancelling a partially-applied operation is the expensive mistake here. It leaves state to reconcile on both the Azure and the OCI side, and this is the most expensive resource in the family to leave half-created.

πŸ’‘ Leave the timeouts alone unless an apply actually times out. Raising them pre-emptively helps nothing and hides nothing β€” the operation takes as long as it takes.

11 Β· What a review should assert
output "exadata_posture" {
  value = {
    capacity   = "${module.exadata.compute_count}c / ${module.exadata.storage_count}s on ${module.exadata.shape}"
    zone       = module.exadata.zones
    downtime   = module.exadata.uses_nonrolling_patching      # expect false
    custom_win = module.exadata.has_custom_maintenance_window
    notifies   = module.exadata.notifies_customer_contacts    # expect true
  }
}

πŸ”’ Two rules worth encoding: downtime == true requires recorded justification, and notifies == false is a finding β€” a rack that cannot notify anyone about a hardware fault, permanently.

πŸ’‘ Three derived booleans rather than one, because they serve different readers. uses_nonrolling_patching is an availability risk, has_custom_maintenance_window is a scheduling decision, and notifies_customer_contacts catches an omission that cannot be corrected. None is inferable from the others.

ℹ️ The capacity string is worth emitting into a composition's outputs: it is the fact a cost conversation starts from, and it is not adjustable, so it does not go stale.

12 Β· Adopting a rack that already exists
terraform import 'module.exadata.azurerm_oracle_exadata_infrastructure.this' \
  '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-oracle-prod/providers/Oracle.Database/cloudExadataInfrastructures/exa-oracle-prod'

πŸ’‘ Given the cost and the provisioning time, importing an existing rack is far more common here than creating one from scratch β€” and far more likely to be the right move.

⚠️ After importing, run a plan and expect it to be empty. If it proposes a replacement, a value in your configuration does not match the real rack. Fix the configuration, never accept the plan β€” with everything force-new, an innocuous-looking mismatch in display_name or a maintenance-window hour will rebuild a rack.

πŸ’‘ Read the real values back before writing the configuration, so the first plan is empty by construction rather than by iteration. The provider ships an azurerm_oracle_exadata_infrastructure data source for exactly that.

ℹ️ Note the Oracle.Database segment in the Resource ID. Copying the shape from a Microsoft.* resource and substituting names will not produce a valid one.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

locals {
  # One zone, driven into every Oracle module that needs to agree on it.
  oracle_zone = ["2"]
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-oracle-prod"
  location = "eastus"
}

# 1 Β· The anchor. Nothing references it, so the ordering has to be stated.
module "oracle_anchor" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-resource-anchor.git?ref=v1.0.0"

  name                = "anchor-oracle-prod"
  resource_group_name = module.rg.name
}

# 2 Β· The rack. Apply this and stop β€” the db-server OCIDs below do not exist yet.
module "exadata" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-exadata-infrastructure.git?ref=v1.0.0"

  name                = "exa-oracle-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  display_name  = "Oracle Production Exadata"
  shape         = "Exadata.X9M"
  zones         = local.oracle_zone
  compute_count = 2
  storage_count = 3

  # A team alias, because this field is force-new.
  customer_contacts = ["oracle-platform@example.com"]

  # Rolling patching, in a quiet window. Set once β€” correcting it rebuilds the rack.
  maintenance_window = {
    preference         = "CustomPreference"
    patching_mode      = "Rolling"
    lead_time_in_weeks = 2
    days_of_week       = ["Sunday"]
    hours_of_day       = [4]
    weeks_of_month     = [2]
  }

  tags = { workload = "oracle-database" } # the only thing here that can change later

  depends_on = [module.oracle_anchor]
}

# 3 Β· Second phase: the OCIDs exist now, so the cluster can be placed.
data "azurerm_oracle_db_servers" "this" {
  resource_group_name               = module.rg.name
  cloud_exadata_infrastructure_name = module.exadata.name
}

module "vm_cluster" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-oracle-cloud-vm-cluster.git?ref=v1.0.0"

  name                = "vmc-oracle-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  cloud_exadata_infrastructure_id = module.exadata.id
  db_servers                      = [for s in data.azurerm_oracle_db_servers.this.db_servers : s.ocid]

  display_name  = "Oracle Production Cluster"
  hostname      = "oraprod"
  gi_version    = "19.0.0.0"
  license_model = "LicenseIncluded"
  cpu_core_count = 4

  virtual_network_id = var.vnet_id
  subnet_id          = var.vnet_subnet_ids["oracle"] # must be delegated
  ssh_public_keys    = [file("~/.ssh/oracle_prod.pub")] # PUBLIC key

  # Enable at creation if you want it at all β€” force-new in this provider.
  data_collection_options = {
    diagnostics_events_enabled = true
    health_monitoring_enabled  = true
    incident_logs_enabled      = true
  }
}

output "exadata_posture" {
  value = {
    capacity = "${module.exadata.compute_count}c / ${module.exadata.storage_count}s on ${module.exadata.shape}"
    zone     = module.exadata.zones
    downtime = module.exadata.uses_nonrolling_patching   # expect false
    notifies = module.exadata.notifies_customer_contacts # expect true
  }
}

πŸ”’ What the composition gets right: one local drives the zone into every module that must agree on it, the notification address is a team alias, patching is Rolling in a quiet window, and the anchor ordering is stated rather than assumed.

⚠️ This is deliberately a two-phase apply. Step 3's data source has nothing to read until step 2 exists, so a first deployment applies the rack, then the cluster. That is a property of the service, not a shortcoming of the configuration (example 9).

⚠️ What no plan will tell you: whether the shape is available in the region, whether the quota exists, whether the subnet is delegated, or whether the zone in local.oracle_zone is one the region actually offers. All four are apply-time failures β€” after hours of provisioning.

πŸ“₯ Inputs

Input Type Default Notes
name string β€” Required. Force-new.
resource_group_name string β€” Required. Force-new.
location string β€” Required. Force-new. Must offer the shape.
display_name string β€” Required. ⚠️ Force-new β€” no cosmetic rename.
shape string β€” Required. Force-new. Not validated β€” a service catalogue.
zones set(string) β€” Required, non-empty. Force-new. Must match clusters and vaults.
compute_count number β€” Required. Force-new. No scale-up.
storage_count number β€” Required. Force-new. No scale-up.
database_server_type string null Force-new. Not validated.
storage_server_type string null Force-new. Not validated.
maintenance_window object(...) null ⚠️ Force-new entirely. patching_mode defaults to Rolling.
customer_contacts list(string) [] ⚠️ Force-new. Use a team alias.
tags map(string) {} The only mutable field.
timeouts object(...) null Hours, not minutes.
Full schemas
variable "maintenance_window" {
  type = object({
    preference         = optional(string)
    patching_mode      = optional(string)
    lead_time_in_weeks = optional(number)
    days_of_week       = optional(list(string))
    hours_of_day       = optional(list(number))
    weeks_of_month     = optional(list(number))
    months             = optional(list(string))
  })
  default = null
  # ⚠️ Force-new IN ITS ENTIRETY β€” a maintenance window is not something to tune later.
  # ⚠️ patching_mode = "NonRolling" involves system downtime. Rolling patches node by node and is
  #    both the service default and this module's default when a window is supplied without one.
  # Validated: patching_mode in {Rolling, NonRolling}; preference in {NoPreference,
  #   CustomPreference}; hours_of_day in {0,4,8,12,16,20}; days_of_week and months as
  #   title-case English names; lead_time_in_weeks 1-4; weeks_of_month 1-4
  # (the fifth week of a month is not schedulable).
}

variable "compute_count" {
  type = number
  # ⚠️ Force-new, and there is NO scale-up. The most expensive number in this module.
  # The legal range depends on `shape`, so only the shape of the number is validated β€” a count the
  # shape does not support fails at apply rather than being rejected by a guessed bound.
  validation {
    condition     = var.compute_count > 0 && floor(var.compute_count) == var.compute_count
    error_message = "compute_count must be a positive whole number of compute servers."
  }
}

🧾 Outputs

Output Description Sensitive
id The infrastructure's Resource ID. no
name The name. no
location The Azure region. no
zones The availability zones. no
shape The hardware shape. no
compute_count / storage_count Provisioned capacity. no
uses_nonrolling_patching Derived β€” patching causes downtime when true. no
has_custom_maintenance_window Derived β€” Oracle's scheduling overridden. no
notifies_customer_contacts Derived β€” Oracle can send notifications. no

πŸ”’ Nothing sensitive is accepted or emitted. Customer contact addresses are inputs, not outputs.

🧠 Architecture Notes

  • The immutability is the module's organising fact, so it leads the documentation. Most modules in this library reward iteration; this one punishes it. Arranging the README around what cannot be changed β€” before what can β€” is the difference between a reader who treats these inputs as a purchase order and one who treats them as settings.

  • patching_mode is defaulted to Rolling in main.tf rather than left null. The service default is identical, so this is functionally a no-op β€” but it renders the safe value into the configuration where a plan reviewer will see the word, instead of leaving safety implied by an absence. For a field whose alternative causes an outage, visible beats implicit.

  • The validation choices follow one test: can a hard-coded bound become wrong? weeks_of_month and lead_time_in_weeks are fixed by the service and documented as 1-to-4, so they are validated. shape, the server model types and the time zone are service catalogues that gain members, so they are not. compute_count and storage_count are validated for shape but not range, because the range is a function of shape.

  • Three derived booleans are emitted rather than one. uses_nonrolling_patching is an availability risk, has_custom_maintenance_window is a scheduling decision, notifies_customer_contacts catches a permanent omission. They serve different reviewers and none follows from the others.

  • notifies_customer_contacts exists because the field is force-new. An empty contact list is easy to ship and impossible to correct without rebuilding the rack, so in practice a rack shipped without notifications never gets them. That is worth a plan-time signal rather than a discovery during an incident.

  • The two-phase apply is documented as a property of the service. The db-server OCIDs a cluster needs do not exist until the rack does, so a first deployment cannot be one pass. Saying so plainly is better than a caller discovering it when a data source returns nothing.

  • Zone alignment is documented as unchecked, with a structural fix. Neither the cluster nor the vault verifies its zone against this rack β€” the vault has no Terraform link to it at all β€” so the README recommends a shared local rather than trusting three literals to agree.

  • lifecycle unavailability is stated explicitly. A reader who knows ignore_changes exists will reach for it, and it is not available for a resource inside a module. Saying that reading the plan is the only safeguard is more useful than letting them find out.

  • No tags propagation to OCI, and the cost consequence is stated in the resource-anchor module rather than repeated here.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Patching availability Rolling rendered explicitly when a window is supplied NonRolling, typed, with recorded justification
Downtime visibility uses_nonrolling_patching emitted for policy assertion β€”
Maintenance scheduling no window at all is a valid, un-get-wrong-able choice a custom window, set once
Notification coverage notifies_customer_contacts emitted so an empty list is findable β€”
Nonsense capacity zero, negative or fractional counts rejected at plan β€”
Schedule impossibilities fifth-week and out-of-range lead times rejected at plan β€”
Catalogue values left to the service rather than a list that can go stale β€”
Immutability stated first, in the badge row, the overview and example 1 β€”
Secrets none accepted, none emitted β€”
  • Before the first apply: agree shape, compute_count and storage_count with whoever owns the spend. They cannot be changed.
  • Before the first apply: confirm the shape is available in the target region, and the quota exists.
  • Before setting a maintenance window: be sure of it, or leave it null and let Oracle schedule.
  • Before setting customer_contacts: use a team alias, not a person.
  • Before every subsequent plan: read it. A replacement here is a rack rebuild, and nothing else will stop it.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag β€” ?ref=v1.0.0 β€” never a branch.
  • Plan-only from here. A human applies from CI.
  • ⚠️ Read every plan. A replacement rebuilds a rack, and ignore_changes is not available to a caller.
  • ⚠️ Expect the first apply to take hours, and do not cancel it.
  • ⚠️ Apply the rack, then read the db-server OCIDs, then apply the cluster. Two phases, by necessity.
  • ℹ️ Only tags updates in place.
  • ℹ️ Prefer importing an existing rack over creating one, where one exists.

πŸ§ͺ Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • the seven required inputs are present and typed correctly;
  • zones is non-empty;
  • compute_count and storage_count are positive whole numbers;
  • maintenance_window.patching_mode is Rolling or NonRolling;
  • maintenance_window.lead_time_in_weeks and weeks_of_month are within 1–4;
  • the timeouts value is an object of Go duration strings - but note that a key the type does not declare is SILENTLY DISCARDED rather than refused, so a misspelling here produces no error anywhere and the provider default quietly applies;
  • the module declares no provider block.

What only plan and apply exercise:

  • whether the resource group exists and the anchor precedes this resource;
  • whether the compute and storage counts are legal for the chosen shape;
  • whether the maintenance window's days, hours and months are accepted.

What no Terraform command checks at any stage:

  • whether the shape is available in the chosen region;
  • whether quota exists for the requested capacity;
  • whether the zone matches the VM clusters and vaults that will use this rack;
  • whether Oracle.Database is registered and the tenant is onboarded;
  • whether the capacity is what the workload actually needs β€” and it cannot be changed afterwards.

πŸ’¬ Example Output

Outputs:

compute_count                 = 2
has_custom_maintenance_window = true
id                            = "/subscriptions/00000000-.../resourceGroups/rg-oracle-prod/providers/Oracle.Database/cloudExadataInfrastructures/exa-oracle-prod"
location                      = "eastus"
name                          = "exa-oracle-prod"
notifies_customer_contacts    = true
shape                         = "Exadata.X9M"
storage_count                 = 3
uses_nonrolling_patching      = false
zones                         = ["2"]

πŸ’‘ uses_nonrolling_patching = false with notifies_customer_contacts = true is the shape to expect. The first flipping is a planned outage; the second flipping is permanent.

πŸ” Troubleshooting

Symptom Cause Fix
A plan proposes a replacement after a small edit Every argument except tags is force-new. ⚠️ Do not apply. Revert the edit (example 1).
Wanted ignore_changes to protect a field lifecycle is not valid inside a module block. Not available. Read the plan (example 1).
Need more compute than the rack has There is no scale-up. Provision a second rack (example 2).
Apply rejects the compute or storage count The count is not legal for the chosen shape. Check the shape's supported configurations (example 2).
Apply rejects the shape Not available in that region, or not a current shape. Confirm regional availability (example 3).
Plan rejects patching_mode Wrong case, or not one of the two values. Use Rolling or NonRolling β€” title case (example 4). The provider's check is case-sensitive and Rolling is refused.
Plan rejects hours_of_day The value is not a multiple of four. Use 0, 4, 8, 12, 16 or 20 β€” each names a four-hour UTC slot.
Plan rejects compute_count or storage_count Outside the provider's range. compute_count is 2–32 and storage_count is 3–64; the chosen shape narrows both further, and that inner range is not visible from Terraform.
Patching caused an outage patching_mode is NonRolling. Oracle documents this. Check uses_nonrolling_patching (example 4).
Plan rejects weeks_of_month = [5] The fifth week of a month is not schedulable. Use 1–4 (example 6).
Notifications go nowhere customer_contacts is empty or wrong, and force-new. Permanent for this rack. Check notifies_customer_contacts (example 7).
A VM cluster cannot be placed Its zone does not match this rack's. Align zones from one shared local (example 8).
The db-servers data source returns nothing The rack does not exist yet. Apply the rack first β€” two phases (example 9).
The apply has run for hours Hardware provisioning is slow. Expected. Do not cancel (example 10).
Cancelled an apply and state is inconsistent A partially-applied cross-cloud operation. The most expensive version of this mistake (example 10).
An import produced a replacement plan The configuration does not match the real rack. Fix the configuration, never accept the plan (example 12).

πŸ”— Related Docs

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."