Task 03: Configure Static IP Address
DOCUMENT CATEGORY: Runbook SCOPE: Network IP configuration PURPOSE: Assign static management IP addresses to all nodes using explicit IP values from
variables.yml— not read from DHCP MASTER REFERENCE: Phase 03: OS Configuration
Status: Active
Overview
Configure a static IP address on the management network adapter of each Azure Local node. All IP parameters — address, subnet prefix, gateway, and DNS — come explicitly from variables.yml. Scripts do not read the current DHCP-assigned address.
All IP values in variables.yml must be correct before running. The scripts hard-fail if any placeholder values remain, and permanently lock in whatever IP you specify.
The process on each node:
- Hard-fails at startup if any
REPLACEplaceholder values remain in the configuration block - Finds the management NIC by exact adapter name — exits and lists all adapters if not found
- Checks idempotency — if already correctly configured as static, exits clean
- Handles the case where the target IP is currently DHCP-assigned (locks it in as static)
- Disables DHCP, removes dynamic IP, sets static IP/prefix/gateway/DNS
- Validates:
PrefixOrigin = Manual,DHCP = Disabled, gateway route present, DNS set - Retries up to
RetryCounttimes if validation fails
Prerequisites
| Requirement | Description | Source |
|---|---|---|
| Tasks 01–02 Complete | WinRM and RDP enabled on all nodes | Task 01, Task 02 |
| Management IP per node | Static IP to assign | variables.yml: nodes.<name>.management_ip |
| Subnet prefix length | e.g. 24 for /24 | variables.yml: network.management.prefix_length |
| Default gateway | Management network gateway | variables.yml: network.management.gateway |
| DNS servers | Primary and secondary DNS | variables.yml: dns.primary, dns.secondary |
| Management NIC name | Exact adapter name on all nodes | variables.yml: cluster.management_nic_name (from Phase 01 hardware discovery) |
Variables from variables.yml
| Path | Type | Description |
|---|---|---|
nodes.<name>.management_ip | string | Static IP to assign to management NIC |
network.management.prefix_length | integer | Subnet prefix (e.g., 24) |
network.management.gateway | string | Default gateway for management network |
dns.primary | string | Primary DNS server IP |
dns.secondary | string | Secondary DNS server IP |
cluster.management_nic_name | string | Management adapter name (e.g., Embedded NIC 1) |
Execution
- SConfig Utility
- Direct Script (On Node)
- Orchestrated Script (Mgmt Server)
Run on each node via iDRAC Virtual Console or RDP.
The adapter number shown in SConfig varies. Before selecting, run Get-NetAdapter in PowerShell to list all adapters and confirm the correct name. The management adapter name is recorded in variables.yml: cluster.management_nic_name from the iDRAC discovery performed in Phase 01.
Common adapter names by NIC vendor:
| Vendor | Typical Adapter Name | Notes |
|---|---|---|
| Nvidia / Mellanox ConnectX | Slot 3 Port 1 (or Slot X Port 1) | Slot number set during Phase 01 iDRAC discovery |
| Intel (embedded) | Embedded NIC 1 | Standard on Dell PowerEdge embedded Intel NICs |
| Broadcom (embedded) | Embedded NIC 1 | Standard on Dell PowerEdge embedded Broadcom NICs |
- Open iDRAC Virtual Console or connect via RDP
- Run
Get-NetAdapterin PowerShell — note the adapter name matchingvariables.yml: cluster.management_nic_name - Type
SConfigand press Enter (or it auto-launches) - Select option 8 — Network settings
- SConfig lists adapters by number — select the number corresponding to your management adapter identified in step 2
- Select option 1 — Set Network Adapter Address
- Enter S for Static
- Enter the static IP address from
variables.yml: nodes.<name>.management_ip - Enter the subnet mask (e.g.,
255.255.255.0for /24,255.255.255.128for /25) - Enter the default gateway from
variables.yml: network.management.gateway - Select option 2 — Set DNS Servers; enter primary from
variables.yml: dns.primary - Enter secondary DNS from
variables.yml: dns.secondary - Repeat for every node
When to use: Directly on a node via iDRAC virtual console, RDP, or local keyboard — no management server required
Script: scripts/deploy/04-cluster-deployment/phase-03-os-configuration/task-03-configure-static-ip-address/powershell/Set-StaticIPAddress.ps1
Configuration Values
Before running, edit the #region CONFIGURATION block at the top of the script with values from variables.yml:
| Script Variable | variables.yml Path | Example |
|---|---|---|
ManagementNIC | cluster.management_nic_name | Slot 3 Port 1 or Embedded NIC 1 |
IPAddress | nodes.<name>.management_ip | 10.10.1.11 |
PrefixLength | network.management.prefix_length | 24 |
Gateway | network.management.gateway | 10.10.1.1 |
DNSPrimary | dns.primary | 10.10.0.10 |
DNSSecondary | dns.secondary | 10.10.0.11 |
Script
#Requires -Version 5.1
<#
.SYNOPSIS
Set-StaticIPAddress.ps1
Configures a static IP address on the management NIC of an Azure Local node.
.DESCRIPTION
Run directly on each Azure Local node (locally or via PSRemoting).
All configuration values are defined in the #region CONFIGURATION block below.
The script does NOT read DHCP-assigned values — all IP settings must be
explicitly set before running.
Behaviour:
- Hard-fails at startup if any REPLACE placeholder values remain
- Looks up the NIC by exact name; lists all adapters and exits if not found
- If the adapter already has the correct static IP, exits cleanly (idempotent)
- If the adapter has the correct IP but is still DHCP-assigned, locks it in as static
- Validates all settings after applying; retries up to $RetryCount times
- Validates: IP, prefix length, PrefixOrigin = Manual, DHCP disabled, gateway route, DNS servers
.NOTES
Author: Azure Local Cloud Azure Local Cloud
Version: 1.0.0
Phase: 03-os-configuration
Task: task-03-configure-static-ip-address
Execution: Run directly on the node (console, KVM, or PSRemoting)
Prerequisites: PowerShell 5.1+, local admin rights
Source values: variables.yml per-node management_ip, network.management.gateway,
dns.primary, dns.secondary, cluster.management_nic_name
.EXAMPLE
# Copy to node, edit #region CONFIGURATION, then run:
.\Set-StaticIPAddress.ps1
#>
# ============================================================================
#region CONFIGURATION
# Fill in ALL values before running. Script will hard-fail on any REPLACE value.
# ============================================================================
$ManagementNIC = "REPLACE_WITH_NIC_NAME" # Exact adapter name from Get-NetAdapter
# Dell Nvidia/Mellanox: "Slot 3 Port 1"
# Dell Embedded Intel/Broadcom: "Embedded NIC 1"
# Source: variables.yml -> cluster.management_nic_name
$IPAddress = "REPLACE_WITH_IP" # e.g. "10.10.1.11"
# Source: variables.yml -> nodes.<name>.management_ip
$PrefixLength = 0 # Subnet prefix (e.g. 24 for /24, 25 for /25)
# Source: variables.yml -> network.management.prefix_length
$Gateway = "REPLACE_WITH_GATEWAY" # e.g. "10.10.1.1"
# Source: variables.yml -> network.management.gateway
$DNSPrimary = "REPLACE_WITH_DNS1" # e.g. "10.10.0.10"
# Source: variables.yml -> dns.primary
$DNSSecondary = "REPLACE_WITH_DNS2" # e.g. "10.10.0.11"
# Source: variables.yml -> dns.secondary
$RetryCount = 3 # Number of attempts if validation fails
$RetryDelaySec = 10 # Seconds between retries
#endregion CONFIGURATION
# ============================================================================
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ============================================================================
# FUNCTIONS
# ============================================================================
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$color = switch ($Level) {
"ERROR" { "Red" }
"WARN" { "Yellow" }
"SUCCESS" { "Green" }
"HEADER" { "Cyan" }
default { "White" }
}
Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color
}
function Assert-ConfigValues {
$errors = [System.Collections.Generic.List[string]]::new()
if ($ManagementNIC -eq "REPLACE_WITH_NIC_NAME" -or [string]::IsNullOrWhiteSpace($ManagementNIC)) {
$errors.Add("ManagementNIC is not set")
}
if ($IPAddress -eq "REPLACE_WITH_IP" -or [string]::IsNullOrWhiteSpace($IPAddress)) {
$errors.Add("IPAddress is not set")
}
if ($PrefixLength -eq 0) {
$errors.Add("PrefixLength is 0 — set the correct subnet prefix (e.g. 24)")
}
if ($Gateway -eq "REPLACE_WITH_GATEWAY" -or [string]::IsNullOrWhiteSpace($Gateway)) {
$errors.Add("Gateway is not set")
}
if ($DNSPrimary -eq "REPLACE_WITH_DNS1" -or [string]::IsNullOrWhiteSpace($DNSPrimary)) {
$errors.Add("DNSPrimary is not set")
}
if ($DNSSecondary -eq "REPLACE_WITH_DNS2" -or [string]::IsNullOrWhiteSpace($DNSSecondary)) {
$errors.Add("DNSSecondary is not set")
}
if ($errors.Count -gt 0) {
Write-Log "=== CONFIGURATION INCOMPLETE ===" "ERROR"
Write-Log "Edit the #region CONFIGURATION block before running:" "ERROR"
foreach ($e in $errors) {
Write-Log " - $e" "ERROR"
}
exit 1
}
}
function Get-ManagementAdapter {
# Exact name match only — no guessing
$adapter = Get-NetAdapter | Where-Object { $_.InterfaceAlias -eq $ManagementNIC }
if (-not $adapter) {
Write-Log "Adapter '$ManagementNIC' not found on this node." "ERROR"
Write-Log "" "ERROR"
Write-Log "Available adapters:" "WARN"
Get-NetAdapter | Select-Object Name, InterfaceAlias, Status, MacAddress |
Format-Table -AutoSize | Out-String | Write-Host
Write-Log "Update ManagementNIC in #region CONFIGURATION and re-run." "ERROR"
exit 1
}
if ($adapter.Status -ne "Up") {
Write-Log "Adapter '$ManagementNIC' exists but status is '$($adapter.Status)'. Verify cabling." "WARN"
}
return $adapter
}
function Get-CurrentIPState {
param([Microsoft.Management.Infrastructure.CimInstance]$Adapter)
$ipConfig = Get-NetIPConfiguration -InterfaceIndex $Adapter.ifIndex
$ipAddress = Get-NetIPAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
$interface = Get-NetIPInterface -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4
$gateway = Get-NetRoute -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" }
$dns = (Get-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4).ServerAddresses
return @{
IPAddress = $ipAddress.IPAddress
PrefixLength = $ipAddress.PrefixLength
PrefixOrigin = $ipAddress.PrefixOrigin
DHCP = $interface.Dhcp
Gateway = if ($gateway) { $gateway.NextHop } else { $null }
DNS = $dns
}
}
function Test-AlreadyConfigured {
param([hashtable]$State)
# Check if the adapter already has the correct static configuration
$allMatch = (
$State.IPAddress -eq $IPAddress -and
$State.PrefixLength -eq $PrefixLength -and
$State.PrefixOrigin -eq "Manual" -and
$State.DHCP -eq "Disabled" -and
$State.Gateway -eq $Gateway -and
($State.DNS -contains $DNSPrimary) -and
($State.DNS -contains $DNSSecondary)
)
return $allMatch
}
function Set-StaticIPConfiguration {
param([Microsoft.Management.Infrastructure.CimInstance]$Adapter)
Write-Log "Disabling DHCP..."
Set-NetIPInterface -InterfaceIndex $Adapter.ifIndex -Dhcp Disabled
Write-Log "Removing existing IPv4 addresses..."
Get-NetIPAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue
Write-Log "Removing existing default gateway routes..."
Get-NetRoute -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" } |
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
Write-Log "Setting static IP: $IPAddress/$PrefixLength GW: $Gateway"
New-NetIPAddress `
-InterfaceIndex $Adapter.ifIndex `
-IPAddress $IPAddress `
-PrefixLength $PrefixLength `
-DefaultGateway $Gateway `
-ErrorAction Stop | Out-Null
Write-Log "Setting DNS: $DNSPrimary, $DNSSecondary"
Set-DnsClientServerAddress `
-InterfaceIndex $Adapter.ifIndex `
-ServerAddresses @($DNSPrimary, $DNSSecondary)
}
function Wait-ForIPStabilization {
param([Microsoft.Management.Infrastructure.CimInstance]$Adapter, [int]$TimeoutSec = 30)
Write-Log "Waiting for IP configuration to stabilize (up to ${TimeoutSec}s)..."
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
$ip = Get-NetIPAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
if ($ip -and $ip.IPAddress -eq $IPAddress) {
Start-Sleep -Seconds 2 # brief settle
return $true
}
Start-Sleep -Seconds 2
}
return $false
}
function Test-Configuration {
param([Microsoft.Management.Infrastructure.CimInstance]$Adapter)
$issues = [System.Collections.Generic.List[string]]::new()
$ip = Get-NetIPAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
$iface = Get-NetIPInterface -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4
$gw = Get-NetRoute -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" }
$dnsAddrs = (Get-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -AddressFamily IPv4).ServerAddresses
if (-not $ip) { $issues.Add("No IPv4 address on adapter") }
elseif ($ip.IPAddress -ne $IPAddress) { $issues.Add("IP mismatch: expected $IPAddress, got $($ip.IPAddress)") }
if ($ip -and $ip.PrefixLength -ne $PrefixLength) { $issues.Add("Prefix mismatch: expected $PrefixLength, got $($ip.PrefixLength)") }
if ($ip -and $ip.PrefixOrigin -ne "Manual") { $issues.Add("PrefixOrigin is '$($ip.PrefixOrigin)' — should be 'Manual'") }
if ($iface.Dhcp -ne "Disabled") { $issues.Add("DHCP is '$($iface.Dhcp)' — should be 'Disabled'") }
if (-not $gw) { $issues.Add("No default gateway route") }
elseif ($gw.NextHop -ne $Gateway) { $issues.Add("Gateway mismatch: expected $Gateway, got $($gw.NextHop)") }
if ($dnsAddrs -notcontains $DNSPrimary) { $issues.Add("DNS primary $DNSPrimary not found in: $($dnsAddrs -join ', ')") }
if ($dnsAddrs -notcontains $DNSSecondary) { $issues.Add("DNS secondary $DNSSecondary not found in: $($dnsAddrs -join ', ')") }
return $issues
}
# ============================================================================
# MAIN
# ============================================================================
try {
Write-Log "=== Set-StaticIPAddress.ps1 ===" "HEADER"
Write-Log "Node: $($env:COMPUTERNAME)"
# 1. Validate all config values are filled in
Assert-ConfigValues
Write-Log "Configuration:"
Write-Log " NIC: $ManagementNIC"
Write-Log " IP: $IPAddress/$PrefixLength"
Write-Log " Gateway: $Gateway"
Write-Log " DNS: $DNSPrimary, $DNSSecondary"
# 2. Get adapter (hard-fail if not found)
$adapter = Get-ManagementAdapter
Write-Log "Adapter found: $($adapter.InterfaceAlias) MAC: $($adapter.MacAddress) Status: $($adapter.Status)"
# 3. Check current state
$currentState = Get-CurrentIPState -Adapter $adapter
Write-Log "Current state:"
Write-Log " IP: $($currentState.IPAddress)/$($currentState.PrefixLength)"
Write-Log " Origin: $($currentState.PrefixOrigin)"
Write-Log " DHCP: $($currentState.DHCP)"
Write-Log " Gateway: $($currentState.Gateway)"
Write-Log " DNS: $($currentState.DNS -join ', ')"
# 4. Idempotency check — already fully configured?
if (Test-AlreadyConfigured -State $currentState) {
Write-Log "Adapter is already correctly configured as static with target IP. No changes needed." "SUCCESS"
exit 0
}
# 5. DHCP == target IP scenario warning
if ($currentState.IPAddress -eq $IPAddress -and $currentState.PrefixOrigin -eq "Dhcp") {
Write-Log "NOTE: Adapter currently has the target IP ($IPAddress) via DHCP." "WARN"
Write-Log " Proceeding to lock it in as static." "WARN"
}
# 6. Apply configuration with retry loop
$success = $false
for ($attempt = 1; $attempt -le $RetryCount; $attempt++) {
Write-Log "--- Attempt $attempt of $RetryCount ---" "HEADER"
try {
Set-StaticIPConfiguration -Adapter $adapter
# Wait for IP to appear on the adapter
if (-not (Wait-ForIPStabilization -Adapter $adapter)) {
throw "Target IP $IPAddress did not appear on adapter within timeout"
}
# Validate
$issues = Test-Configuration -Adapter $adapter
if ($issues.Count -eq 0) {
Write-Log "All validation checks passed." "SUCCESS"
$success = $true
break
} else {
Write-Log "Validation failed ($($issues.Count) issue(s)):" "WARN"
foreach ($issue in $issues) { Write-Log " - $issue" "WARN" }
if ($attempt -lt $RetryCount) {
Write-Log "Waiting $RetryDelaySec seconds before retry..." "WARN"
Start-Sleep -Seconds $RetryDelaySec
}
}
} catch {
Write-Log "Attempt $attempt threw: $_" "ERROR"
if ($attempt -lt $RetryCount) {
Write-Log "Waiting $RetryDelaySec seconds before retry..." "WARN"
Start-Sleep -Seconds $RetryDelaySec
}
}
}
if (-not $success) {
Write-Log "FAILED after $RetryCount attempts. Manual intervention required." "ERROR"
Write-Log "Run: Get-NetIPConfiguration -InterfaceAlias '$ManagementNIC'" "WARN"
exit 1
}
# 7. Final summary
Write-Log "=== FINAL STATE ===" "HEADER"
$finalIP = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4
$finalIface = Get-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4
$finalGW = Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" }
$finalDNS = (Get-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4).ServerAddresses
Write-Log " IP Address: $($finalIP.IPAddress)/$($finalIP.PrefixLength)"
Write-Log " IP Origin: $($finalIP.PrefixOrigin)"
Write-Log " DHCP: $($finalIface.Dhcp)"
Write-Log " Gateway: $($finalGW.NextHop)"
Write-Log " DNS: $($finalDNS -join ', ')"
Write-Log "STATIC IP CONFIGURATION COMPLETE" "SUCCESS"
exit 0
} catch {
Write-Log "CRITICAL ERROR: $_" "ERROR"
exit 1
}
When to use: Standard deployment — reads all configuration from
variables.ymland configures all nodes via PSRemoting
Script: scripts/deploy/04-cluster-deployment/phase-03-os-configuration/task-03-configure-static-ip-address/powershell/Invoke-ConfigureStaticIP-Orchestrated.ps1
In most deployments, DHCP reservations mean the node's current IP equals the target static IP — the PSRemoting session stays alive through the configuration. If the IPs differ (e.g. no DHCP reservation was in place), the session will drop when the IP changes. The script catches this disconnect, waits up to 60 seconds, then reconnects to the new static IP and runs a full verification pass.
Script
#Requires -Version 7.0
<#
.SYNOPSIS
Invoke-ConfigureStaticIP-Orchestrated.ps1
Configures static IP addresses on all Azure Local nodes from the management server.
.DESCRIPTION
Runs from the management server. Reads all IP configuration from variables.yml
(management IP, prefix, gateway, DNS, NIC name) and pushes the configuration to
each node via PSRemoting.
Session-loss handling:
When the static IP is applied, the node
session WILL drop — this is expected. The orchestrator catches that disconnect,
waits for the node to stabilise on its new IP, then opens a fresh session to the
new static IP and runs a verification query to confirm all settings took.
Variables.yml paths used per node:
nodes.<name>.management_ip -> static IP to assign
network.management.prefix_length -> subnet prefix (e.g. 24)
network.management.gateway -> default gateway
dns.primary -> primary DNS
dns.secondary -> secondary DNS
cluster.management_nic_name -> exact NIC adapter name
.PARAMETER ConfigPath
Path to variables.yml. Auto-detected from .\configs\ if not specified.
.PARAMETER NodeNames
Specific node names to target. Default: all nodes in variables.yml.
.PARAMETER Credential
PSCredential for PSRemoting. Prompted if not provided.
.PARAMETER RetryCount
Validation retry attempts on the node. Default: 3.
.PARAMETER ReconnectTimeoutSec
Seconds to wait for node to come up on new static IP. Default: 60.
.PARAMETER ReconnectRetrySec
Seconds between reconnect attempts. Default: 10.
.EXAMPLE
.\Invoke-ConfigureStaticIP-Orchestrated.ps1
.EXAMPLE
.\Invoke-ConfigureStaticIP-Orchestrated.ps1 -NodeNames "AZL-NODE01","AZL-NODE02"
.EXAMPLE
.\Invoke-ConfigureStaticIP-Orchestrated.ps1 -ConfigPath ".\configs\infrastructure-poc.yml"
.NOTES
Author: Azure Local Cloud Azure Local Cloud
Version: 2.0.0
Phase: 03-os-configuration
Task: task-03-configure-static-ip-address
Prerequisites: PowerShell 7+, powershell-yaml module, PSRemoting enabled on nodes (Task 01)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$ConfigPath,
[Parameter(Mandatory = $false)]
[string[]]$NodeNames,
[Parameter(Mandatory = $false)]
[PSCredential]$Credential,
[Parameter(Mandatory = $false)]
[int]$RetryCount = 3,
[Parameter(Mandatory = $false)]
[int]$ReconnectTimeoutSec = 60,
[Parameter(Mandatory = $false)]
[int]$ReconnectRetrySec = 10
)
Set-StrictMode -Version Latest
$ErrorActionPreference =
# ============================================================================
# FUNCTIONS
# ============================================================================
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$color = switch ($Level) {
"ERROR" { "Red" }
"WARN" { "Yellow" }
"SUCCESS" { "Green" }
"HEADER" { "Cyan" }
default { "White" }
}
Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color
}
function Resolve-ConfigPath {
param([string]$ExplicitPath)
if ($ExplicitPath) {
if (-not (Test-Path $ExplicitPath)) { throw "Config not found: $ExplicitPath" }
Write-Log "Using specified config: $ExplicitPath"
return $ExplicitPath
}
$candidates = Get-ChildItem -Path ".\configs\" -Filter "infrastructure*.yml" -ErrorAction SilentlyContinue
if (-not $candidates -or $candidates.Count -eq 0) {
throw "No infrastructure*.yml found in .\configs\. Use -ConfigPath."
}
if ($candidates.Count -eq 1) {
Write-Log "Auto-detected config: $($candidates[0].FullName)"
return $candidates[0].FullName
}
Write-Host "`nMultiple config files found:" -ForegroundColor Yellow
for ($i = 0; $i -lt $candidates.Count; $i++) {
$default = if ($candidates[$i].Name -eq "variables.yml") { " [DEFAULT]" } else { "" }
Write-Host " [$($i+1)] $($candidates[$i].Name)$default"
}
$defaultIdx = ($candidates | ForEach-Object { $_.Name }).IndexOf("variables.yml")
if ($defaultIdx -lt 0) { $defaultIdx = 0 }
$sel = Read-Host "`nSelect config (Enter for default [$($candidates[$defaultIdx].Name)])"
if ([string]::IsNullOrWhiteSpace($sel)) { return $candidates[$defaultIdx].FullName }
$idx = [int]$sel - 1
if ($idx -lt 0 -or $idx -ge $candidates.Count) { throw "Invalid selection: $sel" }
return $candidates[$idx].FullName
}
function Get-NodeConfigs {
param([hashtable]$Config)
$mgmtNet = $Config.network.management
$dns = $Config.dns
$clusterNIC = $Config.cluster.management_nic_name
if (-not $mgmtNet.gateway) { throw "network.management.gateway not set in variables.yml" }
if (-not $mgmtNet.prefix_length) { throw "network.management.prefix_length not set in variables.yml" }
if (-not $dns.primary) { throw "dns.primary not set in variables.yml" }
if (-not $dns.secondary) { throw "dns.secondary not set in variables.yml" }
if (-not $clusterNIC) { throw "cluster.management_nic_name not set in variables.yml" }
$nodeConfigs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($entry in $Config.nodes.GetEnumerator()) {
$nodeName = $entry.Key
$nodeData = $entry.Value
if (-not $nodeData.management_ip) {
Write-Log "Node $nodeName has no management_ip in variables.yml — skipping" "WARN"
continue
}
$nodeConfigs.Add(@{
NodeName = $nodeName
CurrentIP = $nodeData.management_ip # DHCP-reserved = current IP = target IP
TargetIP = $nodeData.management_ip
PrefixLength = [int]$mgmtNet.prefix_length
Gateway = $mgmtNet.gateway
DNSPrimary = $dns.primary
DNSSecondary = $dns.secondary
ManagementNIC = $clusterNIC
})
}
return $nodeConfigs
}
# The script block that runs INSIDE the PSRemoting session on the node.
# Receives all config values explicitly — never reads DHCP or auto-detects IP.
$NodeConfigScriptBlock = {
param(
[string]$ManagementNIC,
[string]$TargetIP,
[int]$PrefixLength,
[string]$Gateway,
[string]$DNSPrimary,
[string]$DNSSecondary,
[int]$RetryCount,
[int]$RetryDelaySec
)
function Write-NLog {
param([string]$M, [string]$L = "INFO")
$ts = Get-Date -Format "HH:mm:ss"
Write-Output " [$ts][$L] $M"
}
try {
Write-NLog "Node: $($env:COMPUTERNAME)"
# Find adapter — exact name only
$adapter = Get-NetAdapter | Where-Object { $_.InterfaceAlias -eq $ManagementNIC }
if (-not $adapter) {
$available = (Get-NetAdapter | Select-Object -ExpandProperty InterfaceAlias) -join ", "
throw "Adapter
}
Write-NLog "Adapter: $($adapter.InterfaceAlias) MAC: $($adapter.MacAddress)"
# Check if already fully configured (idempotent)
$existingIP = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
$existingIface = Get-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4
$existingGW = Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" }
$existingDNS = (Get-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4).ServerAddresses
if ($existingIP.IPAddress -eq $TargetIP -and
$existingIP.PrefixLength -eq $PrefixLength -and
$existingIP.PrefixOrigin -eq "Manual" -and
$existingIface.Dhcp -eq "Disabled" -and
$existingGW.NextHop -eq $Gateway -and
($existingDNS -contains $DNSPrimary) -and
($existingDNS -contains $DNSSecondary)) {
Write-NLog "Already correctly configured as static. No changes needed." "INFO"
return @{ Result = "AlreadyConfigured"; IP = $TargetIP }
}
if ($existingIP.IPAddress -eq $TargetIP -and $existingIP.PrefixOrigin -eq "Dhcp") {
Write-NLog "Target IP ($TargetIP) currently assigned via DHCP — locking in as static." "WARN"
}
# Apply static configuration
Write-NLog "Disabling DHCP..."
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -Dhcp Disabled
Write-NLog "Removing existing IP addresses..."
Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue
Write-NLog "Removing existing default routes..."
Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" } |
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
Write-NLog "Setting static IP: $TargetIP/$PrefixLength GW: $Gateway"
New-NetIPAddress -InterfaceIndex $adapter.ifIndex `
-IPAddress $TargetIP -PrefixLength $PrefixLength `
-DefaultGateway $Gateway -ErrorAction Stop | Out-Null
Write-NLog "Setting DNS: $DNSPrimary, $DNSSecondary"
Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex `
-ServerAddresses @($DNSPrimary, $DNSSecondary)
# Commands submitted — session will likely drop here as IP changes.
# Return a sentinel so the orchestrator knows commands were sent.
return @{ Result = "CommandsSubmitted"; IP = $TargetIP }
} catch {
return @{ Result = "Error"; Error = $_.Exception.Message }
}
}
# Verification script block — runs on the node AFTER reconnecting to the new static IP
$VerifyScriptBlock = {
param([string]$ManagementNIC, [string]$TargetIP, [int]$PrefixLength,
[string]$Gateway, [string]$DNSPrimary, [string]$DNSSecondary)
$issues = [System.Collections.Generic.List[string]]::new()
$adapter = Get-NetAdapter | Where-Object { $_.InterfaceAlias -eq $ManagementNIC }
if (-not $adapter) { return @{ Valid = $false; Issues = @("Adapter not found") } }
$ip = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
$iface = Get-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4
$gw = Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" }
$dns = (Get-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4).ServerAddresses
if (-not $ip -or $ip.IPAddress -ne $TargetIP) { $issues.Add("IP: expected $TargetIP got $($ip.IPAddress)") }
if ($ip -and $ip.PrefixLength -ne $PrefixLength) { $issues.Add("Prefix: expected $PrefixLength got $($ip.PrefixLength)") }
if ($ip -and $ip.PrefixOrigin -ne "Manual") { $issues.Add("PrefixOrigin: $($ip.PrefixOrigin) (expected Manual)") }
if ($iface.Dhcp -ne "Disabled") { $issues.Add("DHCP: $($iface.Dhcp) (expected Disabled)") }
if (-not $gw -or $gw.NextHop -ne $Gateway) { $issues.Add("Gateway: expected $Gateway got $($gw.NextHop)") }
if ($dns -notcontains $DNSPrimary) { $issues.Add("DNS primary $DNSPrimary missing") }
if ($dns -notcontains $DNSSecondary) { $issues.Add("DNS secondary $DNSSecondary missing") }
return @{ Valid = ($issues.Count -eq 0); Issues = $issues; IP = $ip.IPAddress }
}
function Invoke-NodeConfiguration {
param([hashtable]$NodeConfig, [PSCredential]$Cred)
$nodeName = $NodeConfig.NodeName
$currentIP = $NodeConfig.CurrentIP
$targetIP = $NodeConfig.TargetIP
$nic = $NodeConfig.ManagementNIC
Write-Log "--- $nodeName ---" "HEADER"
Write-Log " Connecting to: $currentIP"
Write-Log " Target static IP: $targetIP/$($NodeConfig.PrefixLength) GW: $($NodeConfig.Gateway)"
# ---- Phase 1: Send configuration commands ----
$phase1Result = $null
try {
Write-Log " Opening PSRemoting session to $currentIP..."
$phase1Result = Invoke-Command `
-ComputerName $currentIP `
-Credential $Cred `
-ScriptBlock $NodeConfigScriptBlock `
-ArgumentList $nic, $targetIP, $NodeConfig.PrefixLength, $NodeConfig.Gateway,
$NodeConfig.DNSPrimary, $NodeConfig.DNSSecondary, $RetryCount, 5 `
-ErrorAction Stop
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
# Session loss after IP change — this is the expected path
Write-Log " Session dropped (expected — IP change applied on node)." "WARN"
$phase1Result = @{ Result = "CommandsSubmitted"; IP = $targetIP }
} catch {
Write-Log " Phase 1 failed: $_" "ERROR"
return @{ NodeName = $nodeName; Success = $false; Error = $_.Exception.Message }
}
if ($phase1Result.Result -eq "Error") {
Write-Log " Node reported error: $($phase1Result.Error)" "ERROR"
return @{ NodeName = $nodeName; Success = $false; Error = $phase1Result.Error }
}
if ($phase1Result.Result -eq "AlreadyConfigured") {
Write-Log " Already correctly configured." "SUCCESS"
return @{ NodeName = $nodeName; Success = $true; IP = $targetIP; AlreadyDone = $true }
}
# ---- Phase 2: Reconnect to new static IP and verify ----
Write-Log " Waiting for node to come up on new IP $targetIP (timeout: ${ReconnectTimeoutSec}s)..." "WARN"
$deadline = (Get-Date).AddSeconds($ReconnectTimeoutSec)
$verifyResult = $null
while ((Get-Date) -lt $deadline) {
try {
Start-Sleep -Seconds $ReconnectRetrySec
Write-Log " Attempting reconnect to $targetIP..."
$verifyResult = Invoke-Command `
-ComputerName $targetIP `
-Credential $Cred `
-ScriptBlock $VerifyScriptBlock `
-ArgumentList $nic, $targetIP, $NodeConfig.PrefixLength,
$NodeConfig.Gateway, $NodeConfig.DNSPrimary, $NodeConfig.DNSSecondary `
-ErrorAction Stop
break # connected
} catch {
Write-Log " Reconnect attempt failed: $_ — retrying in ${ReconnectRetrySec}s..." "WARN"
}
}
if (-not $verifyResult) {
Write-Log " Could not reconnect to $targetIP within ${ReconnectTimeoutSec}s." "ERROR"
return @{ NodeName = $nodeName; Success = $false; Error = "Reconnect to $targetIP timed out" }
}
# ---- Phase 3: Evaluate verification result ----
if ($verifyResult.Valid) {
Write-Log " All configuration checks passed on $targetIP." "SUCCESS"
return @{ NodeName = $nodeName; Success = $true; IP = $targetIP }
} else {
Write-Log " Verification failed:" "ERROR"
foreach ($issue in $verifyResult.Issues) { Write-Log " - $issue" "ERROR" }
return @{ NodeName = $nodeName; Success = $false; Error = ($verifyResult.Issues -join "; ") }
}
}
# ============================================================================
# MAIN
# ============================================================================
try {
Write-Log "=== Invoke-ConfigureStaticIP-Orchestrated.ps1 ===" "HEADER"
# Resolve config file
$resolvedConfig = Resolve-ConfigPath -ExplicitPath $ConfigPath
# Load config
Import-Module powershell-yaml -ErrorAction Stop
$config = Get-Content $resolvedConfig -Raw | ConvertFrom-Yaml
# Build per-node config objects
$allNodeConfigs = Get-NodeConfigs -Config $config
# Filter to requested nodes if specified
if ($NodeNames -and $NodeNames.Count -gt 0) {
$allNodeConfigs = $allNodeConfigs | Where-Object { $NodeNames -contains $_.NodeName }
if ($allNodeConfigs.Count -eq 0) {
throw "None of the specified NodeNames found in variables.yml: $($NodeNames -join ', ')"
}
}
Write-Log "Nodes to configure: $($allNodeConfigs.Count)"
foreach ($nc in $allNodeConfigs) {
Write-Log " $($nc.NodeName) current: $($nc.CurrentIP) target: $($nc.TargetIP)/$($nc.PrefixLength)"
}
# Get credentials
if (-not $Credential) {
$Credential = Get-Credential -Message "Enter local admin credentials for Azure Local nodes"
}
# Process each node sequentially (IP change causes connectivity disruption — serial is safer)
$results = [System.Collections.Generic.List[hashtable]]::new()
foreach ($nc in $allNodeConfigs) {
$result = Invoke-NodeConfiguration -NodeConfig $nc -Cred $Credential
$results.Add($result)
}
# Summary
Write-Log "=== SUMMARY ===" "HEADER"
$ok = @($results | Where-Object { $_.Success })
$fail = @($results | Where-Object { -not $_.Success })
foreach ($r in $ok) { Write-Log " OK $($r.NodeName) $($r.IP)" "SUCCESS" }
foreach ($r in $fail) { Write-Log " FAIL $($r.NodeName) $($r.Error)" "ERROR" }
Write-Log "Configured: $($ok.Count) / $($results.Count)"
if ($fail.Count -gt 0) { exit 1 }
Write-Log "ALL NODES CONFIGURED SUCCESSFULLY" "SUCCESS"
} catch {
Write-Log "CRITICAL ERROR: $_" "ERROR"
exit 1
}
Validation Checklist
- Static IP configured on all nodes
-
PrefixOriginshowsManualon each management adapter -
DHCPshowsDisabledon each management adapter - Default gateway reachable from each node
- DNS servers responding from each node
# Run on each node — verify static assignment
$nic = "Embedded NIC 1" # replace with cluster.management_nic_name from variables.yml
Get-NetIPAddress -InterfaceAlias $nic -AddressFamily IPv4 |
Select-Object IPAddress, PrefixLength, PrefixOrigin
Get-NetIPInterface -InterfaceAlias $nic -AddressFamily IPv4 |
Select-Object InterfaceAlias, Dhcp
# Test gateway and DNS reachability
Test-Connection -ComputerName (Get-NetRoute -AddressFamily IPv4 | Where-Object { $_.DestinationPrefix -eq "0.0.0.0/0" }).NextHop -Count 1 -Quiet
Resolve-DnsName "azure.microsoft.com" -ErrorAction SilentlyContinue | Select-Object Name, IPAddress
Troubleshooting
| Issue | Cause | Resolution |
|---|---|---|
| Script hard-fails on startup | REPLACE placeholder values remain | Edit #region CONFIGURATION block with real values |
Adapter not found error | NIC name in config doesn't match the node | Run Get-NetAdapter on the node; update cluster.management_nic_name in variables.yml |
| Node unreachable after orchestrated run | IP changed (no DHCP reservation) | Orchestrator will auto-reconnect to target IP; check logs for reconnect status |
PrefixOrigin shows Dhcp after script | DHCP not fully disabled | Run Set-NetIPInterface -InterfaceAlias '<nic>' -Dhcp Disabled on the node |
| Wrong IP locked in permanently | Incorrect value in variables.yml | Correct management_ip in yml; rerun the script — it will detect mismatch and reconfigure |
| Gateway unreachable after config | Wrong gateway in variables.yml | Verify network.management.gateway; check upstream switch VLAN |
| DNS not resolving | DNS not set or wrong servers | Verify dns.primary / dns.secondary; rerun script |
Navigation
| ← Task 02: Enable RDP | ↑ Phase 03: OS Configuration | Task 04: Disable DHCP → |
Version Control
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-31 | Azure Local Cloud Azure Local Cloudnology | Initial document |
| 2.0 | 2026-03-04 | Azure Local Cloud Azure Local Cloudnology | Full rewrite to standards — complete frontmatter, 4-tab structure |
| 2.1 | 2026-03-04 | Azure Local Cloud Azure Local Cloudnology | Embed full script code in each tab per provisioning runbook standard |
| 2.2 | 2026-03-04 | Azure Local Cloud Azure Local Cloudnology | Fix SConfig NIC instructions — add vendor table, Get-NetAdapter step |
| 3.0 | 2026-03-04 | Azure Local Cloud Azure Local Cloudnology | Replace DHCP-detection scripts with explicit IP configuration; merge Direct+Standalone into Set-StaticIPAddress.ps1; rewrite Orchestrated with session-loss handling and per-node IP from yml; remove Standalone tab |