# Generated from dtm deploy/samples/terraform/single-region/main.tf at 911067a4. Do not edit here.
# Hayami DTM, single region.
#
# Deploys ONE Hayami DTM (DNS Traffic Manager) appliance VM, the cluster
# primary, into an existing subnet, with a dedicated Premium SSD data disk for
# /var/lib/dtm, a per-cluster Key Vault, and the node identity granted the four
# roles it needs (Reader and Contributor on the resource group, Key Vault
# Secrets User and Officer on the vault).
#
# The initial admin password AND the gossip encryption key are generated ON the
# VM at first boot and written to Key Vault. Neither is ever a Terraform
# variable, an output, or state.
#
# 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 'subnet_id=<subnet-resource-id>' \
#     -var 'private_ip=10.50.250.4' \
#     -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 {
  # Nothing here needs a recent language feature; this floor is just a
  # reasonable modern baseline.
  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 (VM, NIC, NSG, disks, Key Vault)."
}

variable "resource_group_name" {
  type        = string
  description = "Existing resource group to deploy into. The node identity is granted Reader and Contributor on it."
}

variable "location" {
  type        = string
  default     = ""
  description = "Azure region. Empty means the resource group's region."
}

variable "subnet_id" {
  type        = string
  description = "Resource ID of the existing subnet the DTM node attaches to. It may live in any VNet, in any resource group, in this subscription."
}

variable "private_ip" {
  type        = string
  description = "Static private IP for the DTM VM, free inside the subnet CIDR. This becomes the stable resolver address, so do not change it once deployed."
}

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

variable "admin_username" {
  type        = string
  default     = "dtmadmin"
  description = "Linux OS admin username, 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. 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."
}

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

variable "gossip_source_address_prefix" {
  type        = string
  default     = "VirtualNetwork"
  description = "Source CIDR (or \"VirtualNetwork\") allowed to reach gossip 7946 (node to node)."
}

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

variable "data_disk_storage_account_type" {
  type        = string
  default     = "Premium_LRS"
  description = "Managed-disk tier for the data disk. 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 every replacement re-resolves it, and two nodes replaced at different times land on different images."
}

variable "region_discovery_enabled" {
  type        = bool
  default     = true
  description = "Azure region discovery on the node: renders azure.region_discovery_enabled in dtm-server.yaml and grants the 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 from https://<node>:8443/terraform/ with no registry and no download. The provider archives ship inside the DTM image at the image version. Default false, because those routes are unauthenticated by protocol (the Terraform CLI reaches them before it has a token). This is a boot value only; the console (Settings, Terraform Provider) 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 == ""

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

  vm_name = "${var.name_prefix}-vm"

  # 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 node 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
# ---------------------------------------------------------------------------

# Per-cluster Key Vault. The primary node writes the generated secrets here on
# first boot; every other node 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.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
# ---------------------------------------------------------------------------

# 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" {
  name                = "${var.name_prefix}-nsg"
  resource_group_name = data.azurerm_resource_group.this.name
  location            = local.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. Scoping it with
  # the admin rule instead would force resolver clients into the management
  # allowlist just to use DoH. 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_prefix      = var.gossip_source_address_prefix
    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_prefix      = var.gossip_source_address_prefix
    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, so
  # narrowing that variable alone does NOT restrict admin access.
  #
  # Scoped to 8443 and 8080 ONLY: DNS (53, 853) and gossip (7946) must stay
  # reachable in-VNet or resolution and replication break.
  #
  # The rationale lives here rather than in `description` because that is a
  # DEPLOYED property and Azure caps a security-rule description at 140
  # characters.
  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" {
  name                = "${var.name_prefix}-nic"
  resource_group_name = data.azurerm_resource_group.this.name
  location            = local.location
  tags                = var.tags

  # Accelerated Networking exposes Azure's fast-path network adapter to the
  # guest. It needs a SKU that supports it: D2s_v5 and above, Ev5, Fv2.
  accelerated_networking_enabled = true

  ip_configuration {
    name                          = "internal"
    subnet_id                     = var.subnet_id
    private_ip_address_allocation = "Static"
    private_ip_address            = var.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" {
  network_interface_id      = azurerm_network_interface.dtm.id
  network_security_group_id = azurerm_network_security_group.dtm.id
}

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

# The data disk is a separate resource, attached rather than inline, so that
# replacing the VM (an image upgrade) leaves the database in place.
resource "azurerm_managed_disk" "dtm_data" {
  name                 = "${var.name_prefix}-data"
  resource_group_name  = data.azurerm_resource_group.this.name
  location             = local.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" {
  managed_disk_id    = azurerm_managed_disk.dtm_data.id
  virtual_machine_id = azurerm_linux_virtual_machine.dtm.id
  lun                = 0
  caching            = "None"
}

resource "azurerm_linux_virtual_machine" "dtm" {
  name                  = local.vm_name
  resource_group_name   = data.azurerm_resource_group.this.name
  location              = local.location
  size                  = var.vm_size
  admin_username        = var.admin_username
  network_interface_ids = [azurerm_network_interface.dtm.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
    }
  }

  # seeds is empty here, which makes this node a single-node primary that
  # reaches Ready immediately.
  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    = var.private_ip
    node_id       = local.vm_name
    region        = local.location
    node_role     = "primary"
    seeds         = ""
    discovery     = var.region_discovery_enabled ? "true" : "false"
    tf_mirror     = var.enable_terraform_mirror ? "true" : "false"
  }))
}

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

# Grant 1 of 4: 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" {
  count                = var.region_discovery_enabled ? 1 : 0
  scope                = data.azurerm_resource_group.this.id
  role_definition_name = "Reader"
  principal_id         = azurerm_linux_virtual_machine.dtm.identity[0].principal_id
}

# Grant 2 of 4: 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" {
  scope                = data.azurerm_resource_group.this.id
  role_definition_name = "Contributor"
  principal_id         = azurerm_linux_virtual_machine.dtm.identity[0].principal_id
}

# Grants 3 and 4: Key Vault data plane. The primary gets Secrets User (read)
# AND Secrets Officer (set), because its cloud-init generates the admin password
# and the gossip key and PUTs them. A joining node gets User only.
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.dtm.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_virtual_machine.dtm.identity[0].principal_id
}

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.identity[0].principal_id
}

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

output "vm_name" {
  value       = azurerm_linux_virtual_machine.dtm.name
  description = "The deployed VM name."
}

output "private_ip" {
  value       = var.private_ip
  description = "The DTM appliance stable private IP. Point your VNet resolver and conditional forwarders here."
}

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

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

output "key_vault_name" {
  value       = azurerm_key_vault.dtm.name
  description = "Name of the Key Vault the VM 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."
}
