Provisions Cloud Exadata Infrastructure β the rack capacity Oracle Database@Azure VM clusters are placed onto (
azurerm_oracle_exadata_infrastructure). Targetshashicorp/azurerm ~> 4.0.
- π₯οΈ 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. - π«
lifecycleis not valid inside amoduleblock, so a caller cannot addignore_changesto guard against that. Reading the plan is the only safeguard. - β±οΈ
patching_mode = "NonRolling"takes the system down.Rollingis the service default and this module's default, anduses_nonrolling_patchingis 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.
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"]
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;
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;
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. |
| 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" oncompute_count,display_name,location,name,resource_group_name,shape,storage_count,zones,database_server_type,storage_server_type,customer_contactsandmaintenance_window. display_nameis force-new, unlike mostdisplay_namefields in this library.customer_contactsis force-new, so a wrong notification address is permanent in practice.maintenance_windowis force-new in its entirety.patching_modeisRollingorNonRolling, defaulting toRolling. Oracle's documentation states that non-rolling patching involves system down time.weeks_of_monthaccepts 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_weeksaccepts 1 to 4.compute_countandstorage_counthave no scale-up, and their legal ranges depend onshape.Oracle.Databasemust be registered on the subscription, and RBAC is written againstOracle.Database/*.lifecycleis not valid inside amoduleblock, so a caller cannot addignore_changesfor a resource inside a module.
| 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 againstOracle.Database/*, notMicrosoft.*. 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.
- The
Oracle.Databaseresource 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
shapeavailable 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_countandstorage_countare not adjustable later, so agree the numbers before the apply. - Time. Provisioning takes hours.
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
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.
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 |
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 withignore_changes.lifecycleis not valid inside amoduleblock, 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
locationandshapeare 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"
β οΈ NonRollingis 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
Rollingwhen a maintenance window is given without apatching_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_patchingis 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_windownull lets Oracle schedule maintenance itself β a genuinely reasonable choice, and one that cannot be got wrong.
βΉοΈ
has_custom_maintenance_windowis 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 4Error: 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
shapeis 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_contactsis 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'sidbut 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"] }βΉοΈ
zonesis 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 == truerequires recorded justification, andnotifies == falseis 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_patchingis an availability risk,has_custom_maintenance_windowis a scheduling decision, andnotifies_customer_contactscatches 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 indisplay_nameor 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_infrastructuredata source for exactly that.
βΉοΈ Note the
Oracle.Databasesegment in the Resource ID. Copying the shape from aMicrosoft.*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
localdrives the zone into every module that must agree on it, the notification address is a team alias, patching isRollingin 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 inlocal.oracle_zoneis one the region actually offers. All four are apply-time failures β after hours of provisioning.
| 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. |
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 |
patching_mode defaults to Rolling. |
customer_contacts |
list(string) |
[] |
|
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."
}
}| 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.
-
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_modeis defaulted toRollinginmain.tfrather 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_monthandlead_time_in_weeksare 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_countandstorage_countare validated for shape but not range, because the range is a function ofshape. -
Three derived booleans are emitted rather than one.
uses_nonrolling_patchingis an availability risk,has_custom_maintenance_windowis a scheduling decision,notifies_customer_contactscatches a permanent omission. They serve different reviewers and none follows from the others. -
notifies_customer_contactsexists 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
localrather than trusting three literals to agree. -
lifecycleunavailability is stated explicitly. A reader who knowsignore_changesexists 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
tagspropagation to OCI, and the cost consequence is stated in the resource-anchor module rather than repeated here.
| 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_countandstorage_countwith 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.
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, andignore_changesis 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
tagsupdates in place. - βΉοΈ Prefer importing an existing rack over creating one, where one exists.
terraform validate and terraform fmt -check are the offline gate. They confirm:
- the seven required inputs are present and typed correctly;
zonesis non-empty;compute_countandstorage_countare positive whole numbers;maintenance_window.patching_modeisRollingorNonRolling;maintenance_window.lead_time_in_weeksandweeks_of_monthare within 1β4;- the
timeoutsvalue 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
providerblock.
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.Databaseis registered and the tenant is onboarded; - whether the capacity is what the workload actually needs β and it cannot be changed afterwards.
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 = falsewithnotifies_customer_contacts = trueis the shape to expect. The first flipping is a planned outage; the second flipping is permanent.
| Symptom | Cause | Fix |
|---|---|---|
| A plan proposes a replacement after a small edit | Every argument except tags is force-new. |
|
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). |
azurerm_oracle_exadata_infrastructureβ provider documentation for this resource.azurerm_oracle_db_serversβ the data source that yields the OCIDs a VM cluster needs.- Oracle-managed infrastructure maintenance updates β what
RollingandNonRollingmean. - Exadata Cloud Infrastructure shapes β shape characteristics and supported configurations.
- Oracle Database@Azure overview β how the Azure and OCI sides relate.
- Sibling modules in this family:
terraform-azurerm-oracle-resource-anchor,terraform-azurerm-oracle-cloud-vm-cluster,terraform-azurerm-oracle-exascale-database-storage-vault. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."