Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Qumulo File System Terraform Module

Manages an Azure Native Qumulo Scalable File System — a third-party marketplace service whose admin password is force-new, so changing it destroys the file system (azurerm_qumulo_file_system). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • 🗄️ A managed scalable file system, sold through the Azure Marketplace and operated by Qumulo — its Resource ID sits under the third-party Qumulo.Storage namespace, not a Microsoft.* one.
  • 🔴 admin_password is force-new. Changing it in Terraform destroys the file system and everything on it, so the field is for the initial password only. Rotate inside Qumulo's own portal.
  • 🔴 Which means the value in state goes stale by design after any in-product rotation — and "fixing" that apparent drift would be destructive. That inverts the usual advice, so it is stated rather than implied.
  • 🔴 Every argument except tags is force-new. Name, region, email, SKU, subnet, zone and all three marketplace identifiers.
  • 🔴 The marketplace plan must be accepted for the subscription first, or the apply fails with a marketplace error that describes neither Azure nor the missing prerequisite.
  • 🔴 subnet_id is VNet injection into a subnet delegated to Qumulo.Storage — so that subnet is effectively dedicated to this file system.
  • 🔒 The password is never emitted, and sensitive redacts plan output rather than encrypting state.
  • ✅ is_zone_redundant is emitted, because only Hot_ZRS survives losing an availability zone.

💡 Why it matters: This module's whole job is to stop one specific accident. Rotating a password is the most routine security action there is, and here it deletes a file system — because the provider marks the field force-new and Terraform will happily do exactly what you asked.

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  mp["AZURE MARKETPLACE: the subscription must be SUBSCRIBED to the Qumulo SaaS offer, on a chosen plan, before anything can be created. Subscription-scoped, and NOT the same thing as accepting a marketplace IMAGE agreement - no azurerm resource performs it."]
  vnet["terraform-azurerm-virtual-network and its subnet. The subnet must be DELEGATED to Qumulo.Storage and is then effectively dedicated to this file system."]
  fs["terraform-azurerm-qumulo-file-system"]
  qumulo["QUMULO, the third party operating the service. The email is shared with them, and the admin password is ROTATED IN THEIR portal - not through Terraform."]
  kv["terraform-azurerm-key-vault, holding the initial admin password out of band. It still lands in Terraform state in plaintext."]

  rg -->|"resource_group_name and location"| fs
  mp -->|"plan accepted, or apply fails with a marketplace error"| fs
  vnet -->|"subnet_id, VNet injection into a delegated subnet"| fs
  kv -->|"admin_password, initial only"| fs
  fs -->|"operated by, and email shared with"| qumulo
  qumulo -->|"rotate the password HERE - changing it in Terraform destroys the file system"| fs

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class fs mine;
  class qumulo,mp keystone;
  class rg,vnet,kv sib;
Loading

🧬 What this module builds

flowchart TB
  fn["EVERY ARGUMENT EXCEPT tags IS FORCE-NEW: name, resource group, location, email, storage_sku, subnet_id, zone and all three marketplace identifiers."]
  pw["admin_password is REQUIRED, SENSITIVE - and force-new. So changing it DESTROYS the file system and everything on it. The field is for the INITIAL password only."]
  rot["Rotate inside Qumulo's own portal instead. Consequence worth a runbook entry: after that, the value in Terraform state is stale BY DESIGN, and reconciling the apparent drift would be destructive - the opposite of the usual advice."]
  state["sensitive redacts plan output and does NOT encrypt state, where the initial password sits in plaintext. The password is never emitted; only its presence is."]
  sku["storage_sku is a closed set of Cold_LRS, Hot_LRS and Hot_ZRS, and is REQUIRED - so no default is invented. Only Hot_ZRS survives losing an availability zone."]
  net["subnet_id is VNet INJECTION into a subnet delegated to Qumulo.Storage. The delegation lives on the subnet, is invisible from here, and its absence fails at apply."]
  mp["And the marketplace PLAN must be accepted for the subscription first. That failure reads like neither an Azure error nor a missing prerequisite."]
  this["azurerm_qumulo_file_system.this"]
  outputs["id, storage_sku, is_zone_redundant, subnet_id, email, has_admin_password, admin_password_rotation_requires_replacement, requires_marketplace_plan_acceptance, subnet_must_be_delegated_to_qumulo"]

  fn -->|"lifecycle"| pw
  pw -->|"so do not rotate here"| rot
  rot -->|"documented"| this
  pw -->|"and note"| state
  state -->|"presence only"| outputs
  sku -->|"resilience"| this
  net -->|"networking"| this
  mp -->|"prerequisite"| this
  this -->|"exports"| outputs

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this mine;
  class sku mine;
  class pw,rot,state keystone;
  class fn,net,mp,outputs sib;
Loading

Resource inventory

Resource Count Notes
azurerm_qumulo_file_system.this 1 The keystone. Only tags updates in place.
timeouts block 0..1 Provider defaults are long on purpose — 90 min create.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Qumulo.Storage (fileSystems) — 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:

  • 🔴 admin_password is force-new, so changing it destroys the file system and its data.
  • 🔴 Every argument except tags is force-new.
  • 🔴 The marketplace plan must be accepted for the subscription before creation.
  • 🔴 subnet_id must be delegated to Qumulo.Storage — VNet injection, and the subnet becomes dedicated.
  • ⚠️ zone is required by the provider even when storage_sku is Hot_ZRS — although the service's own documentation says a zone need not be specified for that class. A provider/service divergence, not an oddity.
  • ⚠️ storage_sku is a closed set — Cold_LRS, Hot_LRS, Hot_ZRS — and required, so no empty call exists.
  • ⚠️ The Resource ID is /providers/Qumulo.Storage/fileSystems/<name>.
  • ⚠️ Provider timeout defaults are long: 90 min create, 1 h update, 30 min delete.
  • 🔒 admin_password complexity IS enforced by the provider — at least 3 of 4 character classes, then a minimum of 8 characters. This module mirrors both, so both are caught offline.
  • 🔴 name is capped at 15 characters (2 minimum, no leading or trailing hyphen). Not in the registry docs.
  • 🔴 The resource exports no attributes but id. No mount IP addresses, no cluster login URL, no provisioning state, no marketplace subscription status — all of which the underlying API returns.
  • 🔴 The subnet must have at least 256 addresses, and the provider does not check that — only the delegation, and only at apply.
  • ⚠️ location is NOT validated against a region list. The region validator degrades to a bare non-empty check when its cached region list is unavailable, which it is in a credential-free run.
  • ⚠️ zone is validated for non-emptiness only — any string passes plan.
  • ⚠️ No capacity or performance argument exists. Capacity is elastic; the pre-provisioned performance level is set and changed out of band, in both directions, and Terraform never sees it.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy (example 11).

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the file system Contributor the resource group
Read the file system Reader the file system
Accept the marketplace plan Contributor or Owner the subscription
Join the delegated subnet Microsoft.Network/virtualNetworks/subnets/join/action the subnet
Delegate the subnet to Qumulo.Storage Network Contributor the virtual network

🔒 State access is credential access here, at least initially. admin_password sits in Terraform state in plaintext — sensitive = true redacts plan output and does not encrypt state — so anyone who can read the state can read the file system's initial administrator password (example 4).

💡 The marketplace subscription is subscription-scoped and usually a one-off, so it is not a permission this module's caller needs on every run (example 6).

🔴 The service documents a broader requirement than least privilege suggests, and it is worth reading before planning the first deployment in a subscription. Its prerequisites call for Owner or Contributor on the subscription, stating that only a principal with one of those roles can set up the partner service integration for that subscription. For custom roles it requires Qumulo.Storage/* and Microsoft.Network/virtualNetworks/subnets/join/action in both the subnet's resource group and the file system's, plus write access to both resource groups. So the first deployment in a subscription is a different permissions problem from every later one, and the resource-group-scoped row above is the steady state rather than the whole story.

⚠️ The caller also needs to READ the subnet, not only write where the file system is going: before creating anything the provider fetches the subnet to verify its delegation, and without read access the create fails there — before any Qumulo call, with an error about retrieving the subnet.

Azure Prerequisites

  • 🔴 The subscription subscribed to the Qumulo SaaS offer, on a chosen plan (example 6). This is a marketplace subscription, not a marketplace image agreement, so no azurerm resource performs it.
  • 🔴 A subnet delegated to Qumulo.Storage/fileSystems with the subnet join action, effectively dedicated to this file system (example 5).
  • 🔴 That subnet must have at least 256 addresses — a /24 or larger. The provider verifies the delegation and never the size, so an undersized but correctly delegated subnet fails after the check passes.
  • A region that offers availability zones and where the Qumulo offer is available. Neither is checked: the provider's region validator degrades to a non-empty check offline, and the service publishes no region list.
  • ℹ️ Expect two resources you did not declare. The service creates a managed resource group for its internal networking, and a SaaS resource used for marketplace billing. Neither is managed here; both go when the file system does.
  • 🔒 An encrypted, access-controlled state backend (example 4).
  • 🔒 A monitored team alias for email, since it is shared with the vendor and force-new (example 7).

📁 Module Structure

terraform-azurerm-qumulo-file-system/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # name, resource_group_name, location, admin_password, email, storage_sku,
#                  # subnet_id, zone, offer_id, plan_id, publisher_id, tags, timeouts
├── main.tf        # the keystone `this` + dynamic timeouts
├── outputs.tf     # id first, then placement and marketplace facts, then the password/prereq flags
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "qumulo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-qumulo-file-system.git?ref=v1.0.0"

  name                = "qfs-research-data"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # 🔴 INITIAL password only. Changing it later destroys the file system (example 3).
  admin_password = var.qumulo_admin_password # 🔒 from Key Vault / a pipeline variable

  # 🔒 Shared with Qumulo — use a team alias (example 7).
  email = "storage-platform@contoso.example"

  # ✅ Hot_ZRS survives a zone loss; the LRS options do not (example 8).
  storage_sku = "Hot_ZRS"

  # 🔴 Must be DELEGATED to Qumulo.Storage (example 5).
  subnet_id = var.qumulo_subnet_id
  zone      = "1"

  tags = { owner = "storage-platform", data_class = "research" }
}

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

🔴 Read example 3 before this reaches production. The one thing you must never do to this resource is rotate its password in Terraform.

🔌 Cross-Module Contract

Consumes

Input Type Source module
name string caller — force-new
resource_group_name / location string terraform-azurerm-resource-group
admin_password string 🔒 out of band — sensitive, force-new
email string caller — 🔒 shared with the vendor
storage_sku string caller — required, closed set
subnet_id string a delegated subnet's id
zone string caller — required by the provider even with Hot_ZRS
offer_id / plan_id / publisher_id string caller — omitted by default
tags map(string) caller — the only in-place field
timeouts object(...) caller — leave the long defaults

Emits

Output Description Consumed by
id Resource ID under Qumulo.Storage. review, RBAC
name / resource_group_name / location Identity. Force-new. review
storage_sku / is_zone_redundant Tier and redundancy. resilience review
zone Placement zone. review
subnet_id The delegated subnet consumed. network review
email 🔒 Deliberately un-redacted. review
offer_id / plan_id / publisher_id Marketplace identifiers in effect. procurement review
has_admin_password 🔒 Always true — instead of the password. security review
admin_password_rotation_requires_replacement 🔴 Always true. runbook
requires_marketplace_plan_acceptance Always true. prerequisite review
subnet_must_be_delegated_to_qumulo Always true. network review
mount_ip_addresses_not_exported 🔴 Always true. integration review
cluster_login_url_not_exported 🔴 Always true. runbook
marketplace_subscription_status_not_visible 🔴 Always true. monitoring
performance_tier_not_managed_here Always true. performance review
delegated_subnet_requires_256_addresses 🔴 Always true. network review
creates_managed_resource_group Always true. inventory, cost review
tags_are_the_only_updatable_argument 🔴 Always true. change review
provider_pinned_storage_sku_set The SKUs this provider version accepts. upgrade review
billed_from_creation_whether_used 🔴 Always true. cost review
destroy_has_no_name_confirmation 🔴 Always true. runbook

🔒 The password is never emitted.

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

variable "qumulo_subnet_id" {
  description = "id of an existing qumulo subnet that these examples reference but do not create."
  type        = string
}
1 · A third-party service wearing an Azure resource's clothes
Resource ID:  /subscriptions/.../providers/Qumulo.Storage/fileSystems/qfs-research-data
                                            ^^^^^^^^^^^^^^ not Microsoft.*

ℹ️ This is an Azure Native ISV service. You buy it through the Azure Marketplace, it appears in the portal and in azurerm like any other resource, and it is operated by Qumulo. The provider namespace is the giveaway.

🔴 Three consequences run through the rest of this document, and none is visible in the argument list: the marketplace plan must be accepted first (example 6), the administrative email goes to the vendor (example 7), and the service injects its own network interfaces into a subnet you delegate to it (example 5).

⚠️ A fourth consequence is not about the marketplace at all, and it is the dangerous one: admin_password is force-new (example 3).

💡 Treat it as a vendor relationship expressed as Terraform. The resource is the contract boundary; what happens inside the file system — shares, quotas, snapshots — is configured in Qumulo, not here.

2 · Almost nothing updates in place
# ✅ In place:
tags = { owner = "storage-platform", data_class = "research", pw_rotated = "2026-09" }

# 🔴 Force-new — every single one:
name           = "qfs-research-data-v2"
location       = "westus2"
admin_password = var.new_password    # 🔴 DESTROYS THE FILE SYSTEM (example 3)
email          = "other@contoso.example"
storage_sku    = "Hot_LRS"
subnet_id      = var.other_subnet_id
zone           = "2"
offer_id       = "..."
plan_id        = "..."
publisher_id   = "..."

🔴 Twelve arguments and exactly one of them is mutable. That is unusual even by Azure standards, and it means the file system is essentially immutable once created: every correction is a rebuild, and a rebuild is a data migration.

✅ So tags carry everything that changes — the owner, the data classification, and (example 3) when the password was last rotated inside Qumulo, which Terraform cannot see.

⚠️ Plan changes as migrations, not edits. Stand up a new file system, copy the data, retire the old one. There is no in-place path for the SKU, the subnet or the zone.

💡 Read every plan. With eleven force-new fields, a plan touching this resource is far more likely to be a replacement than an update — which is the reverse of most resources in this library.

3 · 🔴 Rotating the password destroys the file system
admin_password = var.qumulo_admin_password
# ⚠️ This is the INITIAL password. It is FORCE-NEW.
# 🔴 Editing it does not rotate anything — it replaces the file system and loses the data.

🔴 This is the single most dangerous property of the resource, because rotating a credential is the most routine security action there is. The provider marks admin_password force-new, so Terraform will destroy and recreate the file system — and a file system's contents are not recreated.

✅ Rotate inside Qumulo's own administration interface instead, and then leave the Terraform value alone.

🔴 Which produces a consequence worth writing into a runbook: after any in-product rotation, the value in your configuration and state is no longer the live password — and it is supposed to be that way. Reconciling that apparent drift by editing the field would delete the file system. That is the opposite of the usual advice about keeping Terraform authoritative, and it is exactly why the module emits a constant flag rather than a comment:

output "never_rotate_here" {
  value = module.qumulo.admin_password_rotation_requires_replacement # always true
}

⚠️ A CanNotDelete lock does not save you, because this is a replacement rather than a deletion — see example 11.

💡 Record the rotation date in a tag. It is the only signal anybody reading the Terraform configuration will get that the state value is intentionally stale.

4 · 🔒 Where the password actually lives
admin_password = var.qumulo_admin_password # from Key Vault, or a pipeline variable — never committed

🔒 sensitive = true redacts plan output. It does not encrypt state. The initial password sits in Terraform state in plaintext, so a principal who can read the state can read it — which makes the backend's access controls part of this resource's security, and state access effectively credential access until the password has been rotated in Qumulo (example 3).

✅ So: encrypted, access-controlled remote backend only. A state file in a repository is the failure that makes every other precaution decorative.

✅ The password is never emitted. Only has_admin_password is, because re-emitting a credential copies it into every consuming configuration's state for no benefit. Read it from the store you supplied it from.

✅ The complexity rule is the provider's, and it is checked offline. Confirmed against the provider source: the value must carry at least three of four character classes — a lower-case letter, an upper-case letter, a digit, and a character that is neither — and be at least 8 characters. This module mirrors both, so both fire at plan rather than at apply.

⚠️ The provider tests complexity BEFORE length and returns immediately, so a password that is both too simple and too short reports only the complexity failure. Fixing what was reported can reveal a second problem.

ℹ️ Note which character class an underscore falls into. The provider's fourth class is "neither a letter nor a digit", so _ counts as a special character — the opposite of what reading the pattern loosely suggests.

⚠️ Any policy Qumulo applies on top of that is still invisible here. Treat the two rules above as a floor.

💡 There is a silver lining to example 3's oddity: once the password has been rotated in Qumulo, the value in state is no longer live — so the state's exposure window is bounded by how soon you rotate.

5 · 🔴 VNet injection into a delegated subnet
subnet_id = var.qumulo_subnet_id
# The subnet must carry a delegation to Qumulo.Storage — set on the SUBNET, not here.

🔴 This is VNet injection, not a private endpoint. The service places its own network interfaces directly into your subnet, which is why the delegation is mandatory: a subnet without it, or delegated to a different service, fails at apply.

⚠️ A delegated subnet is effectively dedicated. Other resources generally cannot share it, so size the address space for this file system alone rather than squeezing it into a shared subnet.

⚠️ This module cannot check the delegation. It validates that the value is a subnet Resource ID — catching the common mistake of passing the virtual network's ID — and says plainly that the delegation is beyond its reach:

Error: Invalid value for variable

  subnet_id must be a full subnet Resource ID ending in
  /virtualNetworks/<vnet>/subnets/<subnet>. Passing the virtual network's ID instead of
  a subnet's is the usual mistake. 🔴 The subnet must also be DELEGATED to
  Qumulo.Storage, which this module cannot verify — that failure appears at apply.

🔴 Force-new, so moving the file system to another subnet replaces it (example 2).

✅ subnet_id is emitted so a network review can see which subnet was consumed — useful precisely because the subnet is no longer available for anything else.

6 · 🔴 The marketplace subscription must exist first
# Somewhere in the subscription, ONCE, and NOT in Terraform: subscribe to the Qumulo
# SaaS offer on a chosen plan, through Azure Marketplace or the portal. That creates a
# SaaS resource used for billing, alongside the file system you create below.
#
# 🔴 There is no azurerm resource for this, and reaching for the marketplace-agreement
#    resource will not do it: that one accepts the legal terms of a marketplace IMAGE
#    (Microsoft.MarketplaceOrdering agreements for publisher/offer/plan of an image) and
#    has no effect on a SaaS offer. Nothing below fails validation if you skip the
#    subscription — the apply fails.

# The identifiers this module will send, all provider defaults unless overridden:
# offer_id     — provider default: qumulo-saas-mpp
# plan_id      — provider default: azure-native-qumulo-v3
# publisher_id — provider default: qumulo1584033880660

🔴 Without that subscription the apply fails with a marketplace error, which describes neither an Azure permission problem nor a missing prerequisite. No RBAC change fixes it, which is why the module emits requires_marketplace_plan_acceptance as a constant true.

ℹ️ It is a subscription-scoped, usually one-off action — subscribing to the offer through Azure Marketplace or the portal — so it is not a permission this module's caller needs on every run.

🔴 It is NOT azurerm_marketplace_agreement, and that is the trap worth naming. That resource accepts the legal terms of a marketplace image, operating on Microsoft.MarketplaceOrdering agreements. This is a SaaS offer: subscribing creates a SaaS resource used for billing, and no azurerm resource performs it. So this prerequisite cannot be codified here at all — it is a procurement step with a Terraform dependency, not a Terraform step.

✅ The three marketplace identifiers are left unset by default, so the provider's own values apply. This module deliberately does not restate them: hard-coding values the provider already tracks would mean this module needed updating whenever the offer changed, and would imply the values were reviewed choices when they are not.

✅ They are still emitted, so a procurement review can see exactly which offer and plan were deployed rather than inferring it from an absent input.

⚠️ All three are force-new. Supply them only if you are deploying a non-default offer, and check them against the marketplace listing.

7 · 🔒 The email goes to a third party
email = "storage-platform@contoso.example" # ✅ a monitored team alias
email = "alice@contoso.example"            # ⚠️ an individual's mailbox, and force-new

🔒 This address is shared with Qumulo, the vendor operating the service, and is used for their service correspondence — not just an Azure notification list. So it is personal data leaving your tenant, and it should be a monitored alias rather than a person.

⚠️ Force-new, so it cannot be corrected in place. An individual's mailbox here outlives their involvement and cannot be changed without replacing the file system (example 2).

✅ The module emits email un-redacted, deliberately. It is not a credential, it is shared by design, and redacting it would hide from review the field most likely to be a personal mailbox by mistake — which is precisely what a review should catch.

💡 Same reasoning this library applies to publisher contact details elsewhere: where a value is published by design, hiding it protects nothing and costs a review.

8 · ✅ The SKU, and why there is no default
storage_sku = "Hot_ZRS"  # ✅ zone-redundant
storage_sku = "Hot_LRS"  # ⚠️ locally redundant — does not survive a zone loss
storage_sku = "Cold_LRS" # ⚠️ cheaper tier, still locally redundant

ℹ️ The prefix is performance, the suffix is redundancy. Only Hot_ZRS is zone-redundant.

✅ is_zone_redundant is emitted so the resilience consequence is visible without anybody remembering what the suffix means:

output "resilient" {
  value = module.qumulo.is_zone_redundant # assert true for a system of record
}

⚠️ No default is invented, because the provider makes the argument required — there is no empty call to make safe, and choosing between a cold tier and a hot one on a caller's behalf would be guessing about both cost and performance. Where a module cannot choose safely, saying so beats picking.

🔴 Force-new, so both the tier and the redundancy model are create-time decisions. Hot_LRS → Hot_ZRS later is a new file system and a data migration (example 2).

⚠️ zone is still required even with Hot_ZRS — and this is a documented provider/service divergence rather than a contradiction. The service's own documentation states that if you choose Hot ZRS as the storage class you do not need to specify an availability zone, and the portal drops the field. The provider requires it unconditionally, so with Hot_ZRS you supply a value the service will not act on. There is no way to omit it through this resource, and it is force-new, which is simply what the provider asks for. Supply it either way.

9 · Creation is slow, and that is not a hang
timeouts = {
  create = "2h" # the provider already defaults to 90 minutes
}

ℹ️ The provider's own defaults are the hint: 90 minutes to create, an hour to update, 30 minutes to delete. This provisions real capacity rather than writing a metadata record.

✅ Leave the defaults unless you have a specific reason. They were chosen because the service is genuinely slow, not defensively.

⚠️ Do not read a long-running create as a failure. A pipeline with a short global timeout will kill this apply part-way, which is a worse outcome than waiting — you end up with a partially created resource and no state entry.

💡 Worth telling whoever owns the pipeline. A 90-minute Terraform step is unusual enough that it looks broken to somebody who has not seen it before.

10 · What a review should assert
output "qumulo_review" {
  value = {
    id        = module.qumulo.id
    sku       = module.qumulo.storage_sku
    resilient = module.qumulo.is_zone_redundant   # assert true for a system of record
    zone      = module.qumulo.zone
    subnet    = module.qumulo.subnet_id           # the subnet this consumed
    email     = module.qumulo.email               # 🔒 a team alias, not a person
    plan      = module.qumulo.plan_id             # which offer was deployed
    # 🔴 Prompts, not values:
    no_rotate = module.qumulo.admin_password_rotation_requires_replacement
    needs_mp  = module.qumulo.requires_marketplace_plan_acceptance
    needs_del = module.qumulo.subnet_must_be_delegated_to_qumulo
  }
}

✅ resilient is the assertion that matters most, and it cannot be fixed later (example 8).

🔒 email should be an alias, and this is the review that catches it (example 7).

🔴 no_rotate is a constant true and it is an instruction: never change admin_password in Terraform (example 3). It belongs in a runbook more than a review.

⚠️ subnet tells a network reviewer which subnet is now dedicated to this file system and unavailable for anything else (example 5).

💡 What no output can tell you is how much data is on the file system, whether it is backed up, or whether the password has been rotated in Qumulo. All three matter more than anything in this configuration.

11 · Destroy, locks, and importing
terraform destroy on this module:
  removes the FILE SYSTEM  ->  and everything stored on it
  the subnet survives      ->  still delegated, still dedicated
  the marketplace agreement survives

🔴 This is a data-destroying destroy, and so is any replacement (example 2). The distinction matters because of what a lock can and cannot do:

✅ A CanNotDelete management lock is worth applying, and it protects only against deletion — not against the force-new replacement that an admin_password edit would cause. That is the likelier accident, and no lock prevents it.

⚠️ prevent_destroy is not available, because lifecycle is not valid inside a module block. So the real control here is procedural: read plans, and never touch the password field.

Importing an existing file system

terraform import 'module.qumulo.azurerm_qumulo_file_system.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-storage/providers/Qumulo.Storage/fileSystems/qfs-research-data"

🔴 Importing is the riskiest operation in this document. admin_password is force-new and not readable from Azure, so your configuration must supply something — and if it does not match, the plan is a replacement that destroys the data. Plan and read before applying, always.

⚠️ Restate storage_sku, subnet_id, zone and email exactly too. All are force-new.

12 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-storage-platform"
  location = "eastus" # must offer availability zones
}

# ── A DEDICATED subnet, delegated to Qumulo.Storage (example 5) ──────────────
# ℹ️ module.vnet and module.qumulo_subnet are the caller's network resources. The
#    subnet's delegation is set on the SUBNET and cannot be set from this module.
#
#   delegation {
#     name = "qumulo"
#     service_delegation { name = "Qumulo.Storage/fileSystems" }
#   }

# ── The marketplace SaaS subscription, done once, OUTSIDE Terraform (example 6) ──
# ⚠️ Without it the apply below fails with a marketplace error that describes neither
#    Azure nor the missing prerequisite. No RBAC change fixes it, and no azurerm
#    resource performs it — subscribe to the offer in the marketplace or the portal.

# ── This module: the file system ────────────────────────────────────────────
module "qumulo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-qumulo-file-system.git?ref=v1.0.0"

  name                = "qfs-research-data"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # 🔴 INITIAL password only — NEVER edit this to rotate (example 3). Rotate in
  #    Qumulo's portal, then leave this value alone even though it goes stale.
  # 🔒 From Key Vault via a pipeline variable; it still lands in state (example 4).
  admin_password = var.qumulo_admin_password

  # 🔒 A monitored alias — shared with the vendor, and force-new (example 7).
  email = "storage-platform@contoso.example"

  # ✅ The only zone-redundant option (example 8). Force-new.
  storage_sku = "Hot_ZRS"

  # 🔴 Delegated and effectively dedicated (example 5).
  subnet_id = var.qumulo_subnet_id
  zone      = "1"

  # Marketplace identifiers omitted so the provider's defaults apply (example 6).

  # The only mutable field — so it carries what changes (example 2).
  tags = {
    owner      = "storage-platform"
    data_class = "research"
    pw_rotated = "2026-09" # 🔴 rotated in Qumulo; the state value is stale by design
  }

  # Leave the long defaults; creation genuinely takes ~90 minutes (example 9).
}

output "qumulo_posture" {
  value = {
    id        = module.qumulo.id
    resilient = module.qumulo.is_zone_redundant # assert true
    subnet    = module.qumulo.subnet_id
    plan      = module.qumulo.plan_id
    # 🔴 The instruction, not a status (example 3).
    no_rotate = module.qumulo.admin_password_rotation_requires_replacement
  }
}

🔒 What the composition gets right: Hot_ZRS for a system of record, a dedicated delegated subnet, the password from a pipeline variable with the state exposure acknowledged, a team alias rather than a person for the vendor contact, the marketplace prerequisite shown explicitly as a comment because it is not this module's resource, a pw_rotated tag recording an out-of-band rotation Terraform cannot see, and the long default timeouts left alone.

⚠️ What no plan will tell you: whether the subnet is actually delegated, whether the marketplace plan is accepted, or whether the password in state is still the live one.

💡 no_rotate is the line to put in front of whoever maintains this configuration. It is the one accident this module exists to prevent.

📥 Inputs

Input Type Default Notes
name string — Required. Force-new.
resource_group_name / location string — Required. Force-new. Region must offer zones.
admin_password string — Required, sensitive, force-new. 🔴 Editing it destroys the file system.
email string — Required. Force-new. 🔒 Shared with the vendor.
storage_sku string — Required, no default. Cold_LRS/Hot_LRS/Hot_ZRS. Force-new.
subnet_id string — Required. Force-new. 🔴 Must be delegated to Qumulo.Storage.
zone string — Required. Force-new. Required by the provider even with Hot_ZRS, which the service does not need.
offer_id / plan_id / publisher_id string null Force-new. Provider defaults apply when omitted.
tags map(string) {} ✅ The only field that updates in place.
timeouts object(...) null Provider defaults are long on purpose.
Full schemas
variable "admin_password" {
  type      = string
  sensitive = true
  # 🔴 FORCE-NEW. This is the INITIAL password only — editing it replaces the file system and loses the data, so
  # rotation happens inside Qumulo's own portal and the value here goes stale by design. `sensitive` redacts plan
  # output and does NOT encrypt state. Never emitted. See examples 3 and 4.
  validation {
    condition     = length(var.admin_password) >= 8
    error_message = "admin_password must be at least 8 characters. The provider applies the same floor. And note that changing this field later REPLACES the file system, so choose it deliberately."
  }
  validation {
    # The provider's own complexity rule, mirrored. The fourth class is "neither a letter nor a
    # digit", so an underscore counts as a special character.
    condition = length([
      for class in ["[a-z]", "[A-Z]", "[0-9]", "[^0-9A-Za-z]"] :
      class if can(regex(class, var.admin_password))
    ]) >= 3
    error_message = "admin_password must contain at least 3 of these 4 character classes ... The provider tests complexity BEFORE length, so a password that is also under 8 characters will report only this failure until the complexity is fixed."
  }
}

variable "storage_sku" {
  type = string
  # Closed set, and REQUIRED — so no default is invented: choosing between a cold and a hot tier for a caller would
  # be guessing about both cost and performance. `is_zone_redundant` is emitted instead. See example 8.
  validation {
    condition     = contains(["Cold_LRS", "Hot_LRS", "Hot_ZRS"], var.storage_sku)
    error_message = "storage_sku must be one of: Cold_LRS, Hot_LRS, Hot_ZRS ... ✅ Prefer Hot_ZRS where the region supports it — the LRS options do not survive the loss of an availability zone."
  }
}

variable "subnet_id" {
  type = string
  # Catches the common mistake of passing the VIRTUAL NETWORK's id, and states the delegation requirement the check
  # cannot enforce — the delegation lives on the subnet resource. See example 5.
  validation {
    condition     = can(regex("(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft[.]Network/virtualNetworks/[^/]+/subnets/[^/]+$", var.subnet_id))
    error_message = "subnet_id must be a full subnet Resource ID ... 🔴 The subnet must also be DELEGATED to Qumulo.Storage, which this module cannot verify ..."
  }
}

🧾 Outputs

Output Description Sensitive
id Resource ID under Qumulo.Storage. no
name / resource_group_name / location Identity. Force-new. no
storage_sku / is_zone_redundant Tier and redundancy. no
zone Placement zone. no
subnet_id The delegated subnet consumed. no
email 🔒 Deliberately un-redacted — example 7. no
offer_id / plan_id / publisher_id Marketplace identifiers in effect. no
has_admin_password 🔒 Always true — instead of the password. no
admin_password_rotation_requires_replacement 🔴 Always true. no
requires_marketplace_plan_acceptance Always true. no
subnet_must_be_delegated_to_qumulo Always true. no
mount_ip_addresses_not_exported 🔴 Always true — the addresses you mount are not available from Terraform. no
cluster_login_url_not_exported 🔴 Always true — the admin UI URL is not available either. no
marketplace_subscription_status_not_visible 🔴 Always true — a lapsed subscription produces no diff. no
performance_tier_not_managed_here Always true — no performance argument exists; it is set out of band. no
delegated_subnet_requires_256_addresses 🔴 Always true — a service minimum the provider does not check. no
creates_managed_resource_group Always true — a managed RG and a SaaS billing resource appear alongside. no
tags_are_the_only_updatable_argument 🔴 Always true — every other change is a replacement. no
provider_pinned_storage_sku_set The three SKUs this provider version accepts — a pinned snapshot. no
billed_from_creation_whether_used 🔴 Always true — an idle file system still bills. no
destroy_has_no_name_confirmation 🔴 Always true — the portal asks for the name; Terraform does not. no

No password output exists. Not sensitive-marked, not present.

🔴 Six of these outputs exist to name something this resource does NOT do. The mount addresses, the cluster login URL, the marketplace subscription status and the performance level are all real properties of a live file system that the provider does not surface — and every one of those gaps fails silently: the apply succeeds, the outputs look complete, and the absence is discovered only when something needs to connect, or when a bill arrives for a subscription that lapsed.

🧠 Architecture Notes

  • admin_password being force-new is the module's reason to exist. Rotating a credential is the most routine security action there is, and here Terraform will destroy a file system to do it. So the fact appears in the overview, in the variable description, in main.tf, and as a constant output — because a comment in one place would not be enough for something this expensive and this counter-intuitive.

  • The follow-on consequence is stated because it inverts normal practice. After an in-product rotation the value in state is deliberately stale, so the usual instinct — make Terraform authoritative again — is destructive. A module that flagged the force-new behaviour but not this would leave the second, subtler trap open.

  • The password is sensitive on the way in, never on the way out, and the difference is explained. sensitive redacts plan output and does not encrypt state, so the module says that plainly rather than letting the marking imply protection it does not provide. Only has_admin_password is emitted, following this suite's rule against re-emitting a secret already present in this module's state.

  • email is deliberately not treated as sensitive, and the reasoning is given. It is not a credential, it is shared with the vendor by design, and redacting it would hide the field most likely to be an individual's mailbox by mistake. This is the same call this library makes for publisher contact details elsewhere: where a value is published by design, hiding it protects nothing and costs a review.

  • No storage_sku default is invented. The provider makes it required, so there is no empty call to harden, and guessing between a cold and a hot tier would be guessing about cost and performance. is_zone_redundant is emitted so the resilience consequence of whichever value was chosen is legible without recalling what the suffix means.

  • The marketplace identifiers are left null rather than restated. Hard-coding the provider's own defaults would tie this module to a version of the offer and imply the values were reviewed choices. They are emitted so a procurement review still sees what was deployed.

  • Two prerequisite facts are emitted as constants because both fail at apply in ways that do not describe themselves. A missing marketplace acceptance produces a marketplace error that looks like neither a permission problem nor a missing dependency; a missing subnet delegation is invisible from this resource entirely, since the delegation lives on the subnet.

  • The long timeout defaults are documented as meaningful rather than defensive. A 90-minute create looks broken to somebody who has not seen it, and a pipeline with a short global timeout will kill the apply part-way — which is worse than waiting.

  • The lock advice is qualified honestly. A CanNotDelete lock protects against deletion and not against the force-new replacement that is the likelier accident here, so the module says the real control is procedural.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Destroying data while rotating a credential force-new behaviour flagged four times, incl. a constant output —
Reconciling deliberately-stale state the inversion stated explicitly —
False secrecy "redacts plans, does not encrypt state" stated —
Re-emitting a secret presence only; no password output exists —
Hidden vendor contact email deliberately un-redacted —
Unexamined redundancy is_zone_redundant emitted choose an LRS SKU, knowingly
A default that guesses cost or performance none invented for storage_sku —
Invisible prerequisites marketplace + delegation flags emitted —
Wrong-resource IDs subnet ID validated, VNet ID rejected —
Stale marketplace identifiers left to the provider's defaults supply your own, knowingly
Pipelines that kill a slow apply long provider defaults documented —
  • Before production: never edit admin_password. Rotate in Qumulo.
  • Before the first apply: accept the marketplace plan, and delegate the subnet.
  • Before choosing a SKU: only Hot_ZRS survives a zone loss, and it is force-new.
  • Before trusting the backend: the initial password is in state, in plaintext.
  • Before importing: a mismatched password means a replacement, and a replacement means data loss.

🚀 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.
  • ✅ tags is the only field that updates in place.
  • 🔴 NEVER change admin_password. It replaces the file system and loses the data.
  • 🔴 A plan that says "must be replaced" is a data migration. Read it in full.
  • ⚠️ Creation takes ~90 minutes. Do not let a pipeline timeout kill it. The read that follows a create runs inside the create timeout, and the read after an update inside the update timeout, so those budgets cover a little more than their names suggest.
  • 🔴 admin_password and email are never read back from the service. The provider repopulates both from your configuration on every refresh, so a change made to either outside Terraform produces no diff and no warning.
  • 🔴 Which is exactly why an import proposes a replacement. An import cannot populate those two fields, and both are required and force-new, so they read as empty until the real values are written into the configuration. The plan is not reporting a mismatch it detected — it is reporting the absence of values it could never have fetched.
  • 🔴 The resource exports only id. The underlying API returns the private IP addresses, the cluster login URL, a provisioning state and a marketplace subscription status; the provider surfaces none of them. So the mount targets and the administration URL — the two things you need in order to use the file system — come from the portal, and a composition cannot wire this module into anything automatically.
  • ⚠️ The create is guarded against overwriting an existing file system, conditionally. The provider looks the name up first and refuses with a "requires import" error if something is there — unless the caller's provider block disables that check, in which case the create proceeds against the existing resource. The switch is the caller's, so the guard is a conditional rather than a property of this module.
  • ⚠️ The ID is set during the create, not after it. So a create that fails partway through can leave the file system created and tracked in state while the apply reports an error; re-run rather than assuming nothing happened.
  • ⚠️ storage_sku's three values are pinned in the provider, as a literal list carrying a note that it should become a generated enum once an upstream specification issue is resolved. A class the vendor adds later needs a provider upgrade, not a change here — and the set has already moved once between offer generations.
  • ⚠️ Subnet ID casing is significant in one direction. The provider parses it case-sensitively when creating and case-insensitively when reading back, so a mis-cased ID is rejected up front rather than silently accepted.
  • 💡 A CanNotDelete lock guards deletion, not replacement — and it is the only backstop available, because lifecycle is not valid inside a module block. Note the portal's delete flow asks for the resource name to be typed; terraform destroy asks for nothing.

🧪 Testing

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

  • name, resource_group_name and location are non-empty;
  • admin_password is at least 8 characters and carries at least 3 of the 4 character classes — both mirroring the provider's own rule, so both are caught before any API call;
  • email looks like an email address;
  • storage_sku is exactly one of Cold_LRS, Hot_LRS, Hot_ZRS;
  • subnet_id is a subnet Resource ID, not a virtual network's;
  • zone is numeric;
  • no password is emitted as an output at all;
  • the module declares no provider block.

💡 Which command fires these checks is not interchangeable, and it is easy to state backwards. terraform validate run inside this module's own directory evaluates no variables and therefore fires none of them — it proves type-correctness and nothing more. Run against a calling configuration it fires them all. And terraform console -no-color, given an immediate end-of-input on stdin, fires them all as well, which makes it the practical offline harness: point it at a deliberately bad .tfvars and read the errors.

✅ Every condition above was driven both ways. A virtual-network ID fires the subnet check and a canonical subnet ID passes it; "Hot_GRS" and "hot_zrs" fire the SKU check; a lower-case-only password fires the complexity check while abcdefG1 passes; "1h30m", "1.5h", "500ms" and a bare "0" all pass the timeout check, which is the point of writing it to the full duration grammar rather than a narrower one.

⚠️ What the gate deliberately does not attempt, because each is a vendor or service fact that could change without the provider changing — and a validation {} failure blocks terraform destroy as well as apply, so refusing a legal value could strand a file system that already holds data:

  • which regions the offer is available in, and which availability zones a region actually has;
  • the delegated subnet's size — the service requires at least 256 addresses, and this module receives an ID rather than a prefix, so it cannot see the mask;
  • whether a marketplace plan identifier is one your subscription holds;
  • any password policy Qumulo applies on top of the provider's rule.

Each is reported through an output instead. That is the distinction this module draws throughout: enforce what the provider enforces, report what it does not.

What only plan and apply exercise:

  • whether the resource group exists and the caller may create the file system in it.

What no Terraform command checks at any stage:

  • 🔴 whether the marketplace plan is accepted for the subscription;
  • 🔴 whether the subnet is delegated to Qumulo.Storage;
  • 🔴 whether the password in state is still the live one — after an in-product rotation it is not, by design;
  • 🔴 whether the chosen zone exists in the region;
  • whether the file system's contents are backed up.

💬 Example Output

Outputs:

admin_password_rotation_requires_replacement = true
email                                        = "storage-platform@contoso.example"
has_admin_password                           = true
id                                           = "/subscriptions/00000000-.../providers/Qumulo.Storage/fileSystems/qfs-research-data"
is_zone_redundant                            = true
location                                     = "eastus"
name                                         = "qfs-research-data"
offer_id                                     = "qumulo-saas-mpp"
plan_id                                      = "azure-native-qumulo-v3"
publisher_id                                 = "qumulo1584033880660"
billed_from_creation_whether_used            = true
cluster_login_url_not_exported               = true
creates_managed_resource_group               = true
delegated_subnet_requires_256_addresses      = true
destroy_has_no_name_confirmation             = true
marketplace_subscription_status_not_visible  = true
mount_ip_addresses_not_exported              = true
performance_tier_not_managed_here            = true
provider_pinned_storage_sku_set              = [
  "Cold_LRS",
  "Hot_LRS",
  "Hot_ZRS",
]
requires_marketplace_plan_acceptance         = true
resource_group_name                          = "rg-storage-platform"
storage_sku                                  = "Hot_ZRS"
subnet_must_be_delegated_to_qumulo           = true
subnet_id                                    = "/subscriptions/00000000-.../virtualNetworks/vnet-storage/subnets/snet-qumulo"
tags_are_the_only_updatable_argument         = true
zone                                         = "1"

🔒 There is no password in this output, and that is deliberate (example 4). has_admin_password = true is what a consumer gets.

✅ is_zone_redundant = true with storage_sku = "Hot_ZRS" — the shape to expect for a system of record (example 8).

⚠️ Note id sits under Qumulo.Storage, a third-party provider namespace (example 1).

ℹ️ The three marketplace identifiers show the provider's defaults, because none was supplied — which is what makes emitting them worthwhile (example 6).

🔴 admin_password_rotation_requires_replacement = true is an instruction, not a status (example 3).

🔴 Notice what is NOT in this output: any way to reach the file system. No mount address, no cluster login URL. Both are real properties the underlying API returns, and the provider exports neither — which is what mount_ip_addresses_not_exported and cluster_login_url_not_exported are there to say out loud, because an output block that looks this complete is exactly where the gap would otherwise go unnoticed.

🔍 Troubleshooting

Symptom Cause Fix
A password change destroyed the file system admin_password is force-new. Never rotate here — use Qumulo's portal (example 3).
The state password no longer works It was rotated in Qumulo. Expected; do not "fix" the drift (example 3).
Apply fails with a marketplace error The plan is not accepted for the subscription. Accept it; no RBAC change helps (example 6).
Apply fails on the subnet It is not delegated to Qumulo.Storage. Delegate it on the subnet (example 5).
Plan rejects subnet_id A virtual network ID was passed. Pass the subnet's id (example 5).
Plan rejects storage_sku Not one of the three values, or wrong case. Cold_LRS/Hot_LRS/Hot_ZRS (example 8).
Plan rejects admin_password Under 8 characters, or fewer than 3 of the 4 character classes. Fix whichever was reported, then re-plan — the provider checks complexity first and stops there, so a second failure can follow (example 4).
Apply rejects a password that passed the gate A Qumulo policy on top of the provider's rule. Not visible offline; the provider's rule is a floor, not the whole policy (example 4).
Plan rejects name Under 2 or over 15 characters, or a leading/trailing hyphen. Rename before the first apply — name is force-new.
Apply fails reading the subnet The caller cannot read the subnet, not only write to the target resource group. Grant subnet read plus the join action; the provider fetches the subnet to check its delegation (example 5).
Apply fails after the delegation check passed The subnet is delegated but too small — the service needs at least 256 addresses. Use a /24 or larger; the provider checks the delegation, never the size (example 5).
No output gives the mount address The provider exports only id. Read the IP addresses from the resource's portal blade; see mount_ip_addresses_not_exported.
No output gives the admin UI URL Same cause. Read it from the portal overview; see cluster_login_url_not_exported.
The marketplace subscription lapsed and no plan showed it The subscription status is not exported. Monitor the marketplace subscription itself; see marketplace_subscription_status_not_visible.
A performance change was reverted, or never applied There is no performance argument on this resource. Change it in the portal; Terraform neither sets nor reverts it.
terraform destroy removed the file system with no confirmation The portal asks for the name; Terraform does not. Use a CanNotDelete lock — it blocks deletion, not replacement (example 11).
Apply fails on the zone The zone does not exist in that region. Choose an available zone.
The apply seems to hang for an hour Creation genuinely takes ~90 minutes. Expected (example 9).
A pipeline killed the apply mid-create A short global timeout. Raise it (example 9).
A lock did not stop a replacement Locks prevent deletion only. Read plans (example 11).
An import proposed a replacement The password (or another force-new field) differs. Restate them; plan first (example 11).
Wanted prevent_destroy lifecycle is not valid inside a module block. Use a CanNotDelete lock (example 11).

🔗 Related Docs

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