Skip to content

Manual Test Runbook — N1: Landing-Zone Connectivity (extends F2)

Owner: Sagar  |  Time: ~60 min  |  Sandbox: snowops-sandbox-01

Promotes N1 (modules/azure/landing-zone-connectivity/) from 🟦 Code Complete → 🟩 Shipped. N1 attaches connectivity edge services to an existing F2 hub vNet. The VPN/ExpressRoute gateway and the DNS resolver are slow (20–45 min) and billed hourly — Part C times are dominated by them. Skip the gateway/resolver legs of Part C if you are only iterating on the Bastion path.


Prerequisites

  • Sandbox subscription access active (PIM activated if required)
  • az login done; az account show confirms the sandbox subscription is selected
  • Identity has Network Contributor on the sandbox subscription
  • An existing F2 hub vNet in the sandbox (run docs/runbooks/test/F2.md Part C first, or point N1 at a long-lived hub). Note its name + RG + region.
  • The hub vNet's address space has room for an AzureBastionSubnet (/26), a GatewaySubnet (/27+), and two resolver subnets (/28 each) that do not overlap existing subnets.
  • Local tooling: terraform >= 1.6, go >= 1.22, az CLI >= 2.50
  • SNOWOPS_SANDBOX_SUBSCRIPTION_ID and SNOWOPS_SANDBOX_TENANT_ID env vars set
  • Working directory: repo root

Steps

Part A — terraform fmt + validate + go vet (offline, ~1 min)

  1. Confirm formatting + structural validity of the module, its example, and the fixture:
terraform fmt -check -recursive modules/azure/landing-zone-connectivity
terraform fmt -check -recursive tests/terratest/fixtures/landing-zone-connectivity
terraform -chdir=modules/azure/landing-zone-connectivity init -backend=false -input=false
terraform -chdir=modules/azure/landing-zone-connectivity validate

Expected: terraform fmt -check exits 0; validate prints Success! The configuration is valid.

Note: in the CI sandbox the Terraform registry is network-blocked, so terraform init / validate cannot download the azurerm provider and will fail at init — identical to every other module fixture in this repo. Run Part A's init/validate from a network-enabled host. The fmt -check and go vet checks below run fully offline.

  1. Confirm the offline Terratest validate suite compiles and vets clean:
cd tests/terratest
go vet ./modules/azure/
go test -v -timeout 5m ./modules/azure/... -run TestLandingZoneConnectivityValidate

Expected: go vet is silent (exit 0); TestLandingZoneConnectivityValidate passes (when run from a host that can reach the provider registry).


Part B — what N1 adds on top of F2 (orientation)

N1 is not a second network hub. F2 (modules/azure/network-hub/) already owns the hub vNet, spokes, peering, Azure Firewall, route tables, Private DNS zones, and NSG flow logs. N1 adds only the connectivity edge services F2 omits, attaching them to the existing hub vNet by name:

  • Azure Bastion (always created). Managed, hardened RDP/SSH onto hub and spoke VMs that carry no public IP. This is the security control N1 exists to add — it removes the need for jump boxes with public IPs or for opening 3389/22 on workload NSGs. Lands in the reserved AzureBastionSubnet (Azure mandates that exact name and a /26+) with a Standard static public IP.

  • VPN / ExpressRoute gateway (optional, enable_vpn_gateway). A virtual network gateway plus the reserved GatewaySubnet and a Standard public IP. This is hybrid connectivity — site-to-site / point-to-site VPN, or termination of an ExpressRoute circuit. Off by default because a gateway takes 20–45 min to create and is billed hourly.

  • Private DNS Resolver (optional, enable_dns_resolver). Inbound + outbound endpoints in their own delegated subnets. Hybrid DNS: on-prem resolves Azure private zones via the inbound endpoint IP; Azure resolves on-prem names via the outbound endpoint + a forwarding ruleset. Off by default — hourly cost, only needed with on-prem integration.

Why gating matters: Bastion is cheap-ish and is the point of the module, so it is unconditional. The gateway and resolver are slow and expensive, so they are count-gated behind boolean flags and their outputs are try(one(...))-guarded to resolve to null when disabled.


Part C — integration test (real Azure apply, ~50 min)

The gateway and resolver dominate the runtime. If iterating on Bastion only, set enable_vpn_gateway = false and enable_dns_resolver = false in the fixture and skip steps 7–9.

  1. Export sandbox env vars (same as F2):
export SNOWOPS_SANDBOX_SUBSCRIPTION_ID="<sandbox-subscription-guid>"
export SNOWOPS_SANDBOX_TENANT_ID="<sandbox-tenant-guid>"
  1. Point the fixture at your existing F2 hub. Either set name_prefix so "${name_prefix}-hub-vnet" matches the live hub vNet, or edit tests/terratest/fixtures/landing-zone-connectivity/main.tf to hard-code hub_vnet_name and the subnet CIDRs to fit the hub's free address space.

  2. Apply the fixture from its directory:

cd tests/terratest/fixtures/landing-zone-connectivity
terraform init
terraform apply \
  -var subscription_id=$SNOWOPS_SANDBOX_SUBSCRIPTION_ID \
  -var tenant_id=$SNOWOPS_SANDBOX_TENANT_ID \
  -var resource_group_name="<existing-f2-hub-rg>" \
  -var name_prefix="<prefix-matching-hub>"

Watch for these milestones: - azurerm_subnet.bastion, azurerm_public_ip.bastion, azurerm_bastion_host.this create in ~3–5 min. - azurerm_virtual_network_gateway.this[0]: Still creating... — expect this for 20–45 min; the gateway is the slow path. - azurerm_private_dns_resolver_inbound_endpoint.this[0] / ..._outbound_endpoint.this[0] create in ~2–3 min each.

  1. Bastion connect — no public IP required. Deploy (or reuse) a small VM in a spoke with no public IP, then connect through Bastion:
# confirm the target VM has NO public IP
az vm list-ip-addresses --resource-group "<existing-f2-hub-rg>" \
  --name "<target-vm>" \
  --query "[].virtualMachine.network.publicIpAddresses" -o json
# expected: [] / null

# connect over Bastion (tunnels SSH/RDP via the Bastion FQDN, no public IP on the VM)
az network bastion ssh \
  --name "snowops-bastion" \
  --resource-group "<existing-f2-hub-rg>" \
  --target-resource-id "$(az vm show -g <existing-f2-hub-rg> -n <target-vm> --query id -o tsv)" \
  --auth-type ssh-key --username snowops --ssh-key ~/.ssh/id_rsa

Expected: an interactive session opens even though the VM has no public IP — proof that Bastion is brokering the connection. Match the host against bastion_dns_name from the Terraform output.

  1. Gateway — confirm the gateway provisioned and its public IP is allocated:
az network vnet-gateway show \
  --resource-group "<existing-f2-hub-rg>" \
  --name "snowops-vnet-gateway" \
  --query "{state:provisioningState, type:gatewayType, sku:sku.name}" -o table

Expected: provisioningState = Succeeded, gatewayType = Vpn. The gateway_public_ip Terraform output is non-null. (Timing: this leg alone is 20–45 min — do not treat a long Still creating... as a hang.)

  1. DNS resolver inbound — confirm the inbound endpoint has a private IP:
az dns-resolver inbound-endpoint show \
  --resource-group "<existing-f2-hub-rg>" \
  --dns-resolver-name "snowops-dns-resolver" \
  --name "snowops-dns-resolver-inbound" \
  --query "ipConfigurations[0].privateIpAddress" -o tsv

Expected: a private IP inside 10.0.254.0/28 (the inbound subnet) that matches the dns_resolver_inbound_endpoint_ip Terraform output. On-prem conditional forwarders point at this IP to resolve Azure private zones.

  1. DNS resolver subnets are dedicated + delegated — confirm Azure carved the two resolver subnets with the Microsoft.Network/dnsResolvers delegation:
az network vnet subnet list \
  --resource-group "<existing-f2-hub-rg>" \
  --vnet-name "<hub-vnet-name>" \
  --query "[?contains(name, 'dnsresolver')].{name:name, prefix:addressPrefix, delegation:delegations[0].serviceName}" \
  -o table

Expected: 2 rows, both delegated to Microsoft.Network/dnsResolvers.

  1. Tear down only the N1 resources (the hub vNet + RG belong to F2 and are NOT destroyed by N1):

    terraform destroy \
      -var subscription_id=$SNOWOPS_SANDBOX_SUBSCRIPTION_ID \
      -var tenant_id=$SNOWOPS_SANDBOX_TENANT_ID \
      -var resource_group_name="<existing-f2-hub-rg>" \
      -var name_prefix="<prefix-matching-hub>"
    

    Expected: Destroy complete!. The gateway takes ~20 min to delete; let it finish before re-running, or the next apply may collide on the GatewaySubnet.


Pass criteria

  • Part A — terraform fmt -check -recursive exits 0 for module + fixture
  • Part A — go vet ./modules/azure/ is silent (exit 0)
  • Part A — terraform validate passes (network-enabled host)
  • Bastion created; able to RDP/SSH a workload that has no public IP
  • bastion_dns_name output non-empty and matches the connect host
  • Gateway provisioningState = Succeeded; gateway_public_ip non-null
  • Resolver inbound endpoint has a private IP matching the output
  • Both resolver subnets delegated to Microsoft.Network/dnsResolvers
  • terraform destroy completes; hub vNet + RG still present afterwards
  • All N1 test resources tagged ephemeral = true (X7 cleanup safety net)

Teardown

terraform destroy (step 10) removes everything N1 created. If a failure mid-run orphans resources, delete them individually — do not delete the RG, since it holds the F2 hub vNet:

az network bastion delete --resource-group "<existing-f2-hub-rg>" --name "snowops-bastion"
az network vnet-gateway delete --resource-group "<existing-f2-hub-rg>" --name "snowops-vnet-gateway"
az dns-resolver delete --resource-group "<existing-f2-hub-rg>" --name "snowops-dns-resolver" --yes
# then remove the N1-created subnets from the hub vNet

The VPN/ExpressRoute gateway destroy takes 20+ minutes — let it finish before re-running the test.


Sign-off

  • Tester: _  |  Date: _  |  Result: PASS / FAIL / N/A
  • Notes: