Skip to content

Instantly share code, notes, and snippets.

@MarcelMeurer
Last active November 24, 2025 07:55
Show Gist options
  • Select an option

  • Save MarcelMeurer/1c0aa1f7ea5736095f1a4ccbd592b794 to your computer and use it in GitHub Desktop.

Select an option

Save MarcelMeurer/1c0aa1f7ea5736095f1a4ccbd592b794 to your computer and use it in GitHub Desktop.
<#
.SYNOPSIS
Converts an Azure Gen1 (V1) Windows VM to a Gen2 (V2) VM by imaging the OS to a new V2 disk,
swapping the OS disk, and verifying the new VM boots fully into Windows before cleanup.
Provided by John Marcum - https://x.com/PJ_Marcum
.DESCRIPTION
High-level flow (orchestrated from the host):
1) Build a temporary helper VM (Gen2) in the same RG/subnet as the source.
2) Create two migration artifacts from the source OS disk:
- A snapshot of the source OS disk (stable, point-in-time copy).
- A managed disk COPY of that snapshot (attached to the helper VM as source).
3) Inside the helper VM (via RunCommand), run three stages:
- Stage 1: Partition the empty Gen2 target disk (UEFI + Recovery + Windows), then DISM capture the source volume to C:\Capture.wim.
- Stage 2: DISM apply the WIM to the new Gen2 Windows partition.
- Stage 3: Lay down UEFI boot assets (bcdboot) onto the EFI partition.
4) Deallocate the helper VM, detach data disks, and swap its OS disk to the converted Gen2 disk.
5) Start the converted VM and actively confirm Windows has fully booted (agent + CIM) before any cleanup.
6) Remove migration artifacts (snapshot, copy, original temp OS disk) only after a successful Windows boot.
Design choices:
- Connectivity is validated with Test-Connectivity:
* Preflight: required public endpoints (AAD, ARM, and boot-diagnostics blob if used).
* Wait-loop: a single host wait against a dependable endpoint (login.microsoftonline.com:443).
We deliberately avoid ICMP and avoid treating generic HTTP probes as fatal (TLS inspection can break them).
- Artifact reuse is โ€œboth-or-noneโ€: if snapshot and copy disk exist together, prompt to reuse; if only one exists,
treat as inconsistent and recreate both (protects against half-completed runs).
- All long-running in-guest steps are polled with an idempotent probe mode (CheckTaskProgress).
- Cleanup only happens after confirmed Windows boot; otherwise we leave artifacts for investigation.
.PARAMETER mode
Execution mode:
Default : Full conversion workflow (orchestrates everything from outside the VM)
PrepareAndCaptureImage : In-guest stage 1 (partition target; capture source to WIM)
ApplyCapturedImage : In-guest stage 2 (apply WIM to target Windows partition)
ConfigureUefiBoot : In-guest stage 3 (bcdboot the EFI partition)
CheckTaskProgress : In-guest probe to determine if DISM has completed
.EXAMPLE
.\Convert-AzVmGen1ToGen2.ps1 -mode Default
.CREDITS
Original script created by Marcel Meurer.
Source: https://gist.github.com/MarcelMeurer/aab2ef8f36d7c634beebea771f54bf30
Adapted and extended with improvements by John Marcum, 2025
.NOTES
Author : John Marcum (PJM)
Last Updated : 2025-09-16
Requirements : Az PowerShell modules; permissions to manage VM/Disks/Snapshots/NICs.
Azure VM Agent must be present in the temporary VM for RunCommand to work.
Logging : Writes to console and to C:\Windows\Logs by default.
Security : Temporary admin credentials are configurable (Prompt or Inline). Prefer Key Vault in production.
.DISCLAIMER
THIS SCRIPT IS PROVIDED "AS IS" AND WITHOUT ANY WARRANTY OR GUARANTEE OF ANY KIND, WHETHER EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
USE OF THIS SCRIPT IS AT YOUR OWN RISK. THE AUTHOR(S) AND DISTRIBUTOR(S) SHALL NOT BE LIABLE FOR ANY DAMAGES,
INCLUDING DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SCRIPT, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#>
param(
[ValidateNotNullOrEmpty()]
[ValidateSet('Default', 'PrepareAndCaptureImage', 'ApplyCapturedImage', 'ConfigureUefiBoot', 'CheckTaskProgress')]
[string] $mode = "Default"
)
$ErrorActionPreference = "Stop"
#region Configuration
# -------------------
# Central place for tunables. These act as a single source of truth to avoid copy/paste drift.
# Keep names stableโ€”other parts of the script refer to these explicitly.
# Subscription / scopes
$TenantId = ""
$SubscriptionId = ""
# VM identities & resource groups
$SourceVmName = ""
$SourceVmResourceGroup = ""
$TargetVmName = "" # Keep โ‰ค15 chars for NetBIOS hostname compatibility.
$TargetVmResourceGroup = ""
# Security features for the temporary helper VM
$EnableTrustedLaunch = $true # If $true: SecurityType TrustedLaunch + vTPM + SecureBoot on the helper VM.
# Disk strategy
$UsePremiumStorage = $true # If $true: force Premium_LRS for speed (requires VM size support).
$TempOsDiskSizeGB = 512 # Helper VM OS disk size (large enough to store C:\Capture.wim comfortably).
# Connectivity (public endpoints only). Avoid ICMP; ARM/AAD endpoints matter for RunCommand + control plane.
$ProbeHost = "login.microsoftonline.com" # "Single host wait" target for robust egress check.
$ProbeTimeoutSeconds = 3600
$ProbePollSeconds = 30
$ProbeHttpUri = "http://www.msftconnecttest.com/connecttest.txt" # Non-fatal reachability hint (may be TLS-inspected).
# Sleep timeout used in long polling loops (between RunCommand checks).
$SleepSecs = 180
# Boot diagnostics (optional). If enabled, blob access to the storage account must be allowed egress.
$BootDiagnosticsEnabled = $true
$BootDiagStorageAccountName = ""
$BootDiagResourceGroup = ""
# Helper VM base image (MUST be Gen2) used only to run migration tooling.
$ImagePublisher = "MicrosoftWindowsServer"
$ImageOffer = "WindowsServer"
$ImageSku = "2022-Datacenter-g2"
$ImageVersion = "latest"
# Admin credential strategy for helper VM
# For production, consider using Azure Key Vault or Azure AD-based management accounts.
$AdminCredentialMode = "InlineSecret" # "Prompt" or "InlineSecret"
$AdminUserName = "vmAdmin"
$AdminPasswordPlainText = "Sup+rTempS+cret123---"
# LUN mapping inside helper VM
# We attach two data disks to the helper VM:
# - SourceCopyDisk (Gen1 content) -> S:\ mount target
# - TargetGen2Disk (empty) -> UEFI/Recovery/Windows partitions (U:\, R:\, T:\)
$Luns = @{
SourceDisk = 6
TargetDisk = 7
}
# Extra space to add when creating the Gen2 target disk
$DiskIncreaseGB = 0 # Adding GB to the new C: so we have room to upgrade to Windows 11.
# Partition sizes for the *target* Gen2 disk (the one we will boot from after conversion).
$PartitionSizes = @{
UefiMB = 500
RecoveryMB = 750
}
# VM boot confirmation gates:
# - PowerWaitSeconds: how long to wait for PowerState/running
# - GuestWaitSeconds: how long to wait for guest agent + CIM to respond via RunCommand probe
$PowerWaitSeconds = 600
$GuestWaitSeconds = 600
$GuestPollSeconds = 15
# Logging
$LogDirectory = "C:\Temp" # Does not need local admin rights to be written to unlike C:\Windows\Logs
$timeStamp = Get-Date -Format "yyyyMMdd-HHmmss"
$LogFileBaseName = "Convert-AzVmGen1ToGen2-$timeStamp.log"
# Create C:\Temp if missing. Logging to both console and file helps with RunCommand triage.
if (-not (Test-Path -LiteralPath $LogDirectory)) {
try { New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null } catch {}
}
$LogFilePath = Join-Path $LogDirectory $LogFileBaseName
# Derived names (stable naming; used for idempotency and easy cleanup).
$Names = @{
SnapshotName = "{0}-Disk-Snap" -f $SourceVmName # Snapshot of source OS disk
SourceCopyDisk = "{0}-Disk-Copy" -f $SourceVmName # Managed disk copy created from the snapshot
TargetGen2Disk = "{0}-Disk-Converted" -f $TargetVmName # Empty Gen2 disk -> destination for DISM apply
NicName = "nic-{0}" -f $TargetVmName # Helper VM NIC
}
# Az module versions we expect. The script will install exact versions if missing.
$RequiredModules = @(
@{ Name = "Az.Accounts"; Version = "5.3.0" },
@{ Name = "Az.Compute"; Version = "10.3.0" },
@{ Name = "Az.Network"; Version = "5.5.0" },
@{ Name = "Az.Resources"; Version = "6.15.1" }
)
#endregion Configuration
# Guard: we must be running as a script file (not pasted/selected). RunCommand needs ScriptPath.
if (-not $PSCommandPath -or -not (Test-Path $PSCommandPath)) {
Write-Host "Run the script file directly (not a selection/paste)." -ForegroundColor Yellow
return
}
$ScriptName = Split-Path -Leaf $PSCommandPath
$ScriptPath = $PSCommandPath
Write-Output "Running script: $ScriptName"
Write-Output "Full path: $ScriptPath"
### BEGIN - Functions ###
### Function to Write Logs ###
function LogWriter($message) {
# ISO-8601 with local offset; easy to sort and diff across machines.
$message = ("{0} {1}" -f (Get-Date).ToString("o"), $message)
Write-Host $message
try { $message | Out-File -FilePath $LogFilePath -Append -Encoding UTF8 } catch {}
}
### Function to Present a Confirmation Prompt ###
function Confirm-YesNo {
<#
Small UX helper for destructive ops (delete VM, disks, etc.) so a human can choose:
- Y: proceed
- N/Enter: skip and (often) abort to avoid accidental reuse/collision
#>
param(
[Parameter(Mandatory)][string]$Prompt,
[string]$Default = 'N'
)
do {
$resp = (Read-Host "$Prompt [Y/N] (default: $Default)").Trim()
if ([string]::IsNullOrWhiteSpace($resp)) { $resp = $Default }
} while ($resp -notin @('Y', 'y', 'N', 'n'))
return ($resp -in @('Y', 'y'))
}
### Function to Prompt for Stale Artifact Removal ###
function Invoke-Remove {
<#
Resilient remover with retry/skip/abort:
- Retry: try again (transient control-plane issues are common).
- Continue: leave the resource and move on (recorded in log).
- Stop: throw to bubble up.
#>
param(
[Parameter(Mandatory)][scriptblock]$Script,
[Parameter(Mandatory)][string]$What
)
while ($true) {
try {
& $Script
LogWriter "Removed: $What"
return $true
} catch {
LogWriter "Failed to remove $($What): $($_.Exception.Message)"
if (Confirm-YesNo -Prompt "Retry removing $What?" -Default 'Y') { continue }
if (Confirm-YesNo -Prompt "Continue without removing $What?" -Default 'Y') {
LogWriter "Continuing without removing $What."
return $false
}
LogWriter "User chose to stop after failure removing $What."
throw
}
}
}
### Function to Remove a VM + OS disk + NICs (+ PIPs) with a single prompt ###
function Remove-StaleVmWithCascade {
<#
Deletes an existing helper VM (if present) and offers to remove its OS disk, NIC(s), and public IP(s).
We don't attempt reuse of the helper VM to avoid state drift (extensions, pending reboots, etc.).
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$ResourceGroupName,
[Parameter(Mandatory)][string]$VmName,
[switch]$IncludePublicIp = $true,
[switch]$Force
)
$vm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VmName -ErrorAction SilentlyContinue
if (-not $vm) { return }
# Gather dependencies
$osDiskId = $vm.StorageProfile.OsDisk.ManagedDisk.Id
$osDiskName = if ($osDiskId) { $osDiskId.Split('/')[-1] } else { $null }
$osDiskRg = if ($osDiskId) { $osDiskId.Split('/')[4] } else { $null }
$nicIds = @($vm.NetworkProfile.NetworkInterfaces | ForEach-Object { $_.Id })
$nics = foreach ($nicId in $nicIds) {
$nicRg = $nicId.Split('/')[4]; $nicName = $nicId.Split('/')[-1]
$nic = Get-AzNetworkInterface -ResourceGroupName $nicRg -Name $nicName -ErrorAction SilentlyContinue
if ($nic) {
$pips = @()
foreach ($ipconf in $nic.IpConfigurations) {
if ($ipconf.PublicIpAddress -and $ipconf.PublicIpAddress.Id) {
$pid = $ipconf.PublicIpAddress.Id
$pips += [pscustomobject]@{ Id = $pid; RG = $pid.Split('/')[4]; Name = $pid.Split('/')[-1] }
}
}
[pscustomobject]@{ Name = $nic.Name; RG = $nicRg; Pips = $pips }
}
}
# Build single confirmation line
$osDiskNameText = if ($osDiskName) { $osDiskName } else { "<none>" }
$nicNamesText = if ($nics) { ($nics | ForEach-Object { $_.Name }) -join ', ' } else { "<none>" }
$pipNamesText = if ($nics) { ($nics | ForEach-Object { $_.Pips } | ForEach-Object { $_.Name } | Where-Object { $_ }) -join ', ' } else { "<none>" }
$proceed = $true
if (-not $Force) {
$pipPart = if ($IncludePublicIp) { ", Public IP(s) [$pipNamesText]" } else { "" }
$prompt = "Delete VM '$VmName' (RG '$ResourceGroupName') and cascade remove: OS disk '$osDiskNameText', NIC(s) [$nicNamesText]$pipPart ?"
$proceed = Confirm-YesNo -Prompt $prompt -Default 'N'
}
if (-not $proceed) { LogWriter "User cancelled cascade delete for '$VmName'."; return }
# 1) Deallocate & remove VM
try {
LogWriter "Deallocating VM '$VmName'..."
Stop-AzVM -ResourceGroupName $ResourceGroupName -Name $VmName -Force -ErrorAction Stop | Out-Null
} catch { LogWriter "Deallocate attempt failed (continuing): $($_.Exception.Message)" }
Invoke-Remove { Remove-AzVM -ResourceGroupName $ResourceGroupName -Name $VmName -Force -ErrorAction Stop } "VM '$VmName'"
# 2) Remove NICs (+ optional PIPs)
foreach ($nic in $nics) {
if ($IncludePublicIp -and $nic.Pips) {
foreach ($pip in $nic.Pips) {
Invoke-Remove { Remove-AzPublicIpAddress -ResourceGroupName $pip.RG -Name $pip.Name -Force -ErrorAction Stop } "Public IP '$($pip.Name)'"
}
}
Invoke-Remove { Remove-AzNetworkInterface -ResourceGroupName $nic.RG -Name $nic.Name -Force -ErrorAction Stop } "NIC '$($nic.Name)'"
}
# 3) Remove OS disk
if ($osDiskName -and $osDiskRg) {
Invoke-Remove { Remove-AzDisk -ResourceGroupName $osDiskRg -DiskName $osDiskName -Force -ErrorAction Stop } "OS disk '$osDiskName'"
}
LogWriter "Cascade delete complete for VM '$VmName'."
}
### Function to Remove Stale Disks ###
function Remove-StaleDisk {
<#
Removes an existing disk by name (used for TargetGen2Disk). We do not reuse this disk because its
content is tightly coupled to the last runโ€™s state and we want repeatable, clean runs.
#>
param([Parameter(Mandatory)][string]$ResourceGroupName, [Parameter(Mandatory)][string]$DiskName)
$disk = Get-AzDisk -ResourceGroupName $ResourceGroupName -DiskName $DiskName -ErrorAction SilentlyContinue
if (-not $disk) { return }
LogWriter "Found stale disk: $DiskName"
if (-not (Confirm-YesNo -Prompt "Delete disk '$DiskName' in RG '$ResourceGroupName'?" -Default 'Y')) { return }
Invoke-Remove { Remove-AzDisk -ResourceGroupName $ResourceGroupName -DiskName $DiskName -Force -ErrorAction Stop } "disk '$DiskName'"
}
### Function to Delete Stale Snapshot ###
function Remove-StaleSnapshot {
<#
Removes a snapshot by name (not used in normal path; kept for manual cleanup scenarios).
#>
param([Parameter(Mandatory)][string]$ResourceGroupName, [Parameter(Mandatory)][string]$SnapshotName)
$snap = Get-AzSnapshot -ResourceGroupName $ResourceGroupName -SnapshotName $SnapshotName -ErrorAction SilentlyContinue
if (-not $snap) { return }
LogWriter "Found stale snapshot: $SnapshotName"
if (-not (Confirm-YesNo -Prompt "Delete snapshot '$SnapshotName' in RG '$ResourceGroupName'?" -Default 'Y')) { return }
Invoke-Remove { Remove-AzSnapshot -ResourceGroupName $ResourceGroupName -SnapshotName $SnapshotName -Force -ErrorAction Stop } "snapshot '$SnapshotName'"
}
### Function to Ensure We Have Both a Disk Copy and Snapshot ###$#
function Resolve-SourceArtifacts {
<#
Ensures the *pair* (snapshot + copy disk) are present and trustworthy.
Rules:
- If both exist: offer to reuse them (fast path), or delete + recreate.
- If only one exists: treat as inconsistent (likely previous failed run); delete any that exist and recreate both.
- If none exist: create snapshot from source OS disk, then create a managed disk Copy from the snapshot.
Returns: { Snapshot = <snapshot object>, CopyDisk = <disk object> }
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ResourceGroupName,
[Parameter(Mandatory)][string]$SnapshotName,
[Parameter(Mandatory)][string]$CopyDiskName,
[Parameter(Mandatory)][string]$SourceOsDiskId,
[Parameter(Mandatory)][string]$Location,
[Parameter(Mandatory)][string]$SourceDiskSkuName,
[Parameter(Mandatory)][bool] $UsePremiumStorage
)
$snap = Get-AzSnapshot -ResourceGroupName $ResourceGroupName -SnapshotName $SnapshotName -ErrorAction SilentlyContinue
$copy = Get-AzDisk -ResourceGroupName $ResourceGroupName -DiskName $CopyDiskName -ErrorAction SilentlyContinue
if ($snap -and $copy) {
LogWriter "Found BOTH snapshot '$SnapshotName' and disk '$CopyDiskName'."
if (Confirm-YesNo -Prompt "Reuse existing snapshot and disk copy?" -Default 'Y') {
return [pscustomobject]@{ Snapshot = $snap; CopyDisk = $copy }
}
if (-not (Confirm-YesNo -Prompt "Delete both and recreate?" -Default 'Y')) {
throw "Aborted by user (snapshot+copy exist)."
}
Invoke-Remove { Remove-AzDisk -ResourceGroupName $ResourceGroupName -DiskName $CopyDiskName -Force -ErrorAction Stop } "disk '$CopyDiskName'"
Invoke-Remove { Remove-AzSnapshot -ResourceGroupName $ResourceGroupName -SnapshotName $SnapshotName -Force -ErrorAction Stop } "snapshot '$SnapshotName'"
$snap = $null; $copy = $null
} elseif ($snap -or $copy) {
LogWriter "Mismatched artifacts: exactly one of snapshot/copy exists. Treating as unsafe."
if (-not (Confirm-YesNo -Prompt "Delete the lone artifact and recreate both?" -Default 'Y')) {
throw "Aborted by user (inconsistent artifacts)."
}
if ($copy) { Invoke-Remove { Remove-AzDisk -ResourceGroupName $ResourceGroupName -DiskName $CopyDiskName -Force -ErrorAction Stop } "disk '$CopyDiskName'" }
if ($snap) { Invoke-Remove { Remove-AzSnapshot -ResourceGroupName $ResourceGroupName -SnapshotName $SnapshotName -Force -ErrorAction Stop } "snapshot '$SnapshotName'" }
$snap = $null; $copy = $null
}
if (-not $snap) {
LogWriter "Creating snapshot '$SnapshotName' from source OS disk..."
$snapCfg = New-AzSnapshotConfig -SourceResourceId $SourceOsDiskId -Location $Location -CreateOption Copy
$snap = New-AzSnapshot -ResourceGroupName $ResourceGroupName -SnapshotName $SnapshotName -Snapshot $snapCfg
}
if (-not $copy) {
LogWriter "Creating disk copy '$CopyDiskName' from snapshot..."
$diskCfg = New-AzDiskConfig -Location $Location -SourceResourceId $snap.Id -CreateOption Copy -SkuName $SourceDiskSkuName
if ($UsePremiumStorage) { $diskCfg.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new('Premium_LRS') }
$copy = New-AzDisk -Disk $diskCfg -ResourceGroupName $ResourceGroupName -DiskName $CopyDiskName
}
return [pscustomobject]@{ Snapshot = $snap; CopyDisk = $copy }
}
### Function to Ensure We have Internet Connectivity ####
function Test-Connectivity {
<#
Unified connectivity check:
- Preflight (no -SingleHost): required endpoints must respond on TCP 443.
AAD: login.microsoftonline.com
ARM: management.azure.com
BootDiag Blob (optional): <account>.blob.core.windows.net
Returns PSCustomObject with AllPassed=$true only if all required pass.
- Single-host wait (with -SingleHost): loop until TCP connect succeeds or timeout.
A non-fatal HTTP probe is logged for operator context (TLS inspection can cause errors).
Suppresses Test-NetConnection popups and warning noise.
#>
[CmdletBinding()]
param(
[string]$BootDiagStorageAccountName,
[string]$SingleHost,
[int]$Port = 443,
[int]$TimeoutSeconds = 3600,
[int]$PollSeconds = 30,
[string]$HttpProbeUri = $ProbeHttpUri
)
# ---- Single-host wait mode (used during long in-guest stage polling) ----
if ($SingleHost) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ($true) {
$tcpOk = $false; $err = $null
try {
$tcpOk = Test-NetConnection -ComputerName $SingleHost -Port $Port -InformationLevel Quiet `
-WarningAction SilentlyContinue -ErrorAction SilentlyContinue 4>$null
} catch { $err = $_.Exception.Message; $tcpOk = $false }
if ($tcpOk) {
LogWriter ("Connectivity: {0}:{1} [OK]" -f $SingleHost, $Port)
$row = [pscustomobject]@{ Name = "SingleHost"; Host = $SingleHost; Port = $Port; Required = $true; Passed = $true; Error = $null }
return [pscustomobject]@{ AllPassed = $true; Results = @($row) }
}
# Non-fatal HTTP/HTTPS probe (purely informational)
if ($HttpProbeUri) {
$origProto = [Net.ServicePointManager]::SecurityProtocol
try {
if ($HttpProbeUri -like 'https*') {
[Net.ServicePointManager]::SecurityProtocol = $origProto -bor [Net.SecurityProtocolType]::Tls12
}
$resp = Invoke-WebRequest -Uri $HttpProbeUri -Method Head -UseBasicParsing -MaximumRedirection 0 -TimeoutSec 5 -ErrorAction Stop
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 500) {
LogWriter ("Connectivity: HTTP probe {0} => {1} [OK]" -f $HttpProbeUri, $resp.StatusCode)
}
} catch {
LogWriter ("Connectivity: HTTP probe {0} => SKIPPED ({1})" -f $HttpProbeUri, $_.Exception.Message)
} finally {
[Net.ServicePointManager]::SecurityProtocol = $origProto
}
}
if ((Get-Date) -ge $deadline) {
LogWriter ("Connectivity: {0}:{1} unreachable after {2}s" -f $SingleHost, $Port, $TimeoutSeconds)
$row = [pscustomobject]@{ Name = "SingleHost"; Host = $SingleHost; Port = $Port; Required = $true; Passed = $false; Error = $err }
return [pscustomobject]@{ AllPassed = $false; Results = @($row) }
}
LogWriter ("Connectivity: {0}:{1} not ready; retrying in {2}s..." -f $SingleHost, $Port, $PollSeconds)
Start-Sleep -Seconds $PollSeconds
}
}
# Preflight mode (required endpoints)
$targets = @(
[pscustomobject]@{ Name = "AAD-Login"; Host = "login.microsoftonline.com"; Port = 443; Required = $true },
[pscustomobject]@{ Name = "ARM"; Host = "management.azure.com"; Port = 443; Required = $true }
)
if ($BootDiagStorageAccountName) {
$targets += [pscustomobject]@{ Name = "BootDiag-Blob"; Host = "$BootDiagStorageAccountName.blob.core.windows.net"; Port = 443; Required = $true }
}
$results = foreach ($t in $targets) {
$ok = $false; $err = $null
try {
$ok = Test-NetConnection -ComputerName $t.Host -Port $t.Port -InformationLevel Quiet `
-WarningAction SilentlyContinue -ErrorAction SilentlyContinue 4>$null
} catch { $err = $_.Exception.Message; $ok = $false }
$status = if ($ok) { "OK" } else { "FAIL" }
LogWriter ("Preflight: {0} => {1}:{2} [{3}]" -f $t.Name, $t.Host, $t.Port, $status)
[pscustomobject]@{ Name = $t.Name; Host = $t.Host; Port = $t.Port; Required = $true; Passed = [bool]$ok; Error = $err }
}
# Info-only HTTP/HTTPS probe (helps operators see general egress; never fails preflight)
if ($HttpProbeUri) {
$origProto = [Net.ServicePointManager]::SecurityProtocol
try {
if ($HttpProbeUri -like 'https*') {
[Net.ServicePointManager]::SecurityProtocol = $origProto -bor [Net.SecurityProtocolType]::Tls12
}
$resp = Invoke-WebRequest -Uri $HttpProbeUri -Method Head -UseBasicParsing -MaximumRedirection 0 -TimeoutSec 5 -ErrorAction Stop
LogWriter ("Preflight: HTTP probe {0} => {1}" -f $HttpProbeUri, $resp.StatusCode)
} catch {
LogWriter ("Preflight: HTTP probe {0} => SKIPPED ({1})" -f $HttpProbeUri, $_.Exception.Message)
} finally {
[Net.ServicePointManager]::SecurityProtocol = $origProto
}
}
$requiredFailed = $results | Where-Object { $_.Required -and -not $_.Passed }
return [pscustomobject]@{ AllPassed = (-not $requiredFailed); Results = $results }
}
### Function to Get Azure Auth Token Expiration ###
function Get-AzTokenExpiry {
try {
# Requires Az.Accounts >= 2.11
$tok = Get-AzAccessToken -ResourceUrl "https://management.azure.com/"
return $tok.ExpiresOn
} catch {
return $null
}
}
### Function to Get Signed In User INfo ###
function Get-AzSignedInAccount {
try {
$ctx = Get-AzContext
if ($ctx -and $ctx.Account -and $ctx.Account.Id) { return $ctx.Account.Id }
} catch {}
return "<unknown>"
}
### Function to Ensure We are Authenticated to Azure ###
function Ensure-AzAuth {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$TenantId,
[Parameter(Mandatory)][string]$SubscriptionId,
[int]$MinMinutes = 15,
[switch]$HardRefreshIfStale # clear context if soft refresh doesn't extend expiry
)
$acct = $null
try { $acct = (Get-AzContext -ErrorAction SilentlyContinue).Account.Id } catch {}
# Use your old/working Get-AzTokenExpiry (no params)
$expBefore = Get-AzTokenExpiry
if ($acct -and $expBefore) {
LogWriter ("{0} session expires on {1}" -f $acct, $expBefore.ToString("G"))
} elseif ($acct) {
LogWriter ("{0} session expiry is unknown" -f $acct)
} else {
LogWriter ("Azure session not established")
}
$now = Get-Date
$needsRefresh = (-not $expBefore) -or ($expBefore -lt $now.AddMinutes($MinMinutes))
if ($needsRefresh) {
LogWriter ("Auth missing/near expiry. Attempting soft re-auth (Connect-AzAccount)โ€ฆ")
try {
Connect-AzAccount -TenantId $TenantId -SubscriptionId $SubscriptionId | Out-Null
Get-AzSubscription -SubscriptionId $SubscriptionId | Select-AzSubscription | Out-Null
} catch {
LogWriter "Soft re-auth failed: $($_.Exception.Message)"
throw
}
# Soft refresh may be silent; see if expiry actually moved.
$expAfter = Get-AzTokenExpiry
if ($expAfter -and $expBefore -and $expAfter -le $expBefore -and $HardRefreshIfStale) {
LogWriter "Expiry didn't extend after soft re-auth; performing hard refresh of process contextโ€ฆ"
try {
Remove-AzAccount -Scope Process -ErrorAction SilentlyContinue
Clear-AzContext -Scope Process -Force -ErrorAction SilentlyContinue
Connect-AzAccount -TenantId $TenantId -SubscriptionId $SubscriptionId | Out-Null
Get-AzSubscription -SubscriptionId $SubscriptionId | Select-AzSubscription | Out-Null
$expAfter = Get-AzTokenExpiry
} catch {
LogWriter "Hard refresh failed: $($_.Exception.Message)"
throw
}
}
$expTxt = if ($expAfter) { $expAfter.ToString("G") } else { "<unknown>" }
LogWriter ("Re-auth complete. Current expiry: {0}" -f $expTxt)
}
}
### Function to Invoke Long Running Azure Commands with Reauth if Required ###
function Invoke-AzOpWithReauth {
<#
Runs an Azure cmdlet. On auth/permission errors (e.g., AuthorizationFailed,
expired token, AADSTSโ€ฆ), prompts for re-auth once and retries.
Usage:
$result = Invoke-AzOpWithReauth -Script { Invoke-AzVMRunCommand ... } `
-OperationName "RunCommand PrepareAndCaptureImage" `
-TenantId $TenantId -SubscriptionId $SubscriptionId
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][scriptblock]$Script,
[string]$OperationName = "Azure operation",
[string]$TenantId,
[string]$SubscriptionId
)
try {
return & $Script
} catch {
$msg = $_.Exception.Message
$code = $null; try { $code = $_.Exception.ErrorCode } catch {}
$reauth = ($code -eq 'AuthorizationFailed') -or ($msg -match '(ExpiredAuthenticationToken|invalid_token|AADSTS|Forbidden|AuthorizationFailed)')
if ($reauth) {
LogWriter ("Auth failure on {0}: {1}" -f $OperationName, $msg)
LogWriter "Prompting to re-authenticateโ€ฆ"
Ensure-AzAuth -TenantId $TenantId -SubscriptionId $SubscriptionId -MinMinutes 30 -HardRefreshIfStale
try { return & $Script } catch {
LogWriter ("Retry after re-auth also failed for {0}: {1}" -f $OperationName, $_.Exception.Message)
throw
}
}
throw
}
}
# Function to expand messages from Invoke-AzVMRunCommand
function Invoke-VMRun {
param(
[string]$ResourceGroupName, [string]$VmName,
[string]$ScriptPath, [hashtable]$Parameters,
[switch]$Quiet
)
$rc = Invoke-AzVMRunCommand -ResourceGroupName $ResourceGroupName -Name $VmName `
-CommandId 'RunPowerShellScript' -ScriptPath $ScriptPath `
-Parameter $Parameters -ErrorAction Stop
if (-not $Quiet) {
foreach ($v in $rc.Value) {
# keep this ultra-compact to avoid table spam
if ($v.Message) { LogWriter ( ($v.Message -replace '\r?\n', ' ') ) }
}
}
return $rc
}
### Function to Confirm New VM Boots Into Windows ####
function Wait-ForWindowsBoot {
<#
Confirms the *converted* VM is truly booted into Windows:
1) Wait for PowerState/running (control plane).
2) Use RunCommand to execute a tiny in-guest probe (CIM -> Win32_OperatingSystem).
Returns:
{ Confirmed = $true/$false; Stage = 'PowerState'|'Guest'; Detail = '...'}
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ResourceGroupName,
[Parameter(Mandatory)][string]$VmName,
[int]$PowerTimeoutSeconds = $PowerWaitSeconds,
[int]$GuestTimeoutSeconds = $GuestWaitSeconds,
[int]$PollSeconds = $GuestPollSeconds
)
# 1) Power state gate
$deadline = (Get-Date).AddSeconds($PowerTimeoutSeconds)
$iv = $null
do {
try {
$iv = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VmName -Status
if ($iv.Statuses.Code -contains 'PowerState/running') { break }
} catch { }
Start-Sleep -Seconds $PollSeconds
} while ((Get-Date) -lt $deadline)
if (-not ($iv -and $iv.Statuses.Code -contains 'PowerState/running')) {
return [pscustomobject]@{ Confirmed = $false; Stage = 'PowerState'; Detail = 'VM did not reach PowerState/running' }
}
$probe = @'
$plat = [Environment]::OSVersion.Platform
try { $caption = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).Caption } catch { $caption = $null }
if ($plat.ToString() -eq "Win32NT" -and $caption) { "OK|$caption" } else { "WAITING|$($plat.ToString())|$caption" }
'@
# 2) Guest gate (agent + CIM responding)
$deadline = (Get-Date).AddSeconds($GuestTimeoutSeconds)
do {
$wait = Test-Connectivity -SingleHost $ProbeHost -Port 443 -TimeoutSeconds $ProbeTimeoutSeconds -PollSeconds 10
if (-not $wait.AllPassed) {
return [pscustomobject]@{ Confirmed = $false; Stage = 'Guest'; Detail = "No outbound connectivity within $ProbeTimeoutSeconds s while waiting for guest" }
}
try {
$rv = Invoke-AzVMRunCommand -ResourceGroupName $ResourceGroupName -Name $VmName -CommandId 'RunPowerShellScript' -ScriptString $probe
$msg = $rv.Value[0].Message.Trim()
if ($msg -like 'OK|*') {
return [pscustomobject]@{ Confirmed = $true; Stage = 'Guest'; Detail = $msg.Substring(3) }
}
} catch { }
Start-Sleep -Seconds $PollSeconds
} while ((Get-Date) -lt $deadline)
[pscustomobject]@{ Confirmed = $false; Stage = 'Guest'; Detail = "Guest agent/Windows not ready after $GuestTimeoutSeconds s" }
}
### Function to Set Login Creds on The New VM ###
function New-HelperVmCredential {
<#
Materialize the helper VM local admin credential based on $AdminCredentialMode.
For production, avoid InlineSecret; use Key Vault/managed identity patterns instead.
#>
switch ($AdminCredentialMode) {
"Prompt" { return (Get-Credential -Message "Enter local admin credential for the temporary helper VM") }
"InlineSecret" { $sec = ConvertTo-SecureString $AdminPasswordPlainText -AsPlainText -Force; return New-Object System.Management.Automation.PSCredential($AdminUserName, $sec) }
default { throw "Unknown AdminCredentialMode: $AdminCredentialMode" }
}
}
### Function to validate the captured WIM with DISM ###
function Test-WimHealthy {
param(
[Parameter(Mandatory)][string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
LogWriter(("WIM check: file not found: {0}" -f $Path))
return [pscustomobject]@{ Healthy = $false; Reason = "missing"; SizeMB = 0; Indexes = 0 }
}
$fi = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue
if (-not $fi -or $fi.Length -lt 1MB) {
LogWriter(("WIM check: file too small ({0} bytes)" -f ($fi.Length)))
return [pscustomobject]@{ Healthy = $false; Reason = "too-small"; SizeMB = [math]::Round(($fi.Length / 1MB), 1); Indexes = 0 }
}
$log = "C:\Gen2Logs\WimInfo.log"
$args = @(
'/Get-WimInfo',
'/WimFile:"{0}"' -f $Path,
'/English',
'/LogLevel:4',
'/LogPath:"{0}"' -f $log
) -join ' '
try {
$p = Start-Process -FilePath dism.exe -ArgumentList $args -NoNewWindow -PassThru -Wait
$exit = $p.ExitCode
} catch {
LogWriter(("WIM check: failed to start DISM: {0}" -f $_.Exception.Message))
return [pscustomobject]@{ Healthy = $false; Reason = "launch-failed"; SizeMB = [math]::Round(($fi.Length / 1MB), 1); Indexes = 0 }
}
if ($exit -ne 0) {
# Tail a little context from the DISM log
$tail = (Get-Content -LiteralPath $log -ErrorAction SilentlyContinue -Tail 5) -join ' '
LogWriter(("WIM check: DISM returned {0}. Tail: {1}" -f $exit, $tail))
return [pscustomobject]@{ Healthy = $false; Reason = ("dism-exit-{0}" -f $exit); SizeMB = [math]::Round(($fi.Length / 1MB), 1); Indexes = 0 }
}
# Count indexes from DISM output
$idx = 0
try {
$out = dism.exe /Get-WimInfo /WimFile:$Path /English
$idx = ($out | Select-String -SimpleMatch 'Index :').Count
} catch {}
return [pscustomobject]@{ Healthy = $true; Reason = "ok"; SizeMB = [math]::Round(($fi.Length / 1MB), 1); Indexes = $idx }
}
#### END - Functions ####
#region ModuleBootstrap
if ($mode -eq "Default") {
LogWriter ("Starting in mode: $mode")
# ---------------------
# Ensure the Az modules we depend on are present (and the versions we tested with).
# Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction SilentlyContinue
# Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers -ErrorAction SilentlyContinue
# Import-PackageProvider -Name NuGet -Force -ErrorAction SilentlyContinue
foreach ($mod in $RequiredModules) {
$found = Get-Module -ListAvailable -Name $mod.Name | Sort-Object Version -Descending | Select-Object -First 1
if (-not $found -or ($mod.Version -and ($found.Version -lt [version]$mod.Version))) {
Write-Host "Installing module $($mod.Name) (minimum version $($mod.Version))..."
try {
Install-Module -Name $mod.Name -RequiredVersion $mod.Version -Force -AllowClobber
} catch {
Write-Error "Failed to install module $($mod.Name): $_"
throw
}
} else {
Write-Host "Module $($mod.Name) (version $($found.Version)) already available."
}
}
Import-Module Az.Accounts -RequiredVersion 5.3.0 -Force -ErrorAction Stop
Import-Module Az.Compute -RequiredVersion 10.3.0 -Force -ErrorAction Stop
Import-Module Az.Network, Az.Resources -ErrorAction Stop
#endregion ModuleBootstrap
#region MainApp
# -------------
LogWriter ("Starting in mode: $mode")
#region AzConfig
# Quiet noisy warnings + ensure WAM is disabled so an external browser login is used.
Update-AzConfig -DisplayBreakingChangeWarning $false | Out-Null
Update-AzConfig -EnableLoginByWam $false | Out-Null
# ---- Connectivity preflight (required public endpoints only) ------------
$diagName = if ($BootDiagnosticsEnabled) { $BootDiagStorageAccountName } else { $null }
$pre = Test-Connectivity -BootDiagStorageAccountName $diagName
if (-not $pre.AllPassed) {
LogWriter "Connectivity preflight FAILED. Fix AAD/ARM/Blob egress and re-run."
throw "Connectivity preflight failed."
}
# ---- Azure context/login (token-aware) -------------------------------------
#### FIX ME LATER ##### Ensure-AzAuth -TenantId $TenantId -SubscriptionId $SubscriptionId -MinMinutes 60
Connect-AzAccount -TenantId $TenantId -Subscription $SubscriptionId | Out-Null
Get-AzSubscription -SubscriptionId $SubscriptionId | Select-AzSubscription | Out-Null
# ---- Stale resources (fresh helper VM and target OS disk only) ----------
Remove-StaleVmWithCascade -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName
Remove-StaleDisk -ResourceGroupName $TargetVmResourceGroup -DiskName $Names.TargetGen2Disk
# ---- Load source VM metadata (location/subnet are reused for the helper)-
LogWriter ("Getting data of the source VM")
$sourceVm = Get-AzVM -ResourceGroupName $SourceVmResourceGroup -Name $SourceVmName
$sourceNic = Get-AzNetworkInterface -ResourceId $sourceVm.NetworkProfile.NetworkInterfaces[0].Id
$location = $sourceNic.Location
$subnetId = $sourceNic.IpConfigurations[0].Subnet.Id
# Short-circuit if already Gen2 (avoid doing work twice).
$sourceDisk = Get-AzDisk -ResourceGroupName $sourceVm.StorageProfile.OsDisk.ManagedDisk.Id.Split("/")[4] `
-DiskName $sourceVm.StorageProfile.OsDisk.ManagedDisk.Id.Split("/")[8]
if ($sourceDisk.HyperVGeneration -like "V2") {
LogWriter "Source VM is already a Gen2 VM. Exiting."
exit
}
# ---- Ensure source artifacts (snapshot + copy) exist together -----------
$artifacts = Resolve-SourceArtifacts `
-ResourceGroupName $TargetVmResourceGroup `
-SnapshotName $Names.SnapshotName `
-CopyDiskName $Names.SourceCopyDisk `
-SourceOsDiskId $sourceVm.StorageProfile.OsDisk.ManagedDisk.Id `
-Location $location `
-SourceDiskSkuName $sourceDisk.Sku.Name `
-UsePremiumStorage $UsePremiumStorage
$sourceDiskSnap = $artifacts.Snapshot
$sourceDiskCopy = $artifacts.CopyDisk
# ---- Build VM (Gen2) --------------------------------------------
LogWriter ("Creating the target VM (Gen2)")
$psc = New-HelperVmCredential
$nic = New-AzNetworkInterface -Name $Names.NicName -ResourceGroupName $TargetVmResourceGroup -Location $location -SubnetId $subnetId -Force
$vmConfig = New-AzVMConfig -VMName $TargetVmName -VMSize $sourceVm.HardwareProfile.VmSize -tags $sourceVm.Tags
if ($BootDiagnosticsEnabled) {
$vmConfig = Set-AzVMBootDiagnostic -VM $vmConfig -Enable -StorageAccountName $BootDiagStorageAccountName -ResourceGroupName $BootDiagResourceGroup
}
$vmConfig = Set-AzVMSourceImage -VM $vmConfig -PublisherName $ImagePublisher -Offer $ImageOffer -Skus $ImageSku -Version $ImageVersion
$vmConfig = Set-AzVMOSDisk -VM $vmConfig -DiskSizeInGB $TempOsDiskSizeGB -CreateOption FromImage
$vmConfig = Set-AzVMOperatingSystem -VM $vmConfig -ComputerName $TargetVmName -Windows -EnableAutoUpdate -Credential $psc
$vmConfig = Add-AzVMNetworkInterface -VM $vmConfig -Id $nic.Id
if ($EnableTrustedLaunch) {
# Force Trusted Launch + UEFI bits ON
$vmConfig = Set-AzVMSecurityProfile -VM $vmConfig -SecurityType 'TrustedLaunch'
$vmConfig = Set-AzVmUefi -VM $vmConfig -EnableVtpm $true -EnableSecureBoot $true
} else {
# Force STANDARD security + UEFI bits OFF
$vmConfig = Set-AzVMSecurityProfile -VM $vmConfig -SecurityType 'Standard'
$vmConfig = Set-AzVmUefi -VM $vmConfig -EnableVtpm $false -EnableSecureBoot $false
}
if ($sourceVm.LicenseType -eq $null) {
New-AzVM -VM $vmConfig -ResourceGroupName $TargetVmResourceGroup -Location $location | Out-Null
} else {
New-AzVM -VM $vmConfig -ResourceGroupName $TargetVmResourceGroup -Location $location -LicenseType $sourceVm.LicenseType | Out-Null
}
$targetVm = Get-AzVM -ResourceGroupName $TargetVmResourceGroup -Name $TargetVmName
$targetDiskOrg = Get-AzDisk -ResourceGroupName $TargetVmResourceGroup -DiskName $targetVm.StorageProfile.OsDisk.Name
# ---- Create empty Gen2 target disk (destination for DISM apply) ----
LogWriter ("Create an empty Gen2 disk as destination for the data")
$newSizeGB = [math]::Ceiling($sourceDisk.DiskSizeGB + $DiskIncreaseGB)
$diskCfg = New-AzDiskConfig `
-Location $location `
-SkuName $sourceDisk.Sku.Name `
-OsType Windows `
-HyperVGeneration 'V2' `
-DiskSizeGB $newSizeGB `
-CreateOption 'Empty'
# Match disk security to the VM
if ($EnableTrustedLaunch) {
$diskCfg = Set-AzDiskSecurityProfile -Disk $diskCfg -SecurityType 'TrustedLaunch'
} else {
# Optional (Standard is default if unset, but being explicit is fine):
# $diskCfg = Set-AzDiskSecurityProfile -Disk $diskCfg -SecurityType 'Standard'
}
# Keep your premium tweak if desired
if ($UsePremiumStorage) {
$diskCfg.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new('Premium_LRS')
}
$targetDisk = New-AzDisk -Disk $diskCfg -ResourceGroupName $TargetVmResourceGroup -DiskName $Names.TargetGen2Disk
# ---- Attach data disks to helper VM -------------------------------------
# Source copy (as read source) and empty target (to write partitions + image)
LogWriter ("Attaching the copied source disk to the target VM: LUN $($Luns.SourceDisk)")
$targetVm = Add-AzVMDataDisk -VM $targetVm -Name $sourceDiskCopy.Name -CreateOption Attach -ManagedDiskId $sourceDiskCopy.Id -Lun $Luns.SourceDisk -Caching ReadWrite
Update-AzVM -VM $targetVm -ResourceGroupName $TargetVmResourceGroup | Out-Null
LogWriter ("Attaching the empty target disk to the target VM: LUN $($Luns.TargetDisk)")
$targetVm = Add-AzVMDataDisk -VM $targetVm -Name $targetDisk.Name -CreateOption Attach -ManagedDiskId $targetDisk.Id -Lun $Luns.TargetDisk -Caching ReadWrite
Update-AzVM -VM $targetVm -ResourceGroupName $TargetVmResourceGroup | Out-Null
# ---- Stage 1: Prepare + Capture (in-guest) ---------------------------------
# Optional: top up auth so we start with a healthy token window
LogWriter ('Run the first part of the converting process on the target VM - this can last hours')
LogWriter ('Submitting RunCommand (PrepareAndCaptureImage) to $TargetVmName...')
LogWriter ('Hint: first few polls may show no messages yet while DISM warms up. Once Capture.log appears you will see PROGRESS lines.')
# kickoff
$rc1 = Invoke-AzOpWithReauth `
-OperationName "RunCommand PrepareAndCaptureImage" `
-TenantId $TenantId -SubscriptionId $SubscriptionId `
-Script {
Invoke-VMRun -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName `
-ScriptPath $ScriptPath -Parameters @{ mode = 'PrepareAndCaptureImage' }
}
# Immediately show what RunCommand returned (both streams), flattened
if ($rc1 -and $rc1.Value) {
$flat = $rc1.Value | ForEach-Object {
$msg = ($_.Message -replace '\r?\n', ' ')
"[{0}] {1} :: {2}" -f $_.Code, $_.DisplayStatus, $msg
}
LogWriter ("Kickoff RC" + ($flat -join ' || '))
} else {
LogWriter ('Kickoff RC <no values>')
}
# poll
$completed = $false; $failed = $false
do {
Start-Sleep -Seconds $SleepSecs
LogWriter ('Waking up and testing Internet connectivity.')
$wait = Test-Connectivity -SingleHost $ProbeHost -Port 443 -TimeoutSeconds $ProbeTimeoutSeconds -PollSeconds $ProbePollSeconds
if (-not $wait.AllPassed) {
LogWriter 'Network still down after $ProbeTimeoutSeconds seconds. Aborting this cycle.'
continue
}
try {
LogWriter ('Confirmed Internet connectivity. Getting task progress on: $($TargetVmName).')
$rv = Invoke-AzOpWithReauth `
-OperationName "RunCommand CheckTaskProgress" `
-TenantId $TenantId -SubscriptionId $SubscriptionId `
-Script {
Invoke-VMRun -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName `
-ScriptPath $ScriptPath -Parameters @{ mode = 'CheckTaskProgress' }
}
# pull the message text that our in-guest script writes with Write-Host
$msg = $null
if ($rv -and $rv.Value -and $rv.Value.Count -gt 0) {
$msg = (($rv.Value | ForEach-Object { $_.Message }) -join "`n").Trim()
}
if ([string]::IsNullOrWhiteSpace($msg)) {
LogWriter ('Progress: No messages yet')
} else {
# one-line the message to keep the log tidy
$msgOneLine = ($msg -replace '\r?\n', ' ')
LogWriter ("Progress: $msgOneLine")
}
if ($msg -match '###:COMPLETE:###') {
$completed = $true
LogWriter ('The remote operation completed')
}
} catch {
LogWriter ("The remote operation failed: $_")
$failed = $true
$completed = $true
}
LogWriter ('Run the first part of the converting process on the target VM - this can last hours')
} while (-not $completed)
# ---- Stage 2: Apply (in-guest) ---------------------------------------------
if (-not $failed) {
LogWriter ('Run the second part of the converting process on the target VM - this can last hours')
LogWriter ("Submitting RunCommand (ApplyCapturedImage) to $TargetVmName...")
$rc2 = Invoke-AzOpWithReauth `
-OperationName "RunCommand ApplyCapturedImage" `
-TenantId $TenantId -SubscriptionId $SubscriptionId `
-Script {
Invoke-VMRun -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName `
-ScriptPath $ScriptPath -Parameters @{ mode = 'ApplyCapturedImage' }
}
if ($rc2 -and $rc2.Value -and $rc2.Value.Count -gt 0) {
$msg2 = $rc2.Value[0].Message
if ([string]::IsNullOrWhiteSpace($msg2)) {
LogWriter ('RunCommand returned with no message stage bootstrap may be backgrounded.')
} else {
$preview2 = if ($msg2.Length -gt 500) { $msg2.Substring(0, 500) + ' ...' } else { $msg2 }
LogWriter ("RunCommand output: $preview2")
if ($msg2 -match '###:STARTED:###') { LogWriter ('Confirmed: Stage ApplyCapturedImage has STARTED in-guest.') }
}
} else {
LogWriter ('RunCommand returned no values; proceeding to polling loop.')
}
$completed = $false; $failed = $false
do {
Start-Sleep -Seconds $SleepSecs
$wait = Test-Connectivity -SingleHost $ProbeHost -Port 443 -TimeoutSeconds $ProbeTimeoutSeconds -PollSeconds $ProbePollSeconds
if (-not $wait.AllPassed) {
LogWriter ("Network still down after $ProbeTimeoutSeconds seconds. Aborting this cycle.")
continue
}
try {
LogWriter ('Confirmed Internet connectivity. Getting task progress.')
$rv = Invoke-AzOpWithReauth `
-OperationName "RunCommand CheckTaskProgress" `
-TenantId $TenantId -SubscriptionId $SubscriptionId `
-Script {
Invoke-VMRun -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName `
-ScriptPath $ScriptPath -Parameters @{ mode = 'CheckTaskProgress' }
}
# pull the message text that our in-guest script writes with Write-Host
$msg = $null
if ($rv -and $rv.Value -and $rv.Value.Count -gt 0) {
$msg = (($rv.Value | ForEach-Object { $_.Message }) -join "`n").Trim()
}
if ([string]::IsNullOrWhiteSpace($msg)) {
LogWriter ('Progress: No messages yet')
} else {
# one-line the message to keep the log tidy
$msgOneLine = ($msg -replace '\r?\n', ' ')
LogWriter ("Progress: $msgOneLine")
}
if ($msg -match '###:COMPLETE:###') {
$completed = $true
LogWriter ('The remote operation completed"')
}
} catch {
LogWriter ("The remote operation failed: $_")
$failed = $true
$completed = $true
}
LogWriter ('Run the second part of the converting process on the target VM - this can last hours')
} while (-not $completed)
}
# ---- Stage 3: UEFI boot config (in-guest) ----------------------------------
if (-not $failed) {
LogWriter ('Run the last part of the converting process on the target VM')
LogWriter ("Submitting RunCommand ConfigureUefiBoot to $TargetVmName...")
$rc3 = Invoke-AzOpWithReauth `
-OperationName "RunCommand ConfigureUefiBoot" `
-TenantId $TenantId -SubscriptionId $SubscriptionId `
-Script {
Invoke-VMRun -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName `
-ScriptPath $ScriptPath -Parameters @{ mode = 'ConfigureUefiBoot' }
}
if ($rc3 -and $rc3.Value -and $rc3.Value.Count -gt 0) {
$msg3 = $rc3.Value[0].Message
if ([string]::IsNullOrWhiteSpace($msg3)) {
LogWriter ('RunCommand returned with no message this can be normal for bcdboot.')
} else {
$preview3 = if ($msg3.Length -gt 500) { $msg3.Substring(0, 500) + ' ...' } else { $msg3 }
LogWriter "RunCommand output: $preview3"
if ($msg3 -match '###:BOOTCFG:OK:###') { LogWriter ("Confirmed: Stage ConfigureUefiBoot reported OK.") }
elseif ($msg3 -match '###:BOOTCFG:FAILED:###') {
LogWriter ('Stage ConfigureUefiBoot reported FAILURE. Cleanup will be skipped later if boot is not confirmed.')
$failed = $true
}
}
} else {
LogWriter ('RunCommand returned no values for ConfigureUefiBoot this can be normal.')
}
}
# ---- Disk swap -> Boot -> Verify -> Cleanup -----------------------------
if (-not $failed) {
LogWriter ('Deallocate the target VM')
Stop-AzVM -ResourceGroupName $TargetVmResourceGroup -Name $TargetVmName -Force | Out-Null
LogWriter ('Detach the copied source disk')
$targetVm = Remove-AzVMDataDisk -VM $targetVm -Name $sourceDiskCopy.Name
Update-AzVM -VM $targetVm -ResourceGroupName $TargetVmResourceGroup | Out-Null
LogWriter ('Detach the target disk')
$targetVm = Remove-AzVMDataDisk -VM $targetVm -Name $targetDisk.Name
Update-AzVM -VM $targetVm -ResourceGroupName $TargetVmResourceGroup | Out-Null
LogWriter ('Swap OS disk to the converted Gen2 disk')
$targetVm = Get-AzVM -ResourceGroupName $TargetVmResourceGroup -Name $TargetVmName
$targetVm = Set-AzVMOSDisk -VM $targetVm -ManagedDiskId $targetDisk.Id -Name $targetDisk.Name
Update-AzVM -VM $targetVm -ResourceGroupName $TargetVmResourceGroup | Out-Null
LogWriter ('Starting the converted target VM')
Start-AzVM -ResourceGroupName $TargetVmResourceGroup -Name $TargetVmName | Out-Null
# Hard gate: only cleanup when we *really* see Windows booted.
$boot = Wait-ForWindowsBoot -ResourceGroupName $TargetVmResourceGroup -VmName $TargetVmName -PowerTimeoutSeconds $PowerWaitSeconds -GuestTimeoutSeconds $GuestWaitSeconds -PollSeconds $GuestPollSeconds
if (-not $boot.Confirmed) {
LogWriter ("Skipping cleanup; VM not confirmed booted to Windows. Stage=$($boot.Stage). Detail=$($boot.Detail)")
return
}
# Now itโ€™s safe to remove artifacts from this conversion run.
LogWriter ("Cleaning up confirmed Windows $($boot.Detail)")
if (-not $sourceDiskSnap) { $sourceDiskSnap = Get-AzSnapshot -ResourceGroupName $TargetVmResourceGroup -SnapshotName $Names.SnapshotName -ErrorAction SilentlyContinue }
if (-not $sourceDiskCopy) { $sourceDiskCopy = Get-AzDisk -ResourceGroupName $TargetVmResourceGroup -DiskName $Names.SourceCopyDisk -ErrorAction SilentlyContinue }
if (-not $targetDiskOrg) { $targetDiskOrg = Get-AzDisk -ResourceGroupName $TargetVmResourceGroup -DiskName $targetVm.StorageProfile.OsDisk.Name -ErrorAction SilentlyContinue }
if ($sourceDiskSnap) { Invoke-Remove { Remove-AzSnapshot -ResourceGroupName $TargetVmResourceGroup -SnapshotName $Names.SnapshotName -Force -ErrorAction Stop } "snapshot '$($Names.SnapshotName)'" }
if ($sourceDiskCopy) { Invoke-Remove { Remove-AzDisk -ResourceGroupName $TargetVmResourceGroup -DiskName $Names.SourceCopyDisk -Force -ErrorAction Stop } "disk '$($Names.SourceCopyDisk)'" }
if ($targetDiskOrg) { Invoke-Remove { Remove-AzDisk -ResourceGroupName $TargetVmResourceGroup -DiskName $targetDiskOrg.Name -Force -ErrorAction Stop } "original temp OS disk '$($targetDiskOrg.Name)'" }
LogWriter ("We are ready. The new Gen2 VM $TargetVmName is ready and is a copy of the original VM.")
} else {
LogWriter ("Error: Something went wrong; stopping for debugging. Remember to clean up manually (disks, snapshots, VM).")
}
}
#endregion MainApp
#region In-Guest Stages
# ---------------------
# The following blocks only run inside the helper VM via RunCommand. They perform the disk work.
# Stage 1: Prepare partitions on the Gen2 target disk and capture the source volume into a WIM.
if ($mode -eq "PrepareAndCaptureImage") {
LogWriter('###:STARTED:### PrepareAndCaptureImage')
Write-Host '###:STARTED:### PrepareAndCaptureImage'
LogWriter('Preparing local VM')
# --- 0) Extend the Windows partition ----------------
try {
$defragSvc = Get-Service -Name defragsvc -ErrorAction SilentlyContinue
Set-Service -Name defragsvc -StartupType Manual -ErrorAction SilentlyContinue
$supportedSize = (Get-PartitionSupportedSize -DriveLetter "c" -ErrorAction Stop)
if ((Get-Partition -DriveLetter "c").Size -lt $supportedSize.SizeMax) {
LogWriter("Resize C: partition to fill up the disk")
Resize-Partition -DriveLetter "c" -Size $supportedSize.SizeMax
}
Set-Service -Name defragsvc -StartupType $defragSvc.StartType -ErrorAction SilentlyContinue
}
catch {
LogWriter("Resize C: partition failed: $_")
}
# --- 1) Resolve disks by Azure LUN (robust across reboots) ----------------
$lunTargetPattern = ("*&{0:D6}#*" -f $Luns.TargetDisk)
$lunSourcePattern = ("*&{0:D6}#*" -f $Luns.SourceDisk)
$targetDisk = Get-Disk | Where-Object { $_.Path -like $lunTargetPattern } | Select-Object -First 1
$sourceDisk = Get-Disk | Where-Object { $_.Path -like $lunSourcePattern } | Select-Object -First 1
# Fallback patterns (historic)
if (-not $targetDisk) { $targetDisk = Get-Disk | Where-Object { $_.Path -like '*&000007#*' } | Select-Object -First 1 }
if (-not $sourceDisk) { $sourceDisk = Get-Disk | Where-Object { $_.Path -like '*&000006#*' } | Select-Object -First 1 }
if (-not $targetDisk -or -not $sourceDisk) { throw "Could not resolve source/target disks by LUN." }
LogWriter( ("Resolved disks: source=#{0} path='{1}'; target=#{2} path='{3}'" -f $sourceDisk.Number, $sourceDisk.Path, $targetDisk.Number, $targetDisk.Path) )
Write-Host ( "INFO|ResolvedDisks src=#{0} tgt=#{1}" -f $sourceDisk.Number, $targetDisk.Number )
# Ensure both disks are online & read/write (call Set-Disk with disjoint parameter sets)
foreach ($d in @($sourceDisk, $targetDisk)) {
try {
if ($d.IsReadOnly) { Set-Disk -Number $d.Number -IsReadOnly $false -ErrorAction Stop }
if ($d.IsOffline) { Set-Disk -Number $d.Number -IsOffline $false -ErrorAction Stop }
} catch {
LogWriter( ("WARN: Could not online/unlock disk #{0}: {1}" -f $d.Number, $_.Exception.Message) )
}
}
$targetDiskNumber = [int]$targetDisk.Number
$sourceDiskNumber = [int]$sourceDisk.Number
# --- 2) Ensure target partitions exist with correct GPT types --------------
$GPT_EFI = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' # EFI System
$GPT_RECOVERY = '{de94bba4-06d1-4d40-a16a-bfd50179d6ac}' # Windows Recovery
$GPT_BASIC = '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' # Basic Data
# If disk was never initialized, do it now (safe if already GPT)
$td = Get-Disk -Number $targetDiskNumber
if ($td.PartitionStyle -eq 'RAW') {
LogWriter('Initializing target disk as GPT')
Initialize-Disk -Number $targetDiskNumber -PartitionStyle GPT -ErrorAction Stop
}
# Probe existing partitions by GPT type
$parts = Get-Partition -DiskNumber $targetDiskNumber -ErrorAction SilentlyContinue
$pUEFI = $parts | Where-Object { $_.GptType -eq $GPT_EFI } | Select-Object -First 1
$pREC = $parts | Where-Object { $_.GptType -eq $GPT_RECOVERY } | Select-Object -First 1
$pWIN = $parts | Where-Object { $_.GptType -eq $GPT_BASIC } | Sort-Object Size -Descending | Select-Object -First 1
if (-not $pUEFI) {
LogWriter('Create UEFI partition')
$pUEFI = New-Partition -DiskNumber $targetDiskNumber -Size ($PartitionSizes.UefiMB * 1MB) -GptType $GPT_EFI -IsHidden
}
if (-not $pREC) {
LogWriter('Create recovery partition')
$pREC = New-Partition -DiskNumber $targetDiskNumber -Size ($PartitionSizes.RecoveryMB * 1MB) -GptType $GPT_RECOVERY -IsHidden
@"
select disk $targetDiskNumber
select partition $($pREC.PartitionNumber)
gpt attributes=0x8000000000000001
exit
"@ | diskpart.exe | Out-Null
}
if (-not $pWIN) {
LogWriter('Create Windows partition')
$pWIN = New-Partition -DiskNumber $targetDiskNumber -UseMaximumSize -GptType $GPT_BASIC
}
# --- 3) Assign letters: S=source OS, U=EFI, R=Recovery, T=target OS --------
LogWriter('Assign/mount partition letters')
Write-Host 'INFO|AssignLetters S,U,R,T'
# Helper to (re)assign a letter idempotently
function Use-DriveLetter {
param([Parameter(Mandatory)]$Partition, [Parameter(Mandatory)][char]$Letter)
try {
$existing = Get-Partition -DiskNumber $Partition.DiskNumber -PartitionNumber $Partition.PartitionNumber
# Remove any wrong/multiple paths
foreach ($p in ($existing.AccessPaths | Where-Object { $_ -notlike "$($Letter):\" })) {
Remove-PartitionAccessPath -DiskNumber $existing.DiskNumber -PartitionNumber $existing.PartitionNumber -AccessPath $p -ErrorAction SilentlyContinue
}
# Make sure the desired path exists
if (-not ($existing.AccessPaths -contains "$($Letter):\")) {
# If the letter is taken by some other volume, free it
$taken = Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveLetter -eq $Letter }
if ($taken) {
Set-Partition -DriveLetter $Letter -NewDriveLetter ([char]0) -ErrorAction SilentlyContinue | Out-Null
}
Add-PartitionAccessPath -DiskNumber $existing.DiskNumber -PartitionNumber $existing.PartitionNumber -AccessPath "$($Letter):\"
}
} catch {
LogWriter( ("WARN: Could not assign drive letter {0}: {1}" -f $Letter, $_.Exception.Message) )
}
}
# Source OS = S:\ (we assume Gen1 OS partition is #2 most of the time; fall back by largest NTFS if needed)
$srcParts = Get-Partition -DiskNumber $sourceDiskNumber -ErrorAction SilentlyContinue
$pSRC = $srcParts | Where-Object { $_.PartitionNumber -eq 2 } | Select-Object -First 1
if (-not $pSRC) {
$pSRC = ($srcParts | ForEach-Object { $_ | Add-Member -NotePropertyName SizeBytes -NotePropertyValue ((Get-Volume -Partition $_ -ErrorAction SilentlyContinue).Size) -PassThru } |
Sort-Object SizeBytes -Descending | Select-Object -First 1)
}
if (-not $pSRC) { throw "Could not identify source OS partition on disk #$sourceDiskNumber." }
Use-DriveLetter -Partition $pSRC -Letter 'S'
Use-DriveLetter -Partition $pUEFI -Letter 'U'
Use-DriveLetter -Partition $pREC -Letter 'R'
Use-DriveLetter -Partition $pWIN -Letter 'T'
Write-Host '###:PARTITIONS_READY:### S=\ U=\ R=\ T=\'
# --- 4) Ensure filesystems: U(FAT32), R(NTFS), T(NTFS) --------------------
LogWriter('Ensure filesystems on U:, R:, T:')
Write-Host 'Ensure filesystems on U, R, and T:'
# U: (EFI) must be FAT32
$vu = Get-Volume -DriveLetter U -ErrorAction SilentlyContinue
if (-not $vu -or $vu.FileSystem -ne 'FAT32') {
LogWriter('Formatting U: as FAT32 (EFI)')
Format-Volume -DriveLetter U -FileSystem FAT32 -NewFileSystemLabel 'SYSTEM' -Confirm:$false -Force | Out-Null
}
# R: (Recovery) must be NTFS
$vr = Get-Volume -DriveLetter R -ErrorAction SilentlyContinue
if (-not $vr -or $vr.FileSystem -ne 'NTFS') {
LogWriter('Formatting R: as NTFS (Recovery)')
Format-Volume -DriveLetter R -FileSystem NTFS -NewFileSystemLabel 'Recovery' -Confirm:$false -Force | Out-Null
}
# T: (Windows target) must be NTFS
$vt = Get-Volume -DriveLetter T -ErrorAction SilentlyContinue
if (-not $vt -or $vt.FileSystem -ne 'NTFS') {
LogWriter('Formatting T: as NTFS (Windows System Drive)')
Format-Volume -DriveLetter T -FileSystem NTFS -NewFileSystemLabel 'Windows System Drive' -Confirm:$false -Force | Out-Null
}
# Quick sanity
Get-Volume | Where-Object { $_.DriveLetter -in @('S', 'T', 'U', 'R') } |
ForEach-Object { LogWriter( ("VOL {0}: FS={1} Size={2}" -f $_.DriveLetter, $_.FileSystem, $_.Size) ) }
Write-Host '###:FS_READY:### U=FAT32 R=NTFS T=NTFS'
# --- Defender tuning (best-effort; safe to skip if Defender isn't present) ---
try {
Write-Host 'Tuning Defender'
$svc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue
if ($svc) {
LogWriter('Tuning Microsoft Defender for imaging/apply workload')
# Keep Defender from hogging CPU during DISM
try { Set-MpPreference -ScanAvgCPULoadFactor 5 } catch {}
# Prefer cmdlet-based exclusions when available
$added = $false
if (Get-Command -Name Add-MpPreference -ErrorAction SilentlyContinue) {
try {
Add-MpPreference -ExclusionPath 'S:\', 'T:\' -ErrorAction Stop
$added = $true
LogWriter('Added Defender ExclusionPath for S:\ and T:\')
} catch {
LogWriter(("WARN: Add-MpPreference failed: {0}" -f $_.Exception.Message))
}
}
# Fallback: write policy keys (often honored immediately on Server 2022)
if (-not $added) {
LogWriter('Falling back to registry-based Defender exclusions')
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender\Exclusions' -Name 'Paths' -Force -ErrorAction Ignore | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender\Exclusions\Paths' -Name 'S:\' -Value 0 -Force -ErrorAction Ignore | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender\Exclusions\Paths' -Name 'T:\' -Value 0 -Force -ErrorAction Ignore | Out-Null
New-Item -Path 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Exclusions' -Name 'Paths' -Force -ErrorAction Ignore | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Exclusions\Paths' -Name 'S:\' -Value 0 -Force -ErrorAction Ignore | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Exclusions\Paths' -Name 'T:\' -Value 0 -Force -ErrorAction Ignore | Out-Null
}
} else {
LogWriter('Defender service not present/running; skipping Defender tuning')
}
} catch {
LogWriter(("WARN: Defender tuning skipped: {0}" -f $_.Exception.Message))
}
# --- 5) Make DISM chatty & fast ------------------------------------------
New-Item -ItemType Directory -Path 'C:\Gen2Logs' -Force | Out-Null
New-Item -ItemType Directory -Path 'C:\Gen2Scratch' -Force | Out-Null
$wimScript = @"
[ExclusionList]
\Windows\SoftwareDistribution\Download\*
\Windows\Temp\*
\Users\*\AppData\Local\Temp\*
\Users\*\OneDrive\*
\Users\*\*.tmp
\pagefile.sys
\hiberfil.sys
\swapfile.sys
[CompressionExclusionList]
"@
Set-Content -Path 'C:\Gen2Logs\WimScript.ini' -Value $wimScript -Encoding ASCII
# --- 6) Launch DISM capture (S: -> C:\Capture.wim) ------------------------
$capArgs = @(
'/Capture-Image',
'/ImageFile:"C:\Capture.wim"',
'/CaptureDir:S:\',
'/Name:Captured',
'/Compress:fast',
'/CheckIntegrity',
'/LogLevel:4',
'/LogPath:"C:\Gen2Logs\Capture.log"',
'/ScratchDir:"C:\Gen2Scratch"',
'/ConfigFile:"C:\Gen2Logs\WimScript.ini"'
) -join ' '
LogWriter( ("Starting DISM capture: dism.exe {0}" -f $capArgs) )
Write-Host '###:CAPTURE_LAUNCHING:###'
Start-Process -FilePath dism.exe -ArgumentList $capArgs | Out-Null
Start-Sleep -Seconds $SleepSecs
}
# Stage 2: Apply the WIM to T:\ (the Windows partition on the target Gen2 disk).
if ($mode -eq "ApplyCapturedImage") {
LogWriter ('###:STARTED:### ApplyCapturedImage')
LogWriter ('Starting DISM to apply image to the new Windows partition on the Gen2 disk')
# Validate WIM first
$chk = Test-WimHealthy -Path 'C:\Capture.wim'
if (-not $chk.Healthy) {
LogWriter(("Stage 2 aborted: invalid WIM. Reason={0} SizeMB={1}" -f $chk.Reason, $chk.SizeMB))
Write-Host "###:WIM:INVALID:### reason=$($chk.Reason); sizeMB=$($chk.SizeMB)"
return
}
LogWriter(("WIM looks good (SizeMB={0}, Indexes={1}). Proceeding to applyโ€ฆ" -f $chk.SizeMB, $chk.Indexes))
Write-Host ("###:WIM:OK:### sizeMB={0}; indexes={1}" -f $chk.SizeMB, $chk.Indexes)
# --- Re-resolve disks from LUNs (fresh process) ---
$lunTargetPattern = ("*&{0:D6}#*" -f $Luns.TargetDisk)
$lunSourcePattern = ("*&{0:D6}#*" -f $Luns.SourceDisk)
$targetDisk = Get-Disk | Where-Object { $_.Path -like $lunTargetPattern } | Select-Object -First 1
$sourceDisk = Get-Disk | Where-Object { $_.Path -like $lunSourcePattern } | Select-Object -First 1
if (-not $targetDisk) { $targetDisk = Get-Disk | Where-Object { $_.Path -like "*&000007#*" } | Select-Object -First 1 }
if (-not $sourceDisk) { $sourceDisk = Get-Disk | Where-Object { $_.Path -like "*&000006#*" } | Select-Object -First 1 }
if (-not $targetDisk -or -not $sourceDisk) {
LogWriter ("ERROR: Could not resolve source/target disks by LUN.")
Write-Host '###:COMPLETE:###'
return
}
foreach ($d in @($sourceDisk, $targetDisk)) {
try {
if ($d.IsReadOnly) { Set-Disk -Number $d.Number -IsReadOnly $false -ErrorAction Stop }
if ($d.IsOffline) { Set-Disk -Number $d.Number -IsOffline $false -ErrorAction Stop }
} catch {
LogWriter ("WARN: Could not online/unlock disk #{0}: {1}" -f $d.Number, $_.Exception.Message)
}
}
# --- Re-mount the same access paths U:\, R:\, T:\ (idempotent) ---
try {
$uefiPart = (Get-Partition -DiskNumber $targetDisk.Number | Where-Object GptType -eq "{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}" | Select-Object -First 1)
$recoveryPart = (Get-Partition -DiskNumber $targetDisk.Number | Where-Object GptType -eq "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" | Select-Object -First 1)
$windowsPart = (Get-Partition -DiskNumber $targetDisk.Number | Where-Object { -not $_.GptType -and $_.Type -eq 'Basic' } | Sort-Object -Property Size -Descending | Select-Object -First 1)
if ($uefiPart) { Add-PartitionAccessPath -DiskNumber $targetDisk.Number -PartitionNumber $uefiPart.PartitionNumber -AccessPath 'U:\' -ErrorAction SilentlyContinue }
if ($recoveryPart) { Add-PartitionAccessPath -DiskNumber $targetDisk.Number -PartitionNumber $recoveryPart.PartitionNumber -AccessPath 'R:\' -ErrorAction SilentlyContinue }
if ($windowsPart) { Add-PartitionAccessPath -DiskNumber $targetDisk.Number -PartitionNumber $windowsPart.PartitionNumber -AccessPath 'T:\' -ErrorAction SilentlyContinue }
} catch {
LogWriter ("WARN: Re-mount attempt had issues: $($_.Exception.Message)")
}
# Ensure T:\ exists and is NTFS
if (-not (Test-Path -LiteralPath 'T:\')) {
LogWriter ("ERROR: T:\ not mounted.")
Write-Host '###:COMPLETE:###'
return
}
try {
$volT = Get-Volume -DriveLetter T -ErrorAction Stop
if ($volT.FileSystem -ne 'NTFS') {
LogWriter ("Formatting T:\ as NTFS")
Format-Volume -DriveLetter T -FileSystem NTFS -NewFileSystemLabel 'Windows System Drive' -Confirm:$false -ErrorAction Stop
}
} catch {
LogWriter ("WARN: Could not query/format T:\ : $($_.Exception.Message)")
}
# Guard: Capture.wim must exist from Stage 1
if (-not (Test-Path -LiteralPath 'C:\Capture.wim')) {
LogWriter ("ERROR: C:\Capture.wim not found; Stage 1 may not have completed.")
Write-Host '###:COMPLETE:###'
return
}
# --- Run DISM Apply (chatty logs/scratch) ---
New-Item -ItemType Directory -Path "C:\Gen2Logs" -Force | Out-Null
New-Item -ItemType Directory -Path "C:\Gen2Scratch" -Force | Out-Null
$appArgs = @(
'/Apply-Image',
'/ImageFile:"C:\Capture.wim"',
'/ApplyDir:T:\',
'/Index:1',
'/CheckIntegrity',
'/LogLevel:4',
'/LogPath:"C:\Gen2Logs\Apply.log"',
'/ScratchDir:"C:\Gen2Scratch"'
) -join ' '
# Start apply (orchestrator will poll for DISM.exe)
Start-Process -FilePath Dism.exe -ArgumentList $appArgs | Out-Null
Start-Sleep -Seconds 3
}
# Probe: return PROGRESS while DISM is running, and COMPLETE when it isnโ€™t.
if ($mode -eq 'CheckTaskProgress') {
try {
$dism = Get-Process -Name DISM -ErrorAction SilentlyContinue
} catch { $dism = $null }
# Prefer Apply.log if present (Stage 2), else Capture.log (Stage 1)
$logPath = $null
foreach ($p in @('C:\Gen2Logs\Apply.log', 'C:\Gen2Logs\Capture.log')) {
if (Test-Path -LiteralPath $p) { $logPath = $p; break }
}
$wim = Get-Item -LiteralPath 'C:\Capture.wim' -ErrorAction SilentlyContinue
if ($dism) {
$sizeMB = if ($wim) { [math]::Round($wim.Length / 1MB, 1) } else { 0 }
$cpuSec = [math]::Round(($dism | Measure-Object CPU -Sum).Sum, 1)
# Best-effort IO snapshot (may be null on some SKUs)
$wmio = $null
try { $wmio = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "Name='dism'" } catch {}
$ioR = if ($wmio) { $wmio.IOReadOperationsPersec } else { $null }
$ioW = if ($wmio) { $wmio.IOWriteOperationsPersec } else { $null }
# Tail last lines from whichever log we found
$tail = ''
if ($logPath) {
try {
$tail = ((Get-Content -LiteralPath $logPath -Tail 3 -ErrorAction SilentlyContinue) -join ' ') -replace '\s+', ' '
} catch {}
}
# Machine-parsable single line for the orchestrator
Write-Host ("###:PROGRESS:### sizeMB={0}; cpuSec={1}; ioRps={2}; ioWps={3}; log='{4}'" -f $sizeMB, $cpuSec, $ioR, $ioW, $tail)
} else {
# No DISM process; assume the current stage has finished
Write-Host '###:COMPLETE:###'
}
return
}
# Stage 3: bcdboot to lay down UEFI boot assets on U:\EFI\Microsoft\Boot and validate presence.
if ($mode -eq "ConfigureUefiBoot") {
# Do NOT throw; record failure with a marker so the outer flow can skip cleanup.
$failed = $false
$bcdBootPath = "T:\Windows\System32\bcdboot.exe"
$winDir = "T:\Windows"
$uefiMount = "U:\"
$uefiBootDir = "U:\EFI\Microsoft\Boot"
LogWriter ('Stage 3 - UEFI configuration starting.')
# Prechecks: presence of T:\Windows, bcdboot, and U:\ mount (optionally verify volume label).
if (-not (Test-Path -LiteralPath $winDir)) {
LogWriter ("ERROR: Expected Windows directory not found at '$winDir'.")
$failed = $true
}
if (-not (Test-Path -LiteralPath $bcdBootPath)) {
LogWriter ("ERROR: bcdboot not found at '$bcdBootPath'.")
$failed = $true
}
if (-not (Test-Path -LiteralPath $uefiMount)) {
LogWriter ("ERROR: UEFI partition mount '$uefiMount' not found.")
$failed = $true
} else {
try {
$uefiVol = Get-Volume -FileSystemLabel 'SYSTEM' -ErrorAction SilentlyContinue
if (-not $uefiVol) { LogWriter ('WARN: Could not locate a volume labeled SYSTEM (expected for UEFI). Proceeding.') }
} catch { LogWriter ("WARN: Get-Volume check failed: $($_.Exception.Message)") }
}
if ($failed) {
LogWriter ('Stage 3 prechecks failed - skipping bcdboot.')
Write-Host '###:BOOTCFG:FAILED:### precheck'
return
}
# Run bcdboot and capture exit code + redirected logs for later inspection.
$args = "T:\Windows /s U: /f UEFI"
LogWriter ("Running bcdboot: $bcdBootPath $args")
try {
$p = Start-Process -FilePath $bcdBootPath `
-ArgumentList $args `
-WorkingDirectory "T:\Windows\System32" `
-NoNewWindow `
-PassThru `
-Wait `
-RedirectStandardOutput "C:\Windows\Logs\bcdboot.out.log" `
-RedirectStandardError "C:\Windows\Logs\bcdboot.err.log"
$exit = $p.ExitCode
} catch {
LogWriter ("ERROR: Failed to start bcdboot: $($_.Exception.Message)")
$failed = $true
$exit = -1
}
if (-not $failed -and $exit -ne 0) {
LogWriter ("ERROR: bcdboot exited with code $exit. See C:\Windows\Logs\bcdboot.err.log")
$failed = $true
}
# Post-checks: confirm BCD and bootmgfw.efi exist under the EFI path.
if (-not $failed) {
$expected = @(
(Join-Path $uefiBootDir "BCD"),
(Join-Path $uefiBootDir "bootmgfw.efi")
)
$missing = @()
foreach ($path in $expected) { if (-not (Test-Path -LiteralPath $path)) { $missing += $path } }
if ($missing.Count -gt 0) {
LogWriter ("ERROR: UEFI boot artifacts missing: {0}" -f ($missing -join ", "))
$failed = $true
} else {
LogWriter ('UEFI boot artifacts present.')
}
}
# Marker for the orchestrator; keep cleanup decisions in the outer flow.
if ($failed) {
LogWriter ('Stage 3 bcdboot failed. Skipping automatic cleanup outer workflow will detect boot readiness.')
Write-Host "###:BOOTCFG:FAILED:### exit=$exit"
return
}
LogWriter ('Stage 3 bcdboot completed successfully.')
Write-Host "###:BOOTCFG:OK:###"
}
#endregion In-Guest Stages
@patricklegault

Copy link
Copy Markdown

the script works well but there is something that should be done
around Line 868

  • Current: $vmConfig = New-AzVMConfig -VMName $TargetVmName -VMSize $sourceVm.HardwareProfile.VmSize
  • Updated: $vmConfig = New-AzVMConfig -VMName $TargetVmName -VMSize $sourceVm.HardwareProfile.VmSize -tags $sourceVm.Tags
    this is to address an issue when there are azure policy blocking the vm creation due to tags

Also Around line: 972 (Optional)

  • Original: LogWriter ('Confirmed Internet connectivity. Getting task progress.')
  • Updated: LogWriter ('Confirmed Internet connectivity. Getting task progress on: $TargetVmName.')

@MarcelMeurer

Copy link
Copy Markdown
Author

Hi Patrick. Thanks a lot. I added it to the script ๐Ÿ‘

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment