# Generated from dtm deploy/samples/terraform/dual-region/main.tf at 911067a4. Do not edit here.
# Hayami DTM, dual region.
#
# Deploys a two-node HA Hayami DTM (DNS Traffic Manager) cluster split across
# TWO regions, one appliance VM per region, into subnets you already have. Each
# node gets a dedicated Premium SSD data disk. ONE shared Key Vault, in the
# primary region, holds the cluster secrets.
#
# Node 1 (PRIMARY) generates the initial admin password AND the gossip
# encryption key on the VM and writes them to Key Vault. Node 2 (SECONDARY)
# reads them read-only, retrying until node 1 has published them, so both nodes
# share one gossip key and form one encrypted cluster. Neither secret is ever a
# Terraform variable, an output, or state.
#
# PREREQUISITE: the two subnets must already be able to reach each other on TCP
# and UDP 7946, through VNet peering or through your hub. This configuration
# does not create or change peerings, because your inter-region topology is
# yours to own.
#
# Self-contained: this file and cloud-init.yaml beside it are the whole
# deployment. There are no modules and nothing else to fetch.
#
#   terraform init
#   terraform apply \
#     -var 'resource_group_name=rg-dtm' \
#     -var 'primary_subnet_id=<subnet-1>' \
#     -var 'primary_private_ip=10.60.250.4' \
#     -var 'secondary_subnet_id=<subnet-2>' \
#     -var 'secondary_private_ip=10.61.250.4' \
#     -var 'secondary_location=westeurope' \
#     -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)"
#
# BEFORE THE FIRST APPLY, accept the Marketplace terms once per subscription:
#
#   az vm image terms accept \
#     --publisher hayami --offer dtm --plan dtm-payg-v1
#
# Without it the apply fails at VM create with
# MarketplacePurchaseEligibilityFailed or VMMarketplaceInvalidInput. Terms are
# accepted with the CLI rather than azurerm_marketplace_agreement deliberately:
# that resource DESTROYS the acceptance on terraform destroy, which would
# revoke it for every other DTM deployment in the same subscription.

terraform {
  required_version = ">= 1.5"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------

variable "name_prefix" {
  type        = string
  default     = "dtm"
  description = "Short name prefix for every resource this configuration creates. Per-node resources append -n1 and -n2."
}

variable "resource_group_name" {
  type        = string
  description = "Existing resource group to deploy into. Both node identities are granted Reader and Contributor on it."
}

variable "primary_location" {
  type        = string
  default     = ""
  description = "Primary region. Hosts node 1 and the shared Key Vault. Empty means the resource group's region."
}

variable "secondary_location" {
  type        = string
  description = "Secondary region. Hosts node 2. It must differ from primary_location."
}

variable "primary_subnet_id" {
  type        = string
  description = "Resource ID of the existing subnet node 1 attaches to, in the primary region."
}

variable "primary_private_ip" {
  type        = string
  description = "Static private IP for node 1, free inside the primary subnet CIDR. This becomes a stable resolver address, so do not change it once deployed."
}

variable "secondary_subnet_id" {
  type        = string
  description = "Resource ID of the existing subnet node 2 attaches to, in the secondary region."
}

variable "secondary_private_ip" {
  type        = string
  description = "Static private IP for node 2, free inside the secondary subnet CIDR. This becomes a stable resolver address, so do not change it once deployed."
}

variable "vm_size" {
  type        = string
  default     = "Standard_D2s_v5"
  description = "VM SKU applied to both nodes. Standard_D2s_v5 is the floor for Accelerated Networking."
}

variable "admin_username" {
  type        = string
  default     = "dtmadmin"
  description = "Linux OS admin username on both nodes, for break-glass SSH only. Unrelated to the DTM admin password."
}

variable "ssh_public_key" {
  type        = string
  description = "SSH public key for the Linux OS admin user on both nodes. This is the OS login credential, unrelated to the DTM admin password."
}

variable "dns_client_address_prefix" {
  type        = string
  default     = "VirtualNetwork"
  description = "Source CIDR (or \"VirtualNetwork\") allowed to send DNS 53, DoT 853 and DoH 443 to either node."
}

variable "admin_address_prefix" {
  type        = string
  default     = "VirtualNetwork"
  description = "Source CIDR (or \"VirtualNetwork\") allowed to reach the admin API 8443 and UI 8080 on either node. Enforced by an explicit deny-mgmt-vnet rule, so narrowing it really does cut off other in-VNet and peered sources."
}

variable "gossip_source_address_prefixes" {
  type        = list(string)
  default     = ["VirtualNetwork"]
  description = "Source prefixes allowed to reach gossip 7946. Across regions, \"VirtualNetwork\" does NOT cover the peer: set this to the two node IPs or the two subnet CIDRs, or the cluster cannot form."
}

variable "data_disk_size_gb" {
  type        = number
  default     = 64
  description = "Size (GiB) of the /var/lib/dtm Premium SSD data disk on each node."
}

variable "data_disk_storage_account_type" {
  type        = string
  default     = "Premium_LRS"
  description = "Managed-disk tier for the data disks. Deployed with caching=None, which the database requires for durable writes."
}

variable "image_id" {
  type        = string
  default     = ""
  description = "Resource ID of a Shared Image Gallery or managed image to boot instead of the Marketplace image, for estates that mirror images into their own gallery. When set, no purchase plan is attached, because Azure rejects a plan on an image that has none. Empty means the Marketplace image."
}

variable "marketplace_image_version" {
  type        = string
  default     = "latest"
  description = "Marketplace image version (\"latest\" or a pinned semver). Ignored when image_id is set. Pin it for production: Azure resolves \"latest\" when a VM is CREATED, so two nodes replaced at different times land on different images."
}

variable "region_discovery_enabled" {
  type        = bool
  default     = true
  description = "Azure region discovery on both nodes: renders azure.region_discovery_enabled in dtm-server.yaml and grants each node identity the resource-group Reader role the discovery loop needs. Set false to disable both."
}

variable "enable_terraform_mirror" {
  type        = bool
  default     = false
  description = "Serve the DTM Terraform provider from the cluster itself, so `terraform init` fetches it with no registry and no download. Default false, because those routes are unauthenticated by protocol. This is a boot value only; the console flips it on a running cluster without redeploying."
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied to every resource this configuration creates."
}

# ---------------------------------------------------------------------------
# Locals
# ---------------------------------------------------------------------------

data "azurerm_resource_group" "this" {
  name = var.resource_group_name
}

data "azurerm_client_config" "current" {}

locals {
  # Marketplace identity of the published offer. Deliberately NOT variables:
  # dtm-server is gated fail-closed on the plan Azure attests through IMDS, so
  # a VM booted from any other publisher/offer/plan triple never becomes ready
  # and an override has no working value to take.
  #
  # A wrong value here is invisible at apply time: the VM is created
  # successfully and only then refuses to serve, which reads as a boot failure
  # rather than a configuration error.
  marketplace_publisher = "hayami"
  marketplace_offer     = "dtm"
  marketplace_sku       = "dtm-payg-v1"

  use_marketplace_image = var.image_id == ""

  primary_location = var.primary_location != "" ? var.primary_location : data.azurerm_resource_group.this.location

  # The two nodes. n1 is the cluster PRIMARY: it generates the secrets, so it is
  # the only one granted Key Vault Secrets Officer below. Each node seeds the
  # other, so whichever comes up second finds a member to join.
  nodes = {
    n1 = {
      location   = local.primary_location
      subnet_id  = var.primary_subnet_id
      private_ip = var.primary_private_ip
      role       = "primary"
      seeds      = var.secondary_private_ip
    }
    n2 = {
      location   = var.secondary_location
      subnet_id  = var.secondary_subnet_id
      private_ip = var.secondary_private_ip
      role       = "secondary"
      seeds      = var.primary_private_ip
    }
  }

  # Key Vault names are globally unique and capped at 24 characters, so derive
  # a stable one from the resource group and prefix rather than asking for it.
  key_vault_name = "kv-${substr(sha256("${data.azurerm_resource_group.this.id}/${var.name_prefix}"), 0, 20)}"

  # vault_uri is "https://<name>.<suffix>/". The suffix follows the cloud, so
  # deriving it here is correct in a sovereign cloud without another variable.
  #
  # regex() rather than trimming the name off the front: a trim that does not
  # match returns the string UNCHANGED, so the "suffix" would silently become
  # the whole URL and the nodes would resolve a nonsense Key Vault host at first
  # boot. regex() fails the plan instead if the URI is ever not this shape.
  key_vault_dns_suffix = regex("^https://[^.]+[.](.*)/$", azurerm_key_vault.dtm.vault_uri)[0]
}

# ---------------------------------------------------------------------------
# Key Vault
# ---------------------------------------------------------------------------

# ONE Key Vault for the cluster, in the primary region. Node 1 writes the
# generated secrets here on first boot; node 2 reads them. RBAC data plane, no
# access policies.
#
# purge_protection_enabled is off so a test teardown can recreate the vault
# cleanly. Turn it on for production, where a soft-deleted vault and its
# secrets should not be purgeable before retention elapses. It cannot be
# disabled once enabled.
resource "azurerm_key_vault" "dtm" {
  name                          = local.key_vault_name
  resource_group_name           = data.azurerm_resource_group.this.name
  location                      = local.primary_location
  tenant_id                     = data.azurerm_client_config.current.tenant_id
  sku_name                      = "standard"
  rbac_authorization_enabled    = true
  soft_delete_retention_days    = 7
  purge_protection_enabled      = false
  public_network_access_enabled = true
  tags                          = var.tags
}

# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------

# Identical rules on both nodes. deny-mgmt-vnet (4000) is what makes the admin
# allowlist effective at all: Azure's default in-VNet allow would otherwise
# reach the management ports regardless of admin_address_prefix. DNS and gossip
# stay allow-only, with no matching deny, so resolution and replication can
# never be severed by narrowing an allowlist.
resource "azurerm_network_security_group" "dtm" {
  for_each            = local.nodes
  name                = "${var.name_prefix}-${each.key}-nsg"
  resource_group_name = data.azurerm_resource_group.this.name
  location            = each.value.location
  tags                = var.tags

  security_rule {
    name                       = "allow-dns-udp"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Udp"
    source_port_range          = "*"
    destination_port_range     = "53"
    source_address_prefix      = var.dns_client_address_prefix
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-dns-tcp"
    priority                   = 101
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "53"
    source_address_prefix      = var.dns_client_address_prefix
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-dot-tcp"
    priority                   = 105
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "853"
    source_address_prefix      = var.dns_client_address_prefix
    destination_address_prefix = "*"
  }

  # DoH has its own listener on 443, so it is scoped by the DNS CLIENT prefix
  # like 53 and 853, deliberately NOT by admin_address_prefix. DoH ships
  # disabled, so this rule is allow-only reachability for operators who turn it
  # on.
  security_rule {
    name                       = "allow-doh-tcp"
    priority                   = 106
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = var.dns_client_address_prefix
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-api"
    priority                   = 110
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8443"
    source_address_prefix      = var.admin_address_prefix
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-ui"
    priority                   = 120
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8080"
    source_address_prefix      = var.admin_address_prefix
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-gossip-tcp"
    priority                   = 130
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "7946"
    source_address_prefixes    = var.gossip_source_address_prefixes
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-gossip-udp"
    priority                   = 131
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Udp"
    source_port_range          = "*"
    destination_port_range     = "7946"
    source_address_prefixes    = var.gossip_source_address_prefixes
    destination_address_prefix = "*"
  }

  # Load-bearing rule. Without this explicit Deny, Azure's default
  # AllowVnetInBound (priority 65000) lets ANY in-VNet or peered source reach
  # the management ports even when admin_address_prefix is narrowed.
  #
  # Scoped to 8443 and 8080 ONLY: DNS (53, 853) and gossip (7946) must stay
  # reachable in-VNet or resolution and replication break.
  security_rule {
    name                       = "deny-mgmt-vnet"
    description                = "Makes the admin allowlist effective against in-VNet sources. Mgmt ports only: DNS (53/853) and gossip (7946) stay open."
    priority                   = 4000
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_ranges    = ["8443", "8080"]
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "*"
  }
}

resource "azurerm_network_interface" "dtm" {
  for_each            = local.nodes
  name                = "${var.name_prefix}-${each.key}-nic"
  resource_group_name = data.azurerm_resource_group.this.name
  location            = each.value.location
  tags                = var.tags

  accelerated_networking_enabled = true

  ip_configuration {
    name                          = "internal"
    subnet_id                     = each.value.subnet_id
    private_ip_address_allocation = "Static"
    private_ip_address            = each.value.private_ip
  }
}

# The NSG is attached to the NIC rather than the subnet, so this configuration
# never takes over the network security group on a subnet you already own.
resource "azurerm_network_interface_security_group_association" "dtm" {
  for_each                  = local.nodes
  network_interface_id      = azurerm_network_interface.dtm[each.key].id
  network_security_group_id = azurerm_network_security_group.dtm[each.key].id
}

# ---------------------------------------------------------------------------
# Compute
# ---------------------------------------------------------------------------

# The data disks are separate resources, attached rather than inline, so that
# replacing a VM (an image upgrade) leaves that node's database in place.
resource "azurerm_managed_disk" "dtm_data" {
  for_each             = local.nodes
  name                 = "${var.name_prefix}-${each.key}-data"
  resource_group_name  = data.azurerm_resource_group.this.name
  location             = each.value.location
  storage_account_type = var.data_disk_storage_account_type
  create_option        = "Empty"
  disk_size_gb         = var.data_disk_size_gb
  tags                 = var.tags
}

resource "azurerm_virtual_machine_data_disk_attachment" "dtm_data" {
  for_each           = local.nodes
  managed_disk_id    = azurerm_managed_disk.dtm_data[each.key].id
  virtual_machine_id = azurerm_linux_virtual_machine.dtm[each.key].id
  lun                = 0
  caching            = "None"
}

resource "azurerm_linux_virtual_machine" "dtm" {
  for_each              = local.nodes
  name                  = "${var.name_prefix}-${each.key}-vm"
  resource_group_name   = data.azurerm_resource_group.this.name
  location              = each.value.location
  size                  = var.vm_size
  admin_username        = var.admin_username
  network_interface_ids = [azurerm_network_interface.dtm[each.key].id]
  tags                  = var.tags

  admin_ssh_key {
    username   = var.admin_username
    public_key = var.ssh_public_key
  }

  # System-assigned managed identity. cloud-init uses it to reach Key Vault,
  # and dtm-server uses it to call ARM through IMDS for region discovery.
  identity {
    type = "SystemAssigned"
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "StandardSSD_LRS"
    disk_size_gb         = 30
  }

  # Shared Image Gallery or managed image, for estates that mirror the image
  # into their own gallery. Exactly one of this and source_image_reference.
  source_image_id = local.use_marketplace_image ? null : var.image_id

  # Marketplace: publisher, offer and sku are fixed to the published offer and
  # only the version moves. Both this and source_image_id force replacement, so
  # bumping the version replaces the VM and leaves the NIC, the private IP and
  # the separately-managed data disk in place.
  dynamic "source_image_reference" {
    for_each = local.use_marketplace_image ? [1] : []
    content {
      publisher = local.marketplace_publisher
      offer     = local.marketplace_offer
      sku       = local.marketplace_sku
      version   = var.marketplace_image_version
    }
  }

  # Azure requires the purchase plan on any VM created from a Marketplace image
  # and refuses the create without it. It is also what puts the plan into the
  # IMDS attested document the entitlement gate matches, so a VM built from the
  # Marketplace image WITHOUT this block would be created only to sit there
  # refusing to serve.
  #
  # Omitted on the image_id path: Azure rejects a purchase plan on a gallery or
  # managed image that carries none.
  dynamic "plan" {
    for_each = local.use_marketplace_image ? [1] : []
    content {
      name      = local.marketplace_sku
      publisher = local.marketplace_publisher
      product   = local.marketplace_offer
    }
  }

  custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml", {
    vault_name    = local.key_vault_name
    kv_dns_suffix = local.key_vault_dns_suffix
    private_ip    = each.value.private_ip
    node_id       = "${var.name_prefix}-${each.key}-vm"
    region        = each.value.location
    node_role     = each.value.role
    seeds         = each.value.seeds
    discovery     = var.region_discovery_enabled ? "true" : "false"
    tf_mirror     = var.enable_terraform_mirror ? "true" : "false"
  }))
}

# ---------------------------------------------------------------------------
# Role assignments
# ---------------------------------------------------------------------------

# Reader on the resource group, for region discovery. Skipped when
# region_discovery_enabled is false, because the discovery loop is the only
# consumer, so a discovery-off node gets no ARM read access at all.
resource "azurerm_role_assignment" "discovery_reader" {
  for_each             = var.region_discovery_enabled ? local.nodes : {}
  scope                = data.azurerm_resource_group.this.id
  role_definition_name = "Reader"
  principal_id         = azurerm_linux_virtual_machine.dtm[each.key].identity[0].principal_id
}

# Contributor on the resource group, for the node lifecycle (an in-place
# upgrade keeps the private IP, which means moving the IP configuration between
# NICs).
resource "azurerm_role_assignment" "lifecycle_contributor" {
  for_each             = local.nodes
  scope                = data.azurerm_resource_group.this.id
  role_definition_name = "Contributor"
  principal_id         = azurerm_linux_virtual_machine.dtm[each.key].identity[0].principal_id
}

# EVERY node reads the cluster secrets, so every node gets Secrets User.
resource "azurerm_role_assignment" "kv_secrets_user" {
  for_each             = local.nodes
  scope                = azurerm_key_vault.dtm.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_virtual_machine.dtm[each.key].identity[0].principal_id
}

# Only n1 GENERATES the admin password and the gossip key, so only n1 gets
# Secrets Officer. Granting it to the secondary as well would let a second node
# generate a competing gossip key if it ever won the race, and the cluster would
# never form.
resource "azurerm_role_assignment" "kv_secrets_officer" {
  scope                = azurerm_key_vault.dtm.id
  role_definition_name = "Key Vault Secrets Officer"
  principal_id         = azurerm_linux_virtual_machine.dtm["n1"].identity[0].principal_id
}

# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------

output "private_ips" {
  value       = [var.primary_private_ip, var.secondary_private_ip]
  description = "The two DTM appliance private IPs, primary region first. Point your VNet resolvers and conditional forwarders at both."
}

output "api_endpoint" {
  value       = "https://${var.primary_private_ip}:8443"
  description = "DTM admin API base URL on node 1, reachable in-VNet from the admin CIDR."
}

output "admin_ui_url" {
  value       = "https://${var.primary_private_ip}:8080"
  description = "DTM admin UI URL on node 1, reachable in-VNet from the admin CIDR."
}

output "key_vault_name" {
  value       = azurerm_key_vault.dtm.name
  description = "Name of the shared Key Vault node 1 wrote the initial admin password and gossip key into."
}

output "retrieve_admin_password_command" {
  value       = "az keyvault secret show --vault-name ${azurerm_key_vault.dtm.name} --name dtm-initial-admin-password --query value -o tsv"
  description = "Command to print the VM-generated initial admin password. Needs az login and Key Vault Secrets User. This is the command string, not the password."
}
