# Generated from dtm deploy/samples/terraform/add-node/main.tf at 911067a4. Do not edit here.
# Hayami DTM, add a node.
#
# Adds ONE new Hayami DTM (DNS Traffic Manager) appliance VM to a cluster you
# already have. It REFERENCES existing infrastructure (the cluster subnet, the
# shared Key Vault, the current node IPs) and CREATES only the new node: the VM,
# its Premium SSD data disk, a NIC with a static private IP, an NSG, and the
# node identity.
#
# The new node is always a read-only SECONDARY. Its identity gets Key Vault
# Secrets User, never Officer, because a joining node never writes cluster
# secrets. On first boot it READS the shared gossip key and the initial admin
# password, joins the encrypted cluster over gossip 7946, and reseeds through
# anti-entropy.
#
# PREREQUISITE: the new node must reach the existing nodes on TCP and UDP 7946,
# through the same VNet or through connectivity you already have.
#
# 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 'name_prefix=dtm-n3' \
#     -var 'subnet_id=<subnet-id>' \
#     -var 'existing_key_vault_name=<vault-name>' \
#     -var 'seed_node_ips=["10.50.250.4"]' \
#     -var 'private_ip=10.50.250.5' \
#     -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)"
#
# name_prefix becomes the node id. Use a NEW value for every node you add, and
# keep a separate state file per node, or the second apply will move the first
# node rather than add a second.
#
# 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
  description = "Short name prefix for the new node resources. It BECOMES THE NODE ID, so it must be unique in the cluster and must not be reused: give each node you add its own value (dtm-n3, dtm-n4, and so on). Deliberately has no default, because a default would mean the second node added with this sample silently claimed the first one's id."
}

variable "resource_group_name" {
  type        = string
  description = "Existing resource group the new node deploys into. Its identity is granted Reader and Contributor on it."
}

variable "location" {
  type        = string
  default     = ""
  description = "Region the new node deploys into. It MUST be the region of subnet_id. Empty means the resource group's region."
}

variable "subnet_id" {
  type        = string
  description = "Resource ID of the EXISTING subnet the new node attaches to. It must reach the existing nodes on 7946."
}

variable "private_ip" {
  type        = string
  description = "Static private IP for the new node, free inside the existing subnet CIDR. It must not collide with an existing node."
}

variable "existing_key_vault_name" {
  type        = string
  description = "Name of the EXISTING shared cluster Key Vault, holding dtm-gossip-key and dtm-initial-admin-password."
}

variable "existing_key_vault_resource_group" {
  type        = string
  default     = ""
  description = "Resource group of the existing Key Vault. Empty means the same resource group as the new node."
}

variable "seed_node_ips" {
  type        = list(string)
  description = "Private IPs of the EXISTING cluster nodes. The new node seeds from these, and inbound gossip is allowed only from them."

  validation {
    condition     = length(var.seed_node_ips) > 0
    error_message = "At least one seed node IP is required: a node with no seeds would start its own empty cluster instead of joining yours."
  }
}

variable "vm_size" {
  type        = string
  default     = "Standard_D2s_v5"
  description = "VM SKU for the new node. Match, or exceed, the existing cluster nodes."
}

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

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."
}

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. 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 to the version the existing cluster runs, so the new node joins at the same build."
}

variable "region_discovery_enabled" {
  type        = bool
  default     = true
  description = "Azure region discovery on the new node: renders azure.region_discovery_enabled in dtm-server.yaml and grants the node identity the resource-group Reader role the discovery loop needs. Match the existing cluster."
}

variable "enable_terraform_mirror" {
  type        = bool
  default     = false
  description = "Serve the DTM Terraform provider from this node, 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."
}

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

# ---------------------------------------------------------------------------
# Locals and existing infrastructure
# ---------------------------------------------------------------------------

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

# Referenced, never created: the cluster's existing shared vault. It may live in
# another resource group.
data "azurerm_key_vault" "existing" {
  name = var.existing_key_vault_name
  resource_group_name = (
    var.existing_key_vault_resource_group != ""
    ? var.existing_key_vault_resource_group
    : var.resource_group_name
  )
}

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"

  # vault_uri is "https://<name>.<suffix>/". The suffix follows the cloud, so
  # deriving it from the existing vault is correct in a sovereign cloud without
  # another variable.
  #
  # regex() rather than trimming the name off the front, and it matters most
  # here: existing_key_vault_name is whatever the operator typed, so a case
  # difference from the vault's own URI is entirely likely. A trim that does not
  # match returns the string UNCHANGED, which would make the "suffix" the whole
  # URL and send the new node at a nonsense Key Vault host on first boot, with
  # nothing failing until it never joins. regex() fails the plan instead.
  key_vault_dns_suffix = regex("^https://[^.]+[.](.*)/$", data.azurerm_key_vault.existing.vault_uri)[0]
}

# ---------------------------------------------------------------------------
# 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.
#
# The gossip rules below name the existing cluster nodes as their source. That
# is documentation of intent, NOT a restriction: with no matching deny on 7946,
# Azure's default AllowVnetInBound still admits every in-VNet and peered source,
# by exactly the mechanism deny-mgmt-vnet exists to defeat on 8443 and 8080.
#
# No deny is added here deliberately. One would have to enumerate every current
# and future cluster member, and getting it wrong severs replication rather than
# failing safe. Restrict reachability to 7946 at the subnet or VNet level if you
# need it restricted.
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. 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.seed_node_ips
    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.seed_node_ips
    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" {
  name                = "${var.name_prefix}-nic"
  resource_group_name = data.azurerm_resource_group.this.name
  location            = local.location
  tags                = var.tags

  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 adding a node never
# changes the network security group on the cluster subnet.
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
# ---------------------------------------------------------------------------

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 read the cluster
  # secrets from the existing 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.
  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
    }
  }

  # node_role is secondary, so the node READS the cluster secrets rather than
  # generating them, retrying until its own role assignment has propagated.
  custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml", {
    vault_name    = var.existing_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     = "secondary"
    seeds         = join(",", var.seed_node_ips)
    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.
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
}

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

# Key Vault Secrets User, read-only, on the EXISTING shared vault. Never Secrets
# Officer: a joining node reads the cluster secrets and must never be able to
# overwrite the gossip key the cluster already agreed on.
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = data.azurerm_key_vault.existing.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_linux_virtual_machine.dtm.identity[0].principal_id
}

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

output "vm_name" {
  value       = azurerm_linux_virtual_machine.dtm.name
  description = "The new node VM name. It is also the DTM node id."
}

output "private_ip" {
  value       = var.private_ip
  description = "The new node stable private IP."
}

output "api_endpoint" {
  value       = "https://${var.private_ip}:8443"
  description = "The new node admin API base URL."
}

output "admin_ui_url" {
  value       = "https://${var.private_ip}:8080"
  description = "The new node admin UI URL."
}

output "managed_identity_principal_id" {
  value       = azurerm_linux_virtual_machine.dtm.identity[0].principal_id
  description = "Object ID of the new node system-assigned managed identity."
}
