Deploy-W11-FromSIG.ps1

<#PSScriptInfo
.VERSION 0.1.0
.GUID 94c392be-bd22-4a55-af15-b4fbba9b9d84
.AUTHOR Jörg Brors
.COMPANYNAME
.COPYRIGHT (c) 2025 Jörg Brors. All rights reserved.
.TAGS AVD,AzureSharedImageGallery,Windows11,TrustedLaunch,VMDeployment,AcceleratedNetworking,PowerShell
.LICENSEURI https://opensource.org/licenses/MIT
.PROJECTURI https://github.com/joergbrors/AVD
.ICONURI
.EXTERNALMODULEDEPENDENCIES Az.Accounts,Az.Compute,Az.Network,Az.Resources
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.DESCRIPTION Deploy-W11-FromSIG creates and configures a Windows 11 VM from a Shared Image Gallery (SIG) for Azure Virtual Desktop (AVD) with Trusted Launch and optional post-install steps.
.RELEASENOTES
    0.1.0 - Initial release (created with assistance from ChatGPT).
#>

#Requires -Modules Az.Accounts, Az.Compute, Az.Network, Az.Resources
<#
.SYNOPSIS
    Deploys a Windows 11 virtual machine from a Shared Image Gallery (SIG). Supports Gen2 Trusted Launch
    (Secure Boot + vTPM), accelerated networking where available, boot diagnostics and a one-time EFI
    bootloader repair via RunCommand. Includes name sanitization for Windows/NetBIOS constraints and optional
    post-install script execution.
 
.DESCRIPTION
    This script automates the creation of a Windows 11 VM sourced from a Shared Image Gallery image version.
    It will:
      - Locate the specified Shared Image Gallery (or auto-detect it in the subscription).
      - Prefer image versions replicated to the target region (TargetRegions).
      - Optionally operate non-interactively when ImageDefinitionName and ImageVersionName are supplied.
      - Create the VM (Gen2 when Trusted Launch is enabled), attach to the specified VNet/Subnet, configure
        NIC/accelerated networking when supported, and enable boot diagnostics.
      - Optionally perform a one-time EFI bootloader repair using bcdboot via Azure RunCommand (unless skipped).
      - Optionally run a provided post-install script and set the VM timezone.
      - Sanitize the provided VM name to comply with Windows computer name and NetBIOS limits (≤15 chars,
        allowed characters) unless an override is provided.
 
.PARAMETER SubscriptionId
    Azure subscription ID to use for the deployment. If not provided, the current subscription context is used.
 
.PARAMETER Location
    Azure region (e.g. "westeurope") used as the deployment target and for filtering image TargetRegions.
 
.PARAMETER RgTarget
    Resource group where the VM, NIC and associated resources will be created. Required for deployment.
 
.PARAMETER RgNetwork
    (Optional) Resource group of the existing virtual network. If omitted, the script will search for the VNet
    name across the subscription (or use the target RG if appropriate).
 
.PARAMETER VnetName
    Name of the virtual network to attach the VM's NIC to.
 
.PARAMETER SubnetName
    Name of the subnet within the VNet for the NIC.
 
.PARAMETER VmName
    Desired base name for the VM. Will be sanitized and used for resource names and Windows computer name
    unless ComputerNameOverride is provided.
 
.PARAMETER ComputerNameOverride
    Optional explicit Windows computer name. Will be sanitized to valid Windows/NetBIOS form. If omitted the
    sanitized VmName is used.
 
.PARAMETER VmSize
    VM size to deploy (for example "Standard_D8ds_v5"). Must be supported in the target region. Trusted Launch
    and accelerated networking capabilities depend on the chosen size.
 
.PARAMETER Tags
    Hashtable of tags to apply to the VM and NIC (e.g. @{ Owner = 'Alice'; Project = 'Test' }).
 
.PARAMETER GalleryResourceGroup
    (Optional) Resource group of the Shared Image Gallery. If not supplied, the gallery will be searched for
    across the subscription by name.
 
.PARAMETER GalleryName
    Name of the Shared Image Gallery containing the desired image definitions and versions.
 
.PARAMETER ImageDefinitionName
    (Optional) Exact image definition name to select non-interactively. When supplied, the script will not prompt
    to choose a definition.
 
.PARAMETER ImageVersionName
    (Optional) Exact image version name (must correspond to the chosen ImageDefinitionName) for non-interactive use.
 
.PARAMETER EnableTrustedLaunch
    Switch or boolean to enable Trusted Launch (Gen2 VM with Secure Boot and vTPM). Default: $true.
    Set to $false to create a Gen1 (legacy BIOS) VM when the image supports it.
 
.PARAMETER AdminCredential
    PSCredential object for the local administrator account on the new VM. If omitted, the script will prompt
    for credentials.
 
.PARAMETER SkipBootFix
    Switch to suppress the post-deployment EFI bootloader repair (bcdboot). Use when the image does not require it.
 
.PARAMETER Force
    Skip interactive confirmation prompts before creating the VM. Useful for automation.
 
.PARAMETER PostInstallScriptPath
    Path to an optional script that will be uploaded and executed on the VM after the timezone is configured.
 
.PARAMETER TimeZone
    Time zone ID to set on the VM prior to running post-install tasks. Default: "W. Europe Standard Time".
 
.PARAMETER MultiSessionHost
    Switch indicating the VM should be prepared as a multi-session host. Default behavior = $true (treat as multi-session).
 
.PARAMETER ForceRestart
    Switch to automatically restart the VM at the end of the script execution without prompting.
 
.PARAMETER ForceStop
    Switch to automatically stop/deallocate the VM at the end of the script execution without prompting.
 
.EXAMPLE
    # Non-interactive: specify gallery, image definition and image version, provide credentials and tags
    $cred = Get-Credential
    .\Deploy-W11-FromSIG.ps1 -SubscriptionId "00000000-0000-0000-0000-000000000000" `
        -Location "westeurope" -RgTarget "rg-vm" -VnetName "prod-vnet" -SubnetName "snet-app" `
        -VmName "W11-APP-01" -ImageDefinitionName "win11-enterprise" -ImageVersionName "1.2.0" `
        -VmSize "Standard_D8ds_v5" -AdminCredential $cred -Tags @{Project='Demo'} -Force
 
.EXAMPLE
    # Interactive: auto-detect gallery, choose definition/version via prompts, use Trusted Launch (default)
    .\Deploy-W11-FromSIG.ps1 -SubscriptionId "00000000-0000-0000-0000-000000000000" `
        -Location "westeurope" -RgTarget "rg-vm" -VnetName "prod-vnet" -SubnetName "snet-app" -VmName "W11-INT-01"
 
.EXAMPLE
    # Disable Trusted Launch and skip the EFI boot fix (for images that don't require it)
    .\Deploy-W11-FromSIG.ps1 -SubscriptionId "..." -Location "westeurope" -RgTarget "rg-vm" `
        -VnetName "vnet1" -SubnetName "snet1" -VmName "W11-NoTL" -EnableTrustedLaunch $false -SkipBootFix -Force
 
.EXAMPLE
    # Provide an explicit, sanitized computer name, run a post-install script and restart automatically
    $cred = Get-Credential
    .\Deploy-W11-FromSIG.ps1 -SubscriptionId "..." -Location "westeurope" -RgTarget "rg-vm" `
        -VnetName "vnet1" -SubnetName "snet1" -VmName "base-name" -ComputerNameOverride "DESKTOP01" `
        -AdminCredential $cred -PostInstallScriptPath "C:\scripts\setup-roles.ps1" -ForceRestart -Force
 
.INPUTS
    None from the pipeline. Parameters accept strings, hashtables and PSCredential objects.
 
.OUTPUTS
    Writes details of the created Azure resources to the host and returns the VM representation returned by
    the Az.Compute creation call (PS custom/VM object). Additional information may be written to verbose/debug
    streams.
 
.NOTES
    - Requirements:
        * Windows PowerShell 5.1 (the script uses modules compatible with PowerShell 5.1).
        * Az modules installed and authenticated: Az.Accounts, Az.Compute, Az.Network, Az.Resources.
        * Sufficient RBAC permissions to read Shared Image Gallery contents and create NICs, VMs, public IPs
          and diagnostic storage in the target subscription/resource groups.
    - Trusted Launch requires a Gen2-capable image and supported VM sizes and regions. If enabled, the VM will
      be deployed as Generation 2 with Secure Boot and vTPM.
    - Accelerated Networking (ENA) will be enabled only if both the VM size and the target NIC/region support it.
    - The script performs a one-time bootloader repair using bcdboot via Azure RunCommand to fix EFI boot issues
      for some SIG-to-VM scenarios; this can be suppressed with -SkipBootFix.
    - The script sanitizes names to conform to Windows computer name and NetBIOS limits (no more than 15 characters,
      allowed characters), and fails early if a usable name cannot be derived.
    - If using PostInstallScriptPath, ensure the script is accessible from the machine running this deployment and
      that the VM's RunCommand execution policy and prerequisites are satisfied.
 
.LINK
    https://learn.microsoft.com/azure/virtual-machines/
    https://learn.microsoft.com/azure/virtual-machines/trusted-launch
    https://learn.microsoft.com/azure/virtual-machines/windows/shared-images
 
.AUTHOR
    Jörg Brors
    (Documentation and enhancements assisted by GitHub Copilot)
#>

param (
    [string]$SubscriptionId           = "<your-subscription-id-here>",
    [string]$Location                 = "westeurope",
    [string]$RgTarget                 = "rg-vm",
    [string]$RgNetwork                = "<your-rg-network-here>",
    [string]$VnetName                 = "<your-vnet-name-here>",
    [string]$SubnetName               = "<your-subnet-name-here>",
    [string]$VmName                   = "<your-vm-name-here>",
    [string]$ComputerNameOverride     = "",

    [string]$VmSize                     = "Standard_D8ds_v5",

    [hashtable]$Tags                    = @{ "Workload"="AVD"; "Stage"="GoldImage"; "Usage"="PROD" },

    [string]$GalleryResourceGroup       = "",
    [string]$GalleryName                = "your-gallery-name-here",

    [string]$ImageDefinitionName  = "",
    [string]$ImageVersionName     = "",

    [bool]$EnableTrustedLaunch    = $true,

    [System.Management.Automation.PSCredential]$AdminCredential,

    [switch]$SkipBootFix,
    [switch]$Force,

    # New parameters for post-install script & time zone
    [string]$PostInstallScriptPath = "",
    [string]$TimeZone = "W. Europe Standard Time", # Default time zone, can be adjusted

    # New switch: MultiSessionHost (default behavior = $true)
    [switch]$MultiSessionHost,

    # New switches: automatic final action without interactive prompt
    [switch]$ForceRestart,
    [switch]$ForceStop
)

# Optional: enforce TLS 1.2 (helps with TLS/proxy issues on PS 5.1)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# ===========================
# Helper functions
# ===========================
function Format-Tags {
    param([hashtable]$Tags)
    if (-not $Tags -or $Tags.Count -eq 0) { return "—" }
    ($Tags.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ", "
}

function Get-VNetByName {
    param(
        [string]$Name,
        [string]$Location,
        [string]$ResourceGroup
    )
    if ($ResourceGroup) {
        try { return Get-AzVirtualNetwork -Name $Name -ResourceGroupName $ResourceGroup -ErrorAction Stop } catch {}
    }
    $all = Get-AzVirtualNetwork -ErrorAction Stop | Where-Object { $_.Name -eq $Name }
    if (-not $all) { return $null }
    $inLoc = $all | Where-Object { $_.Location -eq $Location }
    if ($inLoc) { return ($inLoc | Select-Object -First 1) }
    return ($all | Select-Object -First 1)
}

function Test-AcceleratedNetworkingSupport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$VmSize,
        [Parameter(Mandatory)][string]$Location
    )
    try {
        $sku = Get-AzComputeResourceSku -Location $Location -ErrorAction Stop |
               Where-Object { $_.ResourceType -eq "virtualMachines" -and $_.Name -ieq $VmSize } |
               Select-Object -First 1
        if (-not $sku) { return $false }

        $cap = $sku.Capabilities | Where-Object {
            $_.Name -in @("AcceleratedNetworkingEnabled","AcceleratedNetworking","AcceleratedNetworkingSupported")
        } | Select-Object -First 1

        if ($cap -and ($cap.Value -match '^(true|True|TRUE|1)$')) { return $true }
        return $false
    }
    catch { return $false }
}

function Resolve-GalleryByName {
    param(
        [Parameter(Mandatory)][string]$GalleryName,
        [string]$ResourceGroup
    )
    if ($ResourceGroup) {
        try {
            $g = Get-AzGallery -ResourceGroupName $ResourceGroup -Name $GalleryName -ErrorAction Stop
            return [pscustomobject]@{ Gallery = $g; ResourceGroupName = $ResourceGroup }
        } catch {
            throw "Gallery '$GalleryName' was not found in resource group '$ResourceGroup'. Error: $($_.Exception.Message)"
        }
    }
    $allGalleries = Get-AzGallery -ErrorAction Stop | Where-Object { $_.Name -eq $GalleryName }
    if (-not $allGalleries -or $allGalleries.Count -eq 0) {
        throw "No gallery named '$GalleryName' found in the current subscription context."
    }
    if ($allGalleries.Count -eq 1) {
        $one = $allGalleries[0]
        return [pscustomobject]@{ Gallery = $one; ResourceGroupName = $one.ResourceGroupName }
    }
    Write-Host "`nMultiple galleries with name '$GalleryName' found:" -ForegroundColor Cyan
    $idx = 0
    foreach ($g in $allGalleries) {
        $idx++
        Write-Host ("[{0}] RG: {1} | Location: {2} | Id: {3}" -f $idx, $g.ResourceGroupName, $g.Location, $g.Id)
    }
    $sel = Read-Host "Please choose a number (Enter = 1)"
    if ([string]::IsNullOrWhiteSpace($sel)) { $sel = 1 }
    if ([int]$sel -lt 1 -or [int]$sel -gt $allGalleries.Count) { throw "Invalid selection." }
    $chosen = $allGalleries[[int]$sel - 1]
    return [pscustomobject]@{ Gallery = $chosen; ResourceGroupName = $chosen.ResourceGroupName }
}

function Select-GalleryImage {
    <#
      Returns .Definition and .Version (Az objects).
      Non-interactive if -ImageDefinitionName/-ImageVersionName are provided.
      Filters versions by TargetRegions.Name == $Location (if present), otherwise warns and shows all.
    #>

    param(
        [Parameter(Mandatory)][string]$GalleryResourceGroup,
        [Parameter(Mandatory)][string]$GalleryName,
        [Parameter(Mandatory)][string]$Location,
        [string]$ImageDefinitionName,
        [string]$ImageVersionName
    )

    $defs = Get-AzGalleryImageDefinition -ResourceGroupName $GalleryResourceGroup -GalleryName $GalleryName -ErrorAction Stop
    if (-not $defs -or $defs.Count -eq 0) { throw "No image definitions found in gallery '$GalleryName' (RG: $GalleryResourceGroup)." }

    $def = $null
    if ($ImageDefinitionName) {
        $def = $defs | Where-Object { $_.Name -eq $ImageDefinitionName } | Select-Object -First 1
        if (-not $def) { throw "Image definition '$ImageDefinitionName' was not found." }
    } else {
        Write-Host "`nFound image definitions in gallery '$GalleryName':" -ForegroundColor Cyan
        $i = 0
        foreach ($d in $defs) {
            $i++
            $gen = $d.HyperVGeneration
            Write-Host ("[{0}] {1} (Publisher: {2} | Offer: {3} | Sku: {4} | Gen: {5})" -f $i, $d.Name, $d.Publisher, $d.Offer, $d.Sku, $gen)
        }
        $selIndex = Read-Host "Please select image definition number (Enter = 1)"
        if ([string]::IsNullOrWhiteSpace($selIndex)) { $selIndex = 1 }
        if ([int]$selIndex -lt 1 -or [int]$selIndex -gt $defs.Count) { throw "Invalid selection." }
        $def = $defs[[int]$selIndex - 1]
    }

    if ($EnableTrustedLaunch -and $def.HyperVGeneration -ne "V2") {
        throw "Trusted Launch requires Gen2 (HyperVGeneration V2), but the selected definition is '$($def.HyperVGeneration)'."
    }

    $allVersions = Get-AzGalleryImageVersion -ResourceGroupName $GalleryResourceGroup -GalleryName $GalleryName -GalleryImageDefinitionName $def.Name -ErrorAction Stop

    $versions = @()
    foreach ($v in $allVersions) {
        $trMatch = $false
        if ($v.PublishingProfile -and $v.PublishingProfile.TargetRegions) {
            foreach ($tr in $v.PublishingProfile.TargetRegions) {
                if ($tr.Name -eq $Location) { $trMatch = $true; break }
            }
        }
        if ($trMatch) { $versions += $v }
    }
    if (-not $versions -or $versions.Count -eq 0) {
        Write-Host ("Note: For definition '{0}' there are no versions with TargetRegion '{1}'." -f $def.Name, $Location) -ForegroundColor Yellow
        Write-Host "Showing all versions (no region filtering). Please verify replication into the target region!" -ForegroundColor Yellow
        $versions = $allVersions
    }

    # Sort: newest first
    $versions = $versions | Sort-Object -Property @{Expression = {
        if ($_.PublishingProfile -and $_.PublishingProfile.PublishedDate) { $_.PublishingProfile.PublishedDate } else { Get-Date '1900-01-01' }
    }}, @{Expression = {
        try { [version]$_.Name } catch { [version]"0.0.0" }
    }} -Descending

    $ver = $null
    if ($ImageVersionName) {
        $ver = $versions | Where-Object { $_.Name -eq $ImageVersionName } | Select-Object -First 1
        if (-not $ver) { throw "Image version '$ImageVersionName' was not found for definition '$($def.Name)' (or not in TargetRegion '$Location')." }
    } else {
        Write-Host ("`nAvailable versions for '{0}' (TargetRegion: {1}; newest first):" -f $def.Name, $Location) -ForegroundColor Cyan
        $j = 0
        foreach ($v in ($versions | Select-Object -First 10)) {
            $j++
            $pub = "—"
            if ($v.PublishingProfile -and $v.PublishingProfile.PublishedDate) { $pub = $v.PublishingProfile.PublishedDate.ToString("yyyy-MM-dd HH:mm") }
            Write-Host ("({0}) {1} | Published: {2}" -f $j, $v.Name, $pub)
        }
        $verChoice = Read-Host "Please select version number (Enter = 1 = newest)"
        if ([string]::IsNullOrWhiteSpace($verChoice)) { $verChoice = 1 }
        $maxChoice = [Math]::Min(10, $versions.Count)
        if ([int]$verChoice -lt 1 -or [int]$verChoice -gt $maxChoice) { throw "Invalid selection." }
        $ver = $versions[[int]$verChoice - 1]
    }

    [pscustomobject]@{
        Definition = $def
        Version    = $ver
    }
}

function Get-ValidWindowsComputerName {
    param([Parameter(Mandatory)][string]$BaseName)
    $name = ($BaseName -replace '[^A-Za-z0-9-]', '-')
    $name = $name.Trim('-')
    if ([string]::IsNullOrWhiteSpace($name)) { $name = "vm" + (Get-Random -Maximum 9999) }
    if ($name.Length -gt 15) { $name = $name.Substring(0,15) }
    if ($name -match '^[0-9]+$') { $name = "vm-$name" }
    if ($name.Length -gt 15) { $name = $name.Substring(0,15) }
    $name = $name.TrimEnd('-')
    if ([string]::IsNullOrWhiteSpace($name)) { $name = "vm" + (Get-Random -Maximum 9999) }
    return $name
}

# ===========================
# Login & Subscription
# ===========================
if (-not (Get-AzContext)) { Connect-AzAccount -DeviceCode | Out-Null }
Select-AzSubscription -SubscriptionId $SubscriptionId | Out-Null

# ===========================
# Resolve gallery (RG auto, if empty)
# ===========================
$galleryInfo = Resolve-GalleryByName -GalleryName $GalleryName -ResourceGroup $GalleryResourceGroup
$resolvedGalleryRg = $galleryInfo.ResourceGroupName
$resolvedGallery   = $galleryInfo.Gallery

# ===========================
# Gallery selection (definition + version)
# ===========================
$choice = Select-GalleryImage -GalleryResourceGroup $resolvedGalleryRg -GalleryName $resolvedGallery.Name -Location $Location -ImageDefinitionName $ImageDefinitionName -ImageVersionName $ImageVersionName
$imgDef = $choice.Definition
$imgVer = $choice.Version

# ===========================
# Resolve network objects
# ===========================
$vnet = Get-VNetByName -Name $VnetName -Location $Location -ResourceGroup $RgNetwork
if (-not $vnet) { throw "VNet '$VnetName' was not found." }

$subnet = $vnet.Subnets | Where-Object { $_.Name -eq $SubnetName }
if (-not $subnet) { throw "Subnet '$SubnetName' in VNet '$($vnet.Name)' was not found." }

# ===========================
# Check accelerated networking support
# ===========================
$enableAccelNet = Test-AcceleratedNetworkingSupport -VmSize $VmSize -Location $Location

# ===========================
# Admin credentials (if not provided)
# ===========================
if (-not $AdminCredential) {
    $adminUser = Read-Host "Please enter the local admin username for the new VM"
    if ([string]::IsNullOrWhiteSpace($adminUser)) { throw "Admin username must not be empty." }
    $adminPass = Read-Host "Please enter password for '$adminUser'" -AsSecureString
    $AdminCredential = New-Object System.Management.Automation.PSCredential($adminUser, $adminPass)
}

# ===========================
# Sanitize names (VM resource name & Windows computer name)
# ===========================
$vmNameOriginal = $VmName
$VmName         = Get-ValidWindowsComputerName -BaseName $vmNameOriginal

$ComputerName = $VmName
if ($ComputerNameOverride) {
    $ComputerName = Get-ValidWindowsComputerName -BaseName $ComputerNameOverride
}

if ($VmName -ne $vmNameOriginal) {
    Write-Warning ("VM name adjusted due to NetBIOS rules: '{0}' -> '{1}'" -f $vmNameOriginal, $VmName)
}
if ($ComputerNameOverride -and ($ComputerNameOverride -ne $ComputerName)) {
    Write-Warning ("ComputerNameOverride adjusted to a valid name: '{0}' -> '{1}'" -f $ComputerNameOverride, $ComputerName)
}

# ===========================
# Summary
# ===========================
$securitySummary = ""
if ($EnableTrustedLaunch) { $securitySummary = "TrustedLaunch (SecureBoot + vTPM)" } else { $securitySummary = "Standard (explicit: SecureBoot/vTPM OFF)" }

# After sanitizing names determine effective MultiSessionHost (default = true if not provided)
if ($PSBoundParameters.ContainsKey('MultiSessionHost')) {
    # If switch provided, use its boolean value (e.g. -MultiSessionHost:$false possible)
    $UseMultiSessionHost = [bool]$MultiSessionHost
} else {
    # Default: true
    $UseMultiSessionHost = $true
}

# Validation: only one of ForceRestart/ForceStop may be specified
if ($ForceRestart -and $ForceStop) {
    throw "Only one of -ForceRestart or -ForceStop may be specified."
}

Write-Host "================ SUMMARY ================" -ForegroundColor Cyan
Write-Host "Subscription : $SubscriptionId"
Write-Host "Region : $Location"
Write-Host "Gallery : $($resolvedGallery.Name) (RG: $resolvedGalleryRg)"
Write-Host "Image Def : $($imgDef.Name) (Gen: $($imgDef.HyperVGeneration))"
if ($imgVer.PublishingProfile -and $imgVer.PublishingProfile.PublishedDate) {
    Write-Host ("Image Ver : {0} (Published: {1})" -f $imgVer.Name, $imgVer.PublishingProfile.PublishedDate)
} else {
    Write-Host ("Image Ver : {0} (Published: —)" -f $imgVer.Name)
}
Write-Host "VM Name : $VmName"
Write-Host "ComputerName : $ComputerName"
Write-Host "VM Size : $VmSize"
Write-Host "Target RG : $RgTarget"
Write-Host "VNet/Subnet : $($vnet.Name) / $SubnetName (RG: $($vnet.ResourceGroupName))"
Write-Host "NIC Name : $VmName-nic"
Write-Host "Security : $securitySummary"
Write-Host "Accel. Net : $enableAccelNet"
Write-Host "MultiSession : $UseMultiSessionHost"
# Optional: zeige, ob ForceRestart/ForceStop aktiv sind
if ($ForceRestart) { Write-Host "FinalAction : ForceRestart" -ForegroundColor Cyan }
elseif ($ForceStop) { Write-Host "FinalAction : ForceStop" -ForegroundColor Cyan }
Write-Host "Tags : $(Format-Tags -Tags $Tags)"
Write-Host "=========================================" -ForegroundColor Cyan

if (-not $Force) {
    $confirmation = Read-Host "Create the VM now? (Y/N)"
    if ($confirmation -notin @("Y","y","Yes","yes","J","j")) {
        Write-Host "Cancelled." -ForegroundColor Yellow
        return
    }
}

# ===========================
# Create NIC
# ===========================
$nicName = "$VmName-nic"
$nicParams = @{
    Name              = $nicName
    ResourceGroupName = $RgTarget
    Location          = $Location
    SubnetId          = $subnet.Id
    Tag               = $Tags
}
if ($enableAccelNet) { $nicParams["EnableAcceleratedNetworking"] = $true }

$nic = New-AzNetworkInterface @nicParams

# ===========================
# Build VM configuration
# ===========================
$vmConfig = New-AzVMConfig -VMName $VmName -VMSize $VmSize

# Trusted Launch / UEFI
if ($EnableTrustedLaunch) {
    $vmConfig = Set-AzVMSecurityProfile -VM $vmConfig -SecurityType "TrustedLaunch"
    $vmConfig = Set-AzVMUefi          -VM $vmConfig -EnableVtpm $true -EnableSecureBoot $true
} else {
    $vmConfig = Set-AzVMSecurityProfile -VM $vmConfig -SecurityType "Standard"
    $vmConfig = Set-AzVMUefi          -VM $vmConfig -EnableVtpm $false -EnableSecureBoot $false
}

# Source: SIG image version
$vmConfig = Set-AzVMSourceImage -VM $vmConfig -Id $imgVer.Id

# OS type + admin (ComputerName = sanitized name or override)
$vmConfig = Set-AzVMOperatingSystem -VM $vmConfig -Windows -ComputerName $ComputerName -Credential $AdminCredential -ProvisionVMAgent

# Assign NIC
$vmConfig = Add-AzVMNetworkInterface -VM $vmConfig -Id $nic.Id -Primary

# Boot Diagnostics (Managed)
$vmConfig = Set-AzVMBootDiagnostic -VM $vmConfig -Enable

# ===========================
# Create VM
# ===========================
New-AzVM -ResourceGroupName $RgTarget -Location $Location -VM $vmConfig -Tag $Tags -ErrorAction Stop

Write-Host "VM '$VmName' was created successfully. Security mode: $securitySummary | Accelerated Networking: $enableAccelNet" -ForegroundColor Green


# Set LicenseType for Windows 10/11 Multi-Session (required for multi-session images)
try {
    if ($UseMultiSessionHost) {
        Write-Host "Setting LicenseType = 'Windows_Client' for VM '$VmName' ..." -ForegroundColor Cyan
        $vmObj = Get-AzVM -ResourceGroupName $RgTarget -Name $VmName -ErrorAction Stop
        $vmObj.LicenseType = "Windows_Client"
        Update-AzVM -ResourceGroupName $RgTarget -VM $vmObj -ErrorAction Stop
        Write-Host "LicenseType set successfully." -ForegroundColor Green
    } else {
        Write-Host "Skipping LicenseType update (MultiSessionHost disabled)." -ForegroundColor Yellow
    }
}
catch {
    Write-Warning "Could not set LicenseType: $($_.Exception.Message)"
}

# ===========================
# POST-STEP: rewrite EFI bootloader (one-time)
# ===========================
if (-not $SkipBootFix) {
    Write-Host "Starting one-time EFI bootloader repair via RunCommand ..." -ForegroundColor Cyan
    $bootFixScript = @'
# Mount EFI System Partition (ESP) as S:, rewrite boot files, unmount
# Fallback: direct bcdboot without explicit ESP path
 
try {
    # mount ESP
    & mountvol S: /S 2>&1 | Out-Null
 
    # bcdboot: /p overwrites existing entry instead of creating a new one
    & bcdboot C:\Windows /s S: /f UEFI /p /v
    $rc = $LASTEXITCODE
 
    # unmount ESP
    & mountvol S: /D
 
    if ($rc -ne 0) { throw "bcdboot with ESP drive failed (Code: $rc)." }
}
catch {
    # Fallback without explicit ESP path
    & bcdboot C:\Windows /f UEFI /p /v
    if ($LASTEXITCODE -ne 0) {
        throw "bcdboot fallback failed (Code: $LASTEXITCODE). Error: $($_.Exception.Message)"
    }
}
 
# Timeout = 0 to avoid showing boot menu
& bcdedit /timeout 0
 
Write-Output "EFI boot files were successfully recreated and boot menu cleaned."
'@

    $rcRes = Invoke-AzVMRunCommand -ResourceGroupName $RgTarget -Name $VmName -CommandId 'RunPowerShellScript' -ScriptString $bootFixScript -ErrorAction Stop
    $rcRes.Value | ForEach-Object { if ($_.Message) { Write-Host $_.Message } }
    Write-Host "EFI bootloader repair completed." -ForegroundColor Green

    Write-Host "Note: Restart/Deallocate choice will be prompted at the end of the script." -ForegroundColor Cyan
} else {
    Write-Host "EFI bootloader fix skipped (SkipBootFix set)." -ForegroundColor Yellow
}

# ===========================
# Set TimeZone on the VM (always, even if no post-install script provided)
# ===========================
try {
    Write-Host "Setting time zone on VM '$VmName' to '$TimeZone' ..." -ForegroundColor Cyan

    $tzScript = @"
try {
    Set-TimeZone -Id '$TimeZone'
    Write-Output 'Time zone set: $TimeZone'
} catch {
    Write-Error "Error setting time zone: $($_.Exception.Message)"
    exit 1
}
"@


    # Pass a single string to -ScriptString (some Az versions require a string, not string[])
    $tzRes = Invoke-AzVMRunCommand -ResourceGroupName $RgTarget -Name $VmName -CommandId 'RunPowerShellScript' -ScriptString $tzScript -ErrorAction Stop
    $tzRes.Value | ForEach-Object { if ($_.Message) { Write-Host $_.Message } }
    Write-Host "Time zone was set on VM '$VmName'." -ForegroundColor Green
}
catch {
    Write-Warning "Could not set time zone via RunCommand: $($_.Exception.Message)"
}

# ===========================
# POST-STEP: Optional post-install script execution on the VM
# The TimeZone is injected into the remote session as variable $TimeZone (string).
# ===========================
if ($PostInstallScriptPath) {
    if (-not (Test-Path -Path $PostInstallScriptPath -PathType Leaf)) {
        throw "PostInstallScript '$PostInstallScriptPath' was not found."
    }

    Write-Host "Preparing to execute post-install script: $PostInstallScriptPath" -ForegroundColor Cyan

    try {
        # Read whole script as single string so we can inject the TimeZone var and pass one string to RunCommand
        $scriptContent = Get-Content -Path $PostInstallScriptPath -Raw -ErrorAction Stop

        # Escape single quotes in the TimeZone value and inject a variable declaration at the top
        $tzEscaped = $TimeZone -replace "'", "''"
        $injection = "`$TimeZone = '$tzEscaped'`r`n"
        $fullScriptText = $injection + $scriptContent

        Write-Host "Sending script to VM '$VmName' and executing it..." -ForegroundColor Cyan
        $res = Invoke-AzVMRunCommand `
            -ResourceGroupName $RgTarget `
            -Name $VmName `
            -CommandId 'RunPowerShellScript' `
            -ScriptString $fullScriptText `
            -ErrorAction Stop

        $res.Value | ForEach-Object { if ($_.Message) { Write-Host $_.Message } }
        Write-Host "Post-install script executed on VM '$VmName'." -ForegroundColor Green
    }
    catch {
        throw "Error executing post-install script: $($_.Exception.Message)"
    }
}

# ===========================
# Final action: Restart / Deallocate / No action
# Priority:
# 1) If ForceRestart/ForceStop set -> perform that action automatically.
# 2) Else if Force set -> skip (no action).
# 3) Else interactive menu.
# ===========================
if ($ForceRestart) {
    Write-Host "ForceRestart active: initiating restart of VM '$VmName'..." -ForegroundColor Cyan
    Restart-AzVM -ResourceGroupName $RgTarget -Name $VmName -NoWait
}
elseif ($ForceStop) {
    Write-Host "ForceStop active: initiating deallocate (stop) of VM '$VmName'..." -ForegroundColor Cyan
    Stop-AzVM -ResourceGroupName $RgTarget -Name $VmName -Force -NoWait
}
elseif ($Force) {
    Write-Host "Force active: skipping final action." -ForegroundColor Yellow
}
else {
    Write-Host "" -ForegroundColor Cyan
    Write-Host "Select final action:" -ForegroundColor Cyan
    Write-Host " [1] Restart the VM now" -ForegroundColor Cyan
    Write-Host " [2] Deallocate (stop) the VM now" -ForegroundColor Cyan
    Write-Host " [3] No action (exit) (Enter = 3)" -ForegroundColor Cyan

    $finalChoice = Read-Host "Please select a number"
    if ([string]::IsNullOrWhiteSpace($finalChoice)) { $finalChoice = "3" }

    switch ($finalChoice) {
        "1" {
            Write-Host "Initiating restart of VM '$VmName'..." -ForegroundColor Cyan
            Restart-AzVM -ResourceGroupName $RgTarget -Name $VmName -NoWait
        }
        "2" {
            Write-Host "Initiating deallocate (stop) of VM '$VmName'..." -ForegroundColor Cyan
            Stop-AzVM -ResourceGroupName $RgTarget -Name $VmName -Force -NoWait
        }
        default {
            Write-Host "No final action performed." -ForegroundColor Yellow
        }
    }
}