Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure File Share Directory Terraform Module

A directory inside an Azure file share, with its metadata — targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 📁 Creates one directory inside an Azure file share, with its data-plane metadata.
  • 🔴 It takes a data-plane URL, not an ARM Resource ID. storage_share_url is https://<account>.file.core.windows.net/<share> — the opposite of nearly every other module in this library, which makes passing a Resource ID the natural mistake. This module refuses one by name.
  • 🔴 The provider will not catch that for you offline. Its own validator on this argument only parses the URL properly once the provider has been configured; terraform validate never configures one, so a Resource ID, a wrong-cloud suffix and an unrelated host all pass the provider's check at validate. This module's check is the only one that fires offline.
  • 🔴 A deprecated alternative argument exists and is not exposed here. storage_share_id is still accepted by the provider on this line and its own notice says it will be removed in version 5.0.
  • 🔴 The provider marks storage_share_url Optional on 4.x, not Required — it is Required only inside a 5.0 branch gated behind a feature helper that is off by default. Reading the base schema gives the wrong answer for the pinned line.
  • 🔴 A forward slash in name builds a hierarchy, deliberately — so reports/2026/01 is one record standing in for three directories, two of which Terraform does not own.
  • ⚠️ These are DATA-PLANE calls against the file endpoint; an ARM role does not authorise them.
  • ⚠️ name may not be only dots, and there is no maximum length in the provider's check.
  • 🏷️ Carries no tags and no location; metadata is data-plane metadata, not Azure tags.

💡 Why it matters: this is a two-argument resource whose first argument is the one thing this library never asks for anywhere else — a URL — and whose second can quietly describe a tree.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

flowchart TB
  RG["terraform-azurerm-resource-group"]
  SA["terraform-azurerm-storage-account, whose shares map creates the share"]
  SHARE["a file share, addressed here by its data-plane URL"]
  THIS["terraform-azurerm-storage-share-directory"]
  FILE["terraform-azurerm-storage-share-file"]
  MID["the intermediate levels of a slashed name, NOT managed here"]
  RBAC["terraform-azurerm-role-assignments, scoped to the ACCOUNT"]

  RG -->|"name and location"| SA
  SA -->|"shares map"| SHARE
  SHARE -->|"URL, NOT an ARM Resource id"| THIS
  THIS -.->|"a slashed name leaves these unowned"| MID
  THIS -->|"path, so the directory exists before the file"| FILE
  SA -->|"the ACCOUNT is the usable RBAC scope, not this record"| RBAC
  RBAC -.->|"a file data role, or a shared key"| THIS

  classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
  classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
  classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733

  class THIS me
  class SHARE target
  class RG,SA,FILE,MID,RBAC sib
Loading

The share arrives as a URL, not a Resource ID — and it is created through the storage-account module's shares map, because this library has no standalone module for a file SHARE. The FILES in it are a different matter: terraform-azurerm-storage-share-file owns those, and takes this module's name as its path, which is why that edge is solid. The dotted edges are what nothing owns — the intermediate levels of a slashed name — and the access path that makes the calls possible.


🧬 What this module builds

flowchart TB
  URL["storage_share_url, force-new, a DATA-PLANE URL not a Resource id"]
  NM["name, force-new, a slash builds a hierarchy"]
  MD["metadata, the ONLY updatable argument"]
  DEP["storage_share_id, DEPRECATED, removed in 5.0, NOT rendered here"]
  THIS["azurerm_storage_share_directory.this"]
  OURL["this_resource_takes_a_data_plane_url_not_a_resource_id"]
  ODEP["a_deprecated_alternative_argument_is_not_exposed_here"]
  ODEPTH["name_depth and parent_name"]
  OMID["intermediate_directories_are_not_managed_by_this_module"]
  ORENAME["renaming_this_directory_destroys_its_contents"]

  URL --> THIS
  NM --> THIS
  MD --> THIS
  DEP -.->|"deliberately omitted"| THIS
  THIS --> OURL
  THIS --> ODEP
  THIS --> ODEPTH
  THIS --> OMID
  THIS --> ORENAME

  classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
  classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
  classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733

  class THIS me
  class OURL target
  class URL,NM,MD,DEP,ODEP,ODEPTH,OMID,ORENAME sib
Loading

Resource inventory

Resource Count Notes
azurerm_storage_share_directory 1 (this) one directory; files are separate

ℹ️ The Terraform ID is a data-plane identifier built from the file endpoint — not an ARM Resource ID, and not usable as the scope of a role assignment.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures the provider, its authentication, and the mandatory features {} block
Resources created 1

Schema notes that bite

  • 🔴 storage_share_url is a DATA-PLANE URL, not an ARM Resource ID.
  • 🔴 The provider's validator on it is weaker offline than it looks. It parses against the configured cloud's storage domain suffix only after the provider is configured, and falls back to a bare non-empty-path check until then — so at terraform validate an Azure Resource ID, a wrong-cloud suffix and https://example.com/x are all accepted by the provider. The strict parse needs credentials.
  • 🔴 A deprecated storage_share_id still exists on this line, paired with the URL in an ExactlyOneOf, and is removed in 5.0. This module renders only the replacement.
  • 🔴 storage_share_url is Optional and Computed on 4.x, Required only in the provider's 5.0 branch — which is gated behind a feature helper that is off by default.
  • ⚠️ ExactlyOneOf tests PRESENCE, not truth. Rendering only the URL satisfies it; rendering both breaks it.
  • 🔴 A forward slash in name is legal and intended — the provider's source says so. A leading slash is refused.
  • ⚠️ name may not consist only of dots, and the forbidden characters are " \ : | < > * ?.
  • 🔴 A metadata key must match ^[a-z_][a-z0-9_]+$ — lowercase, and at least two characters, so a is rejected along with Owner and owner-team.
  • 🔴 A metadata key may not be a C# keyword, matched case-insensitively — class, default, object, string, event, lock, null and this are all refused. The provider enforces both rules; this module mirrors them so the message names the key.
  • ⚠️ No maximum name length is set by the provider, so an over-long name fails at the service.
  • 🔴 Data-plane calls, and the Terraform ID cannot scope a role assignment.
  • ✅ Force-new: name, storage_share_url. Only metadata updates in place.
  • ✅ A requires-import guard is present, feature-flag-wrapped; all four timeouts exist.
  • No tags, no location — metadata is data-plane metadata, which Azure Policy does not see.

🔑 Required Azure RBAC Roles / Permissions

Least-privilege, at the smallest scope that works:

  • 🔴 These are DATA-PLANE calls. Contributor on the account does not authorise them.
  • Storage File Data SMB Share Contributor on the storage account, or a shared key — depending on which authentication the caller's provider configuration negotiates for the file endpoint.
  • ⚠️ Scope the role to the storage account, not to this module's id.
  • ⚠️ Network reachability to the file endpoint — a firewall that excludes the caller consumes the whole timeout before failing.

🔓 This module handles no credential and emits none.


Azure Prerequisites

  • An existing storage account and, in it, an existing file share — created through the account module's shares map.
  • Network reachability from the caller to the account's file endpoint.
  • The caller configures provider "azurerm" { features {} }, authentication, and the subscription.

📁 Module Structure

terraform-azurerm-storage-share-directory/
├── providers.tf     # required_version, pinned azurerm ~> 4.0, no provider block
├── variables.tf     # 4 typed inputs, 9 validations, the universal timeouts tail
├── main.tf          # the keystone `this`, dynamic timeouts
├── outputs.tf       # 27 outputs: id first, then identity, then posture flags
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

module "reports_dir" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"

  # A URL -- NOT a Resource ID.
  storage_share_url = "https://stgfiles01.file.core.windows.net/reports"
  name              = "2026"
}

🔴 Passing module.storage.id here is refused, with a message saying which value is wanted — it is the mistake this module is most likely to catch.


🔌 Cross-Module Contract

Consumes

Input Type Source
storage_share_url string the shares map on terraform-azurerm-storage-account

Emits

Output Description
id Terraform ID — a data-plane identifier, not an RBAC scope (first)
share_name / storage_account_name / share_host Computed from the URL
name_depth / parent_name Computed — how many levels, and the one above
this_resource_takes_a_data_plane_url_not_a_resource_id Constant true
metadata_keys_are_lowercase_only_and_may_not_be_csharp_keywords Constant true — an unusually strict key rule
the_providers_own_url_check_is_weaker_offline_than_it_looks Constant true — the provider's check does not fire at plan
a_deprecated_alternative_argument_is_not_exposed_here Constant true

📚 Example Library

1 · One directory at the share root
module "reports_dir" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"

  storage_share_url = "https://stgfiles01.file.core.windows.net/reports"
  name              = "2026"
}

ℹ️ name_depth is 1 and parent_name is null — this directory sits directly in the share.

ℹ️ share_name reads reports and storage_account_name reads stgfiles01, both parsed from the URL.

2 · 🔴 The argument that is a URL, not a Resource ID
# REJECTED at plan:
storage_share_url = module.storage.id
# "/subscriptions/.../storageAccounts/stgfiles01"

# CORRECT -- the share's data-plane endpoint:
storage_share_url = "https://stgfiles01.file.core.windows.net/reports"

🔴 Nearly every other module in this library takes an ARM Resource ID in the equivalent position, so reaching for one here is the natural instinct — and it is wrong.

✅ This module refuses a Resource ID with a message naming the right value. It is the probable mistake rather than an invented rule, and it is the single thing about this resource that differs from the rest of the library.

ℹ️ There is no standalone file share module in this library — shares are created through the storage-account module's shares map, so the URL is assembled from the account name and the share name.

3 · 🔴 A deprecated alternative this module does not expose
# The provider ALSO accepts this on the pinned 4.x line:
#   storage_share_id = "https://stgfiles01.file.core.windows.net/reports"
#
# Its own deprecation notice: "will be removed in version 5.0 of the Provider."

🔴 The two arguments sit in an ExactlyOneOf pair. This module renders only storage_share_url, which satisfies the rule — because ExactlyOneOf tests whether an argument is present, not what it contains — and means nothing here changes when the deprecated one disappears.

⚠️ Rendering both would break the pairing. A module that offered the caller a choice would have to render one conditionally, which is why this one simply does not offer it.

ℹ️ a_deprecated_alternative_argument_is_not_exposed_here is emitted so the omission reads as a decision rather than an oversight.

4 · 🔴 The schema says Optional; the module says required
# The provider's base schema marks storage_share_url REQUIRED.
# The 4.x branch then OVERWRITES it as Optional + Computed, because of the
# deprecated pairing -- and that branch is the one in force on this pinned line.

🔴 Reading the base schema gives the wrong answer for 4.x. The Required version lives inside the provider's 5.0 branch, which is gated behind a feature helper that is off by default.

✅ This module requires the argument of its caller regardless. Rendering neither would produce a resource with no share; and in 5.0 the argument becomes genuinely Required, which is what this module already assumes.

ℹ️ the_provider_marks_the_share_url_optional_on_this_line is emitted so nobody comparing the module to the schema concludes it is gratuitously strict.

5 · 🔴 A slash builds a hierarchy — and leaves levels unmanaged
module "deep" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"

  storage_share_url = "https://stgfiles01.file.core.windows.net/reports"
  name              = "reports/2026/01"
}
name_depth  = 3
parent_name = "reports/2026"

🔴 The slash is deliberate, not tolerated. The provider's own source notes it does not forbid a slash in a non-leading segment, because that is how nested directories are addressed.

⚠️ That name describes three directories and this is one record. The two levels above are not owned by Terraform, so their metadata is whatever the service produced.

💡 name_depth and parent_name are emitted so a review sees at a glance that one resource is standing in for a tree. Both ignore empty segments, so reports//2026/ still gives a depth of 2 and a parent of reports.

6 · Declaring each level
module "tree" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"
  for_each = toset(["reports", "reports/2026", "reports/2026/01"])

  storage_share_url = "https://stgfiles01.file.core.windows.net/reports"
  name              = each.key

  metadata = { managed_by = "terraform" }
}

✅ Now every level is owned by Terraform, and each carries the metadata you intended.

⚠️ Terraform does not know these are nested. Nothing orders reports before reports/2026; add depends_on at the call site if the service requires the parent to exist first.

💡 A for_each over a toset keys each instance by its own path, so adding a level does not re-index the others.

7 · 🔴 A rename is a delete
# Before:
name = "2026"

# After -- this DESTROYS the directory and its contents, then creates a new one.
name = "2026-archive"

🔴 name is force-new. Azure Files supports renaming a directory as a service operation, but not through this resource — a change here is destroy-then-create.

⚠️ Whatever is inside goes with it, including files this module did not create. renaming_this_directory_destroys_its_contents is emitted so that is visible in a review.

🔒 So delete rights on this module are delete rights on the contents, which the permissions section states.

8 · What the name may and may not be
# ACCEPTED:
name = "2026"
name = "reports/2026/01"   # a hierarchy
name = "reports.2026"      # dots are fine
name = ".hidden"           # a leading dot is fine
name = "<300 characters>"  # no length limit is set by the provider

# REJECTED at plan:
name = "."                 # only dots
name = ".."                # only dots
name = "/reports"          # a LEADING slash
name = "reports:2026"      # forbidden character
name = "a|b"               # forbidden character

⚠️ The forbidden set is " \ : | < > * ?, and a name made only of dots. Everything else the provider allows, this module allows.

🔴 There is no maximum length in the provider's check, so this module invents none — an over-long name fails at the service rather than at plan. the_provider_sets_no_maximum_name_length records that.

9 · Metadata, and where it is not visible
module "reports_dir" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"

  storage_share_url = "https://stgfiles01.file.core.windows.net/reports"
  name              = "2026"

  metadata = {
    owner_team = "finance"
    retention  = "7y"
  }
}

⚠️ This is data-plane metadata, NOT Azure resource tags. Azure Policy, cost reporting and tag inheritance do not see it — tag the storage account instead.

✅ It is the only argument that updates in place. Both others are force-new, so metadata is the whole of what an in-place change can touch.

10 · Sovereign clouds
module "gov_dir" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"

  storage_share_url = "https://stgfiles01.file.core.usgovcloudapi.net/reports"
  name              = "2026"
}

✅ The domain suffix differs between clouds and this module does not hard-code one. The account name is taken from the host's leading label, which is the same shape in every cloud.

ℹ️ share_host is emitted precisely because the suffix is what tells you which cloud a resource is in, and it appears nowhere else in the module's inputs.

11 · Adopting a directory that already exists
terraform import 'module.reports_dir.azurerm_storage_share_directory.this' \
  "https://stgfiles01.file.core.windows.net/reports/2026"

ℹ️ The import ID is a data-plane URL, consistent with the identifier this resource emits.

✅ A create refuses to overwrite an existing directory of the same name — the provider reads first and returns a requires-import error, wrapped in a caller-configured feature flag.

⚠️ Importing adopts the existing metadata. Declaring a metadata map afterwards replaces it on the first apply.

12 · 🏗️ End-to-end composition
module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-files"
  location = "eastus"
}

# The share is created through the ACCOUNT module -- there is no standalone
# file share module in this library.
module "storage" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stgfiles01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  shares = {
    reports = { name = "reports", quota = 100 }
  }
}

module "reports_2026" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-share-directory.git?ref=v1.0.0"

  # Composed from the account name and the share name -- NOT module.storage.id.
  storage_share_url = "https://${module.storage.name}.file.core.windows.net/reports"
  name              = "2026"

  metadata = { owner_team = "finance" }
}

# The role that makes the calls above possible -- scoped to the ACCOUNT.
module "files_rbac" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.storage.id

  role_assignments = {
    pipeline = {
      role_definition_name = "Storage File Data SMB Share Contributor"
      principal_id         = var.pipeline_identity_object_id
    }
  }
}

output "share_layout" {
  value = {
    account    = module.reports_2026.storage_account_name
    share      = module.reports_2026.share_name
    directory  = module.reports_2026.name
    depth      = module.reports_2026.name_depth
    rbac_scope = module.storage.id
  }
}

🔴 The URL is composed, not taken from an output. The account module emits id and name, and the share URL is built from the account's name plus the share's — because a file share here is a map entry rather than a module with its own outputs.

⚠️ rbac_scope is the ACCOUNT. This module's own id is a data-plane identifier and cannot scope a role assignment.

💡 The files inside this directory are a separate resource whose content is uploaded from wherever Terraform runs — this module creates directories only.


📥 Inputs

Target — storage_share_url (required by this module, force-new; a URL, not a Resource ID). Identity — name (required, force-new; slashes build a hierarchy). Optional — metadata, the only argument that updates in place. Tail — timeouts. There is no tags argument and no location.

Full schemas
variable "storage_share_url" {
  type = string # https://<account>.file.core.windows.net/<share>
                # NOT an ARM Resource ID. The provider marks this Optional on
                # 4.x because of a deprecated pairing; this module requires it.
}

variable "name" {
  type = string # A slash BUILDS A HIERARCHY. Forbidden: " \ : | < > * ?
                # and a name made only of dots. No maximum length.
}

variable "metadata" {
  type    = map(string) # data-plane metadata, NOT Azure resource tags.
  default = {}          # Keys: ^[a-z_][a-z0-9_]+$ -- lowercase, 2+ chars, and
}                       # NOT a C# keyword (class, default, object, string, ...)

🧾 Outputs

Output Description Notes
id Terraform ID — a data-plane identifier first; not an RBAC scope
name The directory name, which may contain slashes
storage_share_url The share URL, as configured a URL, not an ID
share_name Computed from the URL
storage_account_name Computed from the host's leading label RBAC
share_host Computed, including the cloud suffix which cloud
metadata Data-plane metadata not Azure tags
name_depth Computed — levels named design review
parent_name Computed — the level above, or null composition
this_resource_takes_a_data_plane_url_not_a_resource_id Constant true design
metadata_keys_are_lowercase_only_and_may_not_be_csharp_keywords Constant true naming policy
the_providers_own_url_check_is_weaker_offline_than_it_looks Constant true design rationale
a_deprecated_alternative_argument_is_not_exposed_here Constant true v5.0
the_provider_marks_the_share_url_optional_on_this_line Constant true
these_are_data_plane_calls_not_arm_calls Constant true access
the_id_is_a_data_plane_identifier_not_an_rbac_scope Constant true RBAC
a_forward_slash_in_the_name_builds_a_hierarchy Constant true
intermediate_directories_are_not_managed_by_this_module Computed design
the_name_may_not_be_only_dots Constant true naming
the_provider_sets_no_maximum_name_length Constant true naming
force_new_fields Two change planning
updatable_fields Metadata alone change planning
renaming_this_directory_destroys_its_contents Constant true blast radius
this_resource_creates_no_files Constant true composition
create_refuses_an_existing_directory Constant true by default
the_import_guard_can_be_disabled_by_a_provider_feature Constant true
all_four_timeouts_are_honoured_here Constant true
fields_azure_returns_on_read Where drift is detectable drift
this_resource_supports_no_azure_resource_tags Constant true tagging

There is no secret on this resource, so nothing is redacted.


🧠 Architecture Notes

The first argument is the one thing this library never asks for anywhere else. storage_share_url is a data-plane endpoint, not an ARM Resource ID, and nearly every other module here takes an ID in the equivalent position. That makes reaching for module.storage.id the natural instinct and the wrong one, so the module refuses a Resource ID explicitly and says which value it wants. There is also no standalone file share module in this library — shares come from the storage-account module's shares map — so in practice the URL is composed from the account name and the share name rather than read from an output.

The provider's schema reads backwards for the pinned line, and that is worth understanding rather than working around. The base schema marks storage_share_url Required; a branch guarded by a features.FivePointOh() helper then overwrites it as Optional and Computed and adds a deprecated storage_share_id beside it, the two paired in an ExactlyOneOf. That helper is off by default, so the Optional version is the one in force on 4.x. This module requires the argument of its caller regardless — rendering neither would produce a resource with no share — and emits the discrepancy so nobody comparing the module against the schema concludes it is gratuitously strict.

The deprecated argument is not exposed at all, per this suite's rule against deprecated names. That works cleanly because ExactlyOneOf tests whether an argument is present rather than what it contains: a module rendering only the replacement satisfies the pairing, and needs no change when the deprecated one is removed in 5.0. Offering the caller a choice would mean rendering one conditionally, which is a lot of machinery to support an argument that is going away.

A forward slash in the name is intended, and it is the quiet one. The provider's source explicitly declines to forbid a slash in a non-leading segment, because that is how nested directories are addressed. So reports/2026/01 is legal and describes three directories — of which this record is one. The other two are not owned by Terraform, so their metadata is whatever the service produced. name_depth and parent_name are computed and emitted to make that visible in a review rather than something a reader has to notice by counting slashes, and both ignore empty segments so a stray slash does not distort them.

Almost nothing here updates. name and storage_share_url are both force-new, so metadata is the whole of what an in-place change can touch — and a rename is a destroy-and-create that takes the directory's contents with it, including files this module never created. Azure Files does support renaming a directory as a service operation; this resource does not expose it.


🧱 Design Principles

Concern This module's position Why
The share URL a Resource ID is refused by name it is the probable mistake, and unique to this resource in this library
The deprecated argument not exposed at all ExactlyOneOf tests presence, so the replacement alone satisfies it
Required-vs-Optional required of the caller, and the discrepancy emitted the Optional marking exists only because of the deprecated pairing
The account name parsed from the host's leading label the domain suffix differs by cloud; the label does not
Slashed names name_depth / parent_name computed one record standing in for a tree should be visible
Maximum length not invented the provider sets none; the absence is emitted as a fact
metadata named as not Azure tags Policy and cost reporting do not see it
Metadata keys lowercase, 2+ chars, no C# keywords mirrors the provider's own check, so the message names the key

🔒 The security-relevant control is delete: destroying this record, or changing name, removes the directory and everything inside it.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. This module is plan-only from a workstation; a human applies from CI.


🧪 Testing

terraform validate and terraform fmt -check are the offline proof gate. All 9 validation {} blocks fire at plan. Each was exercised with a negative fixture that fires it and a positive fixture that does not, driven from .tfvars files through terraform console — which, unlike terraform validate on a calling configuration, does evaluate root-module variable validations. A parse guard runs first, because a fixture that fails to parse reports exactly like a check that did not fire.

15 negative fixtures fired the checks they targeted, and 8 positive fixtures passed clean. The negatives cover the share URL five ways — including 🔴 an ARM Resource ID, the mistake this module exists to catch — and the name nine ways: empty, ., .., a leading slash, and each of the forbidden characters.

The positives prove the boundaries, and several pin behaviour rather than checking a rule:

  • 🔴 reports/2026/01 passes, because a slash builds a hierarchy — that is the provider's documented intent, not a leniency.
  • A 300-character name passes, because the provider sets no length limit and inventing one would reject legal input.
  • .hidden and reports.2026 pass, since only a name made entirely of dots is refused.
  • 🔴 A US Gov endpoint passes, proving the URL check does not hard-code the public-cloud domain suffix.

The computed outputs were driven through the same harness across four cases, one of which exists purely to pin behaviour: reports//2026/ yields a depth of 2 and a parent of reports, so a doubled or trailing slash does not distort either derived value. A sovereign-cloud case confirms the account name is taken from the host's leading label rather than by stripping a known suffix.

What only a real plan/apply exercises: whether the account and share exist, whether the caller can reach the file endpoint, and the requires-import guard.


💬 Example Output

id                     = "https://stgfiles01.file.core.windows.net/reports/2026"
name                   = "2026"
storage_share_url      = "https://stgfiles01.file.core.windows.net/reports"
share_name             = "reports"
storage_account_name   = "stgfiles01"
share_host             = "stgfiles01.file.core.windows.net"
name_depth             = 1
parent_name            = null
metadata               = { "owner_team" = "finance" }
force_new_fields       = ["name", "storage_share_url"]
updatable_fields       = ["metadata"]

🔍 Troubleshooting

Symptom Cause Fix
storage_share_url is an Azure Resource ID module.storage.id was passed This argument takes a data-plane URL
Contributor was not enough These are data-plane calls Grant a file data role, or use a shared key
A role assignment scoped to this module failed The ID is a data-plane identifier Scope to the storage account
A rename deleted the contents name is force-new It is a destroy-and-create, not a rename
The parent directory has no metadata Intermediate levels are not managed here Declare each level — see example 6
name consists only of dots . or .. Those are not usable directory names
name begins with a slash A leading slash The path is relative to the share root
name contains a forbidden character One of `" \ : < > * ?`
A long name passed validate and failed at apply The provider sets no length limit The file service does
A sovereign-cloud URL was expected to fail The check does not hard-code a domain suffix It works in every cloud
The apply hung then failed A firewall excludes the caller from the file endpoint Data-plane reachability, not RBAC
A create fails with a requires-import error A directory of that name already exists Import it — see example 11

🔗 Related Docs


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