Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Managed Redis Geo-Replication Terraform Module

Links up to five Managed Redis instances into one active geo-replication group β€” from a single declaration, because linking is reciprocal. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • βš™οΈ Creates one azurerm_managed_redis_geo_replication, named this. Three inputs, and every one of them matters.
  • πŸ”΄ This resource's id IS managed_redis_id β€” the provider says so outright. So it has no identity of its own, and two declarations against one instance produce a permanent alternating diff that neither Terraform nor Azure reports.
  • πŸ”΄ Linking is reciprocal, so declare this module ONCE per group. The instinct to declare it from every member for symmetry is precisely what breaks it.
  • βœ… The self-referential rule is enforceable, and enforced: linked_managed_redis_ids must not contain managed_redis_id. Both halves are inputs, which is rare in this family.
  • ⚠️ Every member must already share one geo_replication_group_name β€” set on the other resource, so the join is a bare string and no validation on either side can compare the values.
  • πŸ”‘ Takes no credential, unlike the parent module where plan access is key access.

πŸ’‘ Why it matters: three arguments, and the two hardest facts about them are that one is also the resource's own identity and one depends on a value this module cannot see.


❀️ Support this project

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


πŸ—ΊοΈ Where this fits in the family

This DAG is shared with the parent terraform-azurerm-managed-redis module, validated once per focus. What differs is which node is highlighted and what each node's text carries: this focus names the group-name rule and the SKU floor that live on the other resource, while the parent focus names its grouped default_database.

flowchart TB
  RG["terraform-azurerm-resource-group"]
  MR["terraform-azurerm-managed-redis -- each member sets the SAME geo_replication_group_name and needs Balanced_B3 or higher"]
  GEO["terraform-azurerm-managed-redis-geo-replication -- its id IS the anchor's id, so declare it ONCE per group"]
  PEER["other terraform-azurerm-managed-redis instances in other regions -- up to 4 more, making a group of 5"]
  APA["terraform-azurerm-managed-redis-access-policy-assignment -- grants one Entra ID principal data-plane access"]
  PE["terraform-azurerm-private-endpoint"]
  RC["terraform-azurerm-redis-cache -- a DIFFERENT ARM type, and its redis-linked-server geo-replication is a separate mechanism"]

  RG -->|"name as resource_group_name"| MR
  MR -->|"id as managed_redis_id -- the anchor"| GEO
  PEER -->|"id as linked_managed_redis_ids -- at most 4"| GEO
  MR -->|"group name set on default_database, invisible from the link resource"| PEER
  MR -->|"id as the parent for a policy assignment"| APA
  MR -->|"id as private_service_connection target"| PE
  RC -->|"not interchangeable: Microsoft.Cache/redis versus redisEnterprise"| MR

  style GEO fill:#0078D4,stroke:#004578,color:#ffffff
  style MR fill:#004578,stroke:#00335c,color:#ffffff
  style RG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style PEER fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style APA fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style PE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style RC fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

🧬 What this module builds

flowchart TB
  IN_ANCHOR["managed_redis_id -- the anchor. This resource's OWN id is this same value"]
  IN_LINKED["linked_managed_redis_ids -- a SET, at most 4, must NOT contain the anchor, and no two entries may be one id in two casings"]
  IN_T["timeouts -- all four, because the link set is genuinely updatable in place"]
  THIS["azurerm_managed_redis_geo_replication.this"]
  ARM["the SAME ARM object as the anchor instance -- no identity of its own, which is why two declarations collide"]
  OUT_ID["id equals managed_redis_id, then anchor_name"]
  OUT_GROUP["linked_names, group_size, remaining_group_capacity, spans_subscriptions, spans_resource_groups"]
  OUT_FLAG["two_declarations_for_one_instance_collide_silently, group_names_must_match_and_nothing_here_can_check_it, every_member_needs_balanced_b3_or_higher, region_spread_is_not_knowable_here"]

  IN_ANCHOR --> THIS
  IN_LINKED --> THIS
  IN_T --> THIS
  THIS -->|"links reciprocally, so declare it ONCE per group"| ARM
  THIS --> OUT_ID
  THIS --> OUT_GROUP
  THIS --> OUT_FLAG

  style THIS fill:#0078D4,stroke:#004578,color:#ffffff
  style ARM fill:#004578,stroke:#00335c,color:#ffffff
  style IN_ANCHOR fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_LINKED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_T fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_GROUP fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_FLAG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

Resource inventory

Resource Count Notes
azurerm_managed_redis_geo_replication.this 1 The keystone. Its id is the anchor instance's id.
timeouts block 0–1 All four operations β€” the link set is updatable in place.
The Managed Redis instances 0 None created here. Consumed by id, and left running on destroy.

βœ… Provider / Versions

Item Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None. The caller configures the provider, its authentication, and its mandatory features {} block.
Module type standalone
Keystone azurerm_managed_redis_geo_replication.this

Schema notes that bite

  • πŸ”΄ The resource's id is identical to managed_redis_id. The provider's attributes reference says so: "The ID of the Managed Redis Database Geo-Replication resource (same as managed_redis_id)", and its import path is just the cluster ID. Two declarations for one instance therefore claim one ARM object from two state addresses.
  • πŸ”΄ Linking is reciprocal. "If A is linked to B, both A and B will have the same linking state. There is no need to have duplicate azurerm_managed_redis_geo_replication resources for each."
  • πŸ”΄ The bound is 4, not 5. "Can contain up to 4 Managed Redis IDs, making up a group of 5 in total" β€” the anchor is counted automatically.
  • ⚠️ linked_managed_redis_ids is a set(string), so exact duplicates collapse β€” but ARM Resource IDs are case-insensitive, so two casings of one ID survive as two entries and count twice against the limit. This module rejects that.
  • ⚠️ managed_redis_id is force-new; the link set is not. Adding or removing a member is an in-place update, which is why all four timeouts keys exist here.
  • ⚠️ The ARM type is redisEnterprise, not redis. An azurerm_redis_cache ID is a different service and is rejected by name.
  • ℹ️ A Resource ID does not carry the region, only the subscription and resource group β€” so this module cannot tell whether the group actually spans regions. See region_spread_is_not_knowable_here.

πŸ”‘ Required Azure RBAC Roles / Permissions

Principal Permission Scope Why
The Terraform identity Microsoft.Cache/redisEnterprise/databases/write The anchor instance Create and update the link set.
The Terraform identity Microsoft.Cache/redisEnterprise/read Every member instance Resolve each ID in the group.
The Terraform identity Microsoft.Cache/redisEnterprise/databases/forceLinkToReplicationGroup/action Each linked instance The operation that performs the linking.

⚠️ Permissions are needed on every member, not just the anchor. And when spans_subscriptions or spans_resource_groups is true, those permissions live in more than one scope β€” which is also more than one place a lock or policy can block an update to this single resource.

ℹ️ Plan access here is genuinely read-only. This resource holds no key, password or connection string. Worth stating because it does not hold for the parent module: there, the default database's access keys are computed, sensitive attributes Terraform must read, so planning the parent is credential access.


Azure Prerequisites

  • Microsoft.Cache registered on the subscription of every member.
  • Two to five existing Managed Redis instances, each created by terraform-azurerm-managed-redis or equivalent.
  • The same geo_replication_group_name on every member, set on each instance's own default_database block. Nothing here can verify it.
  • Balanced_B3 or higher on every member. The parent module rejects the two SKUs demonstrably below the floor when a group name is set; beyond those, an undersized member fails at apply.
  • No persistence on any member. The provider documents that persistence works only on non-geo-replicated databases.
  • Only RediSearch or RedisJSON modules on any member, if modules are used at all.

πŸ“ Module Structure

terraform-azurerm-managed-redis-geo-replication/
β”œβ”€β”€ providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0. No provider block.
β”œβ”€β”€ variables.tf     # 3 typed inputs, 7 validations.
β”œβ”€β”€ main.tf          # One keystone `this`; a dynamic timeouts block; group facts derived in locals.
β”œβ”€β”€ outputs.tf       # id first, then the group shape, then the constants for what cannot be checked here.
β”œβ”€β”€ README.md        # This document.
β”œβ”€β”€ SCOPE.md         # The cross-module contract.
β”œβ”€β”€ LICENSE          # MIT.
└── .gitignore       # The canonical library ignore file.

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

Two arguments, one declaration, and the whole group is linked. Do not add a second declaration from cache_westus2 β€” see example 3.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
managed_redis_id Managed Redis Resource ID terraform-azurerm-managed-redis β†’ id
linked_managed_redis_ids Managed Redis Resource IDs terraform-azurerm-managed-redis β†’ id, one per member

Emits

Output Description Consumed by
id The anchor's Resource ID Audit β€” not a new identifier
anchor_name / linked_names Readable member names Plan review, audit
group_size / remaining_group_capacity Against the limit of 5 Capacity planning
spans_subscriptions / spans_resource_groups Permission and lock scoping Composition assertions
two_declarations_for_one_instance_collide_silently Constant true Read this one

πŸ“š 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 "cache_australiaeast_id" {
  description = "id of an existing cache australiaeast that these examples reference but do not create."
  type        = string
}

variable "cache_partner_region_id" {
  description = "id of an existing cache partner region that these examples reference but do not create."
  type        = string
}

variable "cache_southeastasia_id" {
  description = "id of an existing cache southeastasia that these examples reference but do not create."
  type        = string
}
1 Β· A two-region group
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

ℹ️ group_size reports 2 β€” the linked set plus the anchor, which the provider counts automatically.

⚠️ Both instances must already carry the same geo_replication_group_name. Setting it creates a one-member group on each; this resource is what joins them.

2 Β· A full five-instance group
module "cache_geo_global" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id = module.cache_eastus2.id

  linked_managed_redis_ids = [
    module.cache_westus2.id,
    module.cache_northeurope.id,
    var.cache_southeastasia_id,
    var.cache_australiaeast_id,
  ]
}

πŸ”΄ Four is the maximum for this set, and five for the group. The bound is on the input, not the group, because the anchor is counted for you. remaining_group_capacity reports 0 here.

⚠️ Adding a sixth region means removing one. There is no larger tier.

3 Β· The declaration that must not be duplicated
# CORRECT: one declaration links the whole group, because linking is reciprocal.
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

Declaring a second copy from cache_westus2 pointing back at cache_eastus2 looks symmetrical and is the one thing to avoid. Because this resource's id is managed_redis_id, the two declarations would be two Terraform resources claiming overlapping ARM state from different addresses β€” both plan cleanly, both apply, and every plan afterwards proposes to revert the other's link set. Azure raises nothing, because each individual write is legitimate.

πŸ”΄ two_declarations_for_one_instance_collide_silently carries a failure-timing table with a Never row for both drift and Azure errors. This is the batch-31-class collision: no identity to conflict on, so no conflict is detected.

βœ… The defence is a convention, not a check: exactly one geo-replication declaration per group, from whichever member you nominate as the anchor. "Anchor" is this module's word β€” Azure gives it no special standing.

4 Β· The self-referential rule, enforced
module "cache_geo_correct" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id = module.cache_eastus2.id

  # The anchor is included automatically. Listing it here is rejected at plan time.
  linked_managed_redis_ids = [module.cache_westus2.id]
}

βœ… This is one of the few cross-argument rules in this family that can be ENFORCED rather than reported, because both halves are inputs to the same module. The provider: "The ID of this Managed Redis is always included by default and does not need to be provided here."

⚠️ The comparison is case-insensitive, because ARM Resource IDs are β€” so a differently-cased copy of the anchor's ID is caught too.

ℹ️ Its limit, stated: the check compares strings. It cannot tell that two different IDs refer to the same instance through, say, a differently-cased resource group in an otherwise identical path β€” which is why a second check rejects duplicate casings within the set itself.

5 Β· The group-name rule this module cannot check
locals {
  # ONE source of truth. Nothing can verify that the members' names match, so do not type it twice.
  redis_geo_group = "orders-global"
}

module "cache_eastus2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-eastus2"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B3"

  default_database = {
    geo_replication_group_name = local.redis_geo_group
  }
}

module "cache_westus2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-westus2"
  resource_group_name = module.cache_rg.name
  location            = "westus2"
  sku_name            = "Balanced_B3"

  default_database = {
    geo_replication_group_name = local.redis_geo_group
  }
}

module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

πŸ”΄ The join between these resources is a bare STRING. The group name lives on each instance's default_database block, so this module never sees it and no validation on either side can compare the values. A mismatch produces no error at all β€” the link simply never forms.

βœ… Composition is the only defence, and that is what the local above is for. group_names_must_match_and_nothing_here_can_check_it is emitted so this is a documented limitation rather than a discovery.

6 Β· What every member needs, and where it is checked
module "cache_member" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-westus2"
  resource_group_name = module.cache_rg.name
  location            = "westus2"

  # Balanced_B3 or higher is required for geo-replication.
  sku_name = "Balanced_B3"

  default_database = {
    geo_replication_group_name = local.redis_geo_group

    # Only RediSearch and RedisJSON are permitted in a geo-replicated group.
    modules = [{ name = "RedisJSON" }]

    # Persistence is deliberately absent: it cannot coexist with geo-replication.
  }
}

⚠️ Three member-side rules, all invisible from this module, and all emitted here as constants so they are not forgotten: every_member_needs_balanced_b3_or_higher, only_redisearch_and_redisjson_modules_are_allowed_in_a_group, and persistence_cannot_be_combined_with_membership.

ℹ️ The parent module enforces what it can, per instance: it rejects the two sub-floor SKUs and the disallowed modules when that instance also sets a group name. An instance that sets no group name of its own was never checked β€” another reason to wire the name deliberately.

7 Β· A group spanning subscriptions
module "cache_geo_cross_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [var.cache_partner_region_id]
}

output "geo_spans_subscriptions" {
  value = module.cache_geo_cross_sub.spans_subscriptions
}

βœ… Allowed and reported, not rejected. The provider publishes no cross-subscription prohibition for Managed Redis geo-replication β€” unlike the Machine Learning inference-cluster attachment elsewhere in this library, where Microsoft does, and where this suite therefore enforces it. "Enforce what the provider documents" is per resource.

⚠️ It changes who needs what. The Terraform identity needs read access on every member, so a cross-subscription group means permissions in more than one subscription.

8 Β· What this module cannot tell you about regions
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

# The region check has to come from the member modules, not from here.
check "geo_group_actually_spans_regions" {
  assert {
    condition     = module.cache_eastus2.location != module.cache_westus2.location
    error_message = "Both members of the geo-replication group are in the same region."
  }
}

πŸ”΄ A Managed Redis Resource ID does not carry the region β€” only the subscription and resource group. So this module cannot confirm that a "geo"-replication group is geographically distributed at all, and would happily link an instance to four others in the same datacentre. region_spread_is_not_knowable_here says so.

βœ… The check block above is where that assertion belongs, using each member module's own location output.

9 Β· Adding a region later
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id = module.cache_eastus2.id

  linked_managed_redis_ids = [
    module.cache_westus2.id,
    module.cache_northeurope.id, # added in a later change
  ]
}

βœ… Adding or removing a member is an in-place UPDATE, not a replacement. Only managed_redis_id is force-new, which is why this resource has all four timeouts keys.

⚠️ Changing the anchor is a replacement, and it dissolves and rebuilds the group. If you need a different anchor, prefer leaving it alone β€” the anchor has no operational significance.

10 Β· Custom timeouts
module "cache_geo_timeouts" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]

  timeouts = {
    create = "45m"
    read   = "10m"
    update = "45m"
    delete = "45m"
  }
}

ℹ️ All four keys exist here, because the link set is updatable in place. Linking several regions is a slow operation β€” the provider's default create is 30 minutes, and a five-member group can want more.

11 Β· Dissolving a group
# Removing this module's declaration -- or running `terraform destroy` against it -- unlinks every member.
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id         = module.cache_eastus2.id
  linked_managed_redis_ids = [module.cache_westus2.id]
}

output "unlinking_is_not_deleting" {
  value = module.cache_geo.unlinking_does_not_delete_any_instance
}

βœ… Destroying this resource dissolves the group and leaves every instance running, billing, and holding whatever data it had. It is a link, not a lifecycle owner.

πŸ”΄ The reverse is the sharper direction. Deleting a linked instance outside Terraform leaves this resource pointing at an ID that no longer exists, and no drift is reported, because the stored ID is still the ID it was given.

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

locals {
  redis_geo_group = "orders-global"
}

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

  name     = "rg-cache-prod"
  location = "eastus2"

  tags = {
    environment = "prod"
    workload    = "cache"
  }
}

module "cache_eastus2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-eastus2"
  resource_group_name = module.cache_rg.name
  location            = "eastus2"
  sku_name            = "Balanced_B5"

  default_database = {
    geo_replication_group_name = local.redis_geo_group
    modules                    = [{ name = "RedisJSON" }]
  }

  tags = {
    environment = "prod"
  }
}

module "cache_westus2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-westus2"
  resource_group_name = module.cache_rg.name
  location            = "westus2"
  sku_name            = "Balanced_B5"

  default_database = {
    geo_replication_group_name = local.redis_geo_group
    modules                    = [{ name = "RedisJSON" }]
  }

  tags = {
    environment = "prod"
  }
}

module "cache_northeurope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis.git?ref=v1.0.0"

  name                = "redis-orders-northeurope"
  resource_group_name = module.cache_rg.name
  location            = "northeurope"
  sku_name            = "Balanced_B5"

  default_database = {
    geo_replication_group_name = local.redis_geo_group
    modules                    = [{ name = "RedisJSON" }]
  }

  tags = {
    environment = "prod"
  }
}

# ONE declaration for the whole group. Linking is reciprocal.
module "cache_geo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

  managed_redis_id = module.cache_eastus2.id

  linked_managed_redis_ids = [
    module.cache_westus2.id,
    module.cache_northeurope.id,
  ]
}

# The composition's own output carries what no single member can enforce.
output "cache_geo_posture" {
  value = {
    # The group name matched only because it came from one local -- nothing checks it.
    group_names = [
      module.cache_eastus2.geo_replication_group_name,
      module.cache_westus2.geo_replication_group_name,
      module.cache_northeurope.geo_replication_group_name,
    ]

    group_size         = module.cache_geo.group_size
    capacity_remaining = module.cache_geo.remaining_group_capacity

    # Geo-replication IS the durability story here: persistence cannot coexist with it.
    members_are_memory_only_individually = module.cache_eastus2.has_no_durability_beyond_the_instance

    # And this module cannot confirm the group is geographically spread.
    region_spread_unverifiable = module.cache_geo.region_spread_is_not_knowable_here
  }
}

check "geo_group_is_actually_geographic" {
  assert {
    condition = length(distinct([
      module.cache_eastus2.location,
      module.cache_westus2.location,
      module.cache_northeurope.location,
    ])) == 3
    error_message = "The geo-replication group has members sharing a region, which defeats its purpose."
  }
}

πŸ’‘ Order comes from references alone β€” the instances from the resource group, the link from three instance ids. No depends_on anywhere.

πŸ”΄ One geo-replication declaration, three members. A second declaration from any other member would collide with this one silently.

βœ… The check block asserts what this module structurally cannot: that the group spans regions, using each member's own location.

⚠️ module.cache_rg is referenced for name and location only. The resource-group module takes tags as an input and emits none.


πŸ“₯ Inputs

Required (2) β€” managed_redis_id (the anchor, and this resource's own id), linked_managed_redis_ids (a set, at most 4, excluding the anchor).

Universal tail (1) β€” timeouts, all four keys. There is no tags input, because the resource exposes no tags attribute β€” tag the member instances instead.

Full input schemas
Name Type Default Required
managed_redis_id string β€” βœ…
linked_managed_redis_ids set(string) β€” βœ…
timeouts object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) }) null β€”
# Both ID arguments take the same shape:
#   /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Cache/redisEnterprise/<name>
#
# linked_managed_redis_ids:
#   - at least 1, at most 4 (the anchor makes 5)
#   - must NOT contain managed_redis_id (case-insensitively)
#   - must not contain one ID in two different casings

7 validations, all proven to fire: the anchored ID shape on both arguments, the empty and over-four bounds, the self-referential exclusion, the duplicate-casing check, and the timeouts duration format.

Nothing is grouped here β€” three arguments, and the two ID arguments are related by a rejection rather than a joint rule. See the SCOPE.


🧾 Outputs

Output Description Notes
id The anchor's Resource ID Emitted first; not a new identifier
managed_redis_id The same value, echoed
anchor_name The anchor's name, parsed "Anchor" is this module's word, not Azure's
resource_group_name / subscription_id Parsed from the anchor's ID
linked_managed_redis_ids The other members, sorted Excludes the anchor
linked_names Their names, parsed and sorted Readable in a plan
group_size Linked set plus the anchor The provider caps it at 5
remaining_group_capacity How many more can join 0 means full
spans_subscriptions Reported, not rejected No prohibition is published
spans_resource_groups Several lock and policy scopes
region_spread_is_not_knowable_here Constant true An ID carries no region
two_declarations_for_one_instance_collide_silently Constant true Read this one
group_names_must_match_and_nothing_here_can_check_it Constant true A bare-string join
every_member_needs_balanced_b3_or_higher Constant true A fact about the other resource
only_redisearch_and_redisjson_modules_are_allowed_in_a_group Constant true The provider enforces nothing
persistence_cannot_be_combined_with_membership Constant true Alternative durability strategies
unlinking_does_not_delete_any_instance Constant true Both directions
accepts_no_credential Constant true Contrast with the parent module

No output is a secret, and nothing this module accepts is one either.


🧠 Architecture Notes

The resource has no identity of its own, and that is the central fact. Its id is managed_redis_id β€” the provider's attributes reference says so, and its import path is just the cluster ID. So two declarations against one instance are two Terraform resources claiming one ARM object from two different state addresses, with nothing to conflict on: both plan, both apply, and every plan afterwards proposes to revert the other's link set. Azure raises nothing, because each write is individually legitimate. Combined with reciprocal linking β€” one declaration links the whole group β€” the natural instinct to declare it from every member for symmetry is exactly the failure mode.

One cross-argument rule is enforceable and three are not, and the difference is visibility. The self-referential rule has both halves as inputs, so linked_managed_redis_ids is checked against managed_redis_id at plan time, case-insensitively. The group-name match, the Balanced_B3 floor and the module restriction all live on the other resource's default_database block, invisible from here β€” so they are emitted as constants with the parent module named as where enforcement happens. The group-name rule is the sharpest of the three because its join is a bare string: a mismatch produces no error, and the link simply never forms.

The bound is on the input, not the group. Four linked IDs make a group of five, because the anchor is counted automatically. And because set(string) deduplicates exact strings but ARM Resource IDs are case-insensitive, two casings of one ID survive as two set members and consume two of the four slots β€” which a second validation rejects.

A Resource ID carries no region. Subscription and resource group, yes; region, no. So this module can report that a group spans subscriptions or resource groups, and cannot report the one thing the feature exists for. That belongs in a check block over the member modules' own location outputs, which the README demonstrates rather than pretending the module could do it.

Update semantics are asymmetric. Adding or removing a member is an in-place update, so all four timeouts keys exist; changing the anchor is a replacement that dissolves and rebuilds the group. Since the anchor has no operational significance, leaving it alone is free.

Segment arithmetic. The anchor ID is nine segments, so the instance name is length - 1 and the resource group is index 4 β€” written in a comment, because an off-by-one yields the literal redisEnterprise, which formats and validates perfectly.


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Both arguments are required There is no empty call to harden, and no enum to flip. The module's value is in what it rejects β€”
The anchor listed among the links Rejected at plan time, case-insensitively β€”
One ID in two casings Rejected β€” a set would not catch it, and it consumes two of four slots β€”
More than four links Rejected, with the group-of-five arithmetic explained β€”
An azurerm_redis_cache ID Rejected by name β€” a different ARM type β€”
A child path such as /databases/default Rejected β€” the regex is anchored at both ends β€”
Credentials None accepted, none emitted β€”
Cross-subscription groups Allowed and reported β€” no prohibition is published, unlike the ML attachment elsewhere in this library β€”
Region spread Not checkable. Reported as a constant, with the check block that does work shown in the README β€”

⚠️ What this table cannot cover. Three of the four rules governing group membership live on the member instances, not here. The module names them and names where they are enforced.


πŸš€ Runbook

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

Pin the module by tag, never by branch:

source = "git::https://github.com/microsoftexpert/terraform-azurerm-managed-redis-geo-replication.git?ref=v1.0.0"

Authored and verified plan-only. No cloud apply happens during authoring or review; a human applies from CI.


πŸ§ͺ Testing

Gate What it proves What it does not
terraform init -backend=false The provider constraint resolves Nothing about Azure
terraform validate HCL parses; types line up; validations are declared It does not run root-module variable validations from a calling configuration
terraform fmt -check Canonical formatting Nothing semantic
terraform console with a .tfvars file The real offline harness. All 7 validations proven to fire, by line number Nothing needing an Azure API call
terraform plan Provider-level validation Requires credentials; not run here
Apply Whether the members' group names match, their SKUs qualify, and the group actually spans regions β€”

All 7 validations were proven individually by line number β€” Terraform hard-wraps messages, so grepping for a phrase silently matches nothing. The duplicate-casing fixture necessarily also trips the ID-shape check, because an uppercased ARM path fails the anchored regex too; that overlap is by construction and is stated rather than contrived around.

Every derived local was printed against four shapes β€” a two-member group, a full five-member group, a cross-subscription group and a cross-resource-group group. That is what confirmed the nine-segment arithmetic yields redis-eastus2 rather than the literal redisEnterprise, and that remaining_group_capacity reaches 0 exactly at the provider's limit.


πŸ’¬ Example Output

Outputs:

accepts_no_credential = true
anchor_name = "redis-orders-eastus2"
every_member_needs_balanced_b3_or_higher = true
group_names_must_match_and_nothing_here_can_check_it = true
group_size = 3
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-cache-prod/providers/Microsoft.Cache/redisEnterprise/redis-orders-eastus2"
linked_managed_redis_ids = [
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-cache-prod/providers/Microsoft.Cache/redisEnterprise/redis-orders-northeurope",
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-cache-prod/providers/Microsoft.Cache/redisEnterprise/redis-orders-westus2",
]
linked_names = [
  "redis-orders-northeurope",
  "redis-orders-westus2",
]
managed_redis_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-cache-prod/providers/Microsoft.Cache/redisEnterprise/redis-orders-eastus2"
only_redisearch_and_redisjson_modules_are_allowed_in_a_group = true
persistence_cannot_be_combined_with_membership = true
region_spread_is_not_knowable_here = true
remaining_group_capacity = 2
resource_group_name = "rg-cache-prod"
spans_resource_groups = false
spans_subscriptions = false
subscription_id = "00000000-0000-0000-0000-000000000000"
two_declarations_for_one_instance_collide_silently = true
unlinking_does_not_delete_any_instance = true

πŸ” Troubleshooting

Symptom Cause Fix
Every plan proposes to change the link set, forever Two declarations of this module against one instance. Its id is managed_redis_id, so they claim one ARM object. Delete one. Linking is reciprocal β€” one declaration links the whole group.
linked_managed_redis_ids contains managed_redis_id itself The anchor was listed among the links. Remove it; the provider includes it automatically.
linked_managed_redis_ids may contain at most 4 IDs Five or more links. The group caps at 5 including the anchor.
linked_managed_redis_ids contains the same Managed Redis twice under different casing Two spellings of one ARM path. A set does not deduplicate them. Use one spelling; each copy consumes a slot.
must be a Managed Redis Resource ID ending .../redisEnterprise/<name> An azurerm_redis_cache ID (.../Microsoft.Cache/redis/<name>), or a child path. Pass module.<redis>.id. They are different services.
The link never forms and nothing errors The members' geo_replication_group_name values differ. Nothing can check it. Derive the name from one local and pass it to every member.
Apply fails on a member's SKU Geo-replication needs Balanced_B3 or higher on every member. Scale the undersized member.
Apply fails on a member's modules Only RediSearch and RedisJSON are allowed in a group. Remove the others β€” which destroys that member's data.
A member has persistence enabled and refuses to link Persistence works only on non-geo-replicated databases. Choose one durability mechanism.
Destroying the module did not delete anything It is a link, not a lifecycle owner. Expected. Destroy the instances through their own modules.
Terraform reports no drift after a member was deleted The stored ID is still the ID it was given. Re-plan after recreating it, or taint this resource.

πŸ”— Related Docs


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