Skip to main content

🧪 Offline Boot Testing & Generalization of VHDX OS Images

VHDX Imaging and Azure Upload Process Guide

This guide follows the Disk2vhd imaging and Azure upload process. In networks where the .vhdx is synced to local hypervisor storage, the image must be tested offline to validate bootability before being generalized and cleaned.


1. 🔌 Boot Test in Local Staging Hypervisor

Objective:

Ensure the .vhdx image boots in a virtual machine within a completely offline staging environment.

Steps:

  1. Import the .vhdx into your hypervisor (e.g., Hyper-V, Proxmox, VMware Workstation).
  2. Create a test VM:
    • Connect the disk as the primary boot volume.
    • Ensure no internet/network adapters are connected.
  3. Power on the VM.

🏢 Special Case: Ridgebrook Client Deployment

For Ridgebrook:

  • Images stored in the Azure file share:
    • Storage account: clientosimages01e2usdtc
    • File share: client-os-images-01
  • The image should be synced to the local staging hypervisor:
    • Hypervisor name: ELDERBRAIN
    • Local path:
      D:\Virtual Hard Disks\client-os-images-01
  • Ridgebrook .vhdx images should be placed into a subfolder matching their PSA company name (e.g., ridgebrook-industrial).

Once synced, follow the standard boot validation and conversion process below.


2. 🔄 Convert MBR to GPT (If Needed)

If the VM fails to boot, the .vhdx likely uses MBR instead of GPT.

✅ Option 1: MBR2GPT (Recommended)

Steps:

mbr2gpt /validate /disk:0 /allowFullOS
mbr2gpt /convert /disk:0 /allowFullOS

Make sure the OS volume is the last volume and delete any non-essential partitions (see below).

⚠️ Option 2: gptgen (Advanced / Manual Bootloader Required)

gptgen is a third-party tool that can convert MBR to GPT when MBR2GPT fails or is unsupported (e.g., on older systems or modified layouts). However, it does not set up the bootloader automatically.

Key Points:

  • You must manually recreate the EFI partition.
  • You must manually install the UEFI bootloader using bcdboot after conversion.
  • Proceed only if MBR2GPT is not usable.
  • Always follow up gptgen with Step 4: Rebuild BCD Bootloader.

3. 📼 Cleaning Up Partitions With DiskPart

Target Partition Layout (Post‑Conversion):

Partition Purpose Size
1 EFI System 4 GB
2 Recovery 4 GB
3 OS Volume Remainder

Why fixed sizes? Microsoft has misaligned these in the past — we standardize to 10 GB for EFI and Recovery to avoid risk.

DiskPart Steps:

diskpart
list disk
select disk 0
list partition

Delete unwanted partitions (OEM, redundant recovery, etc).

Create EFI:

create partition efi size=4096
format quick fs=fat32 label="System"
assign letter=S

Create Recovery (optional):

create partition primary size=4096
format quick fs=ntfs label="Recovery"
assign letter=R

4. 🥾 Rebuild BCD Bootloader

bcdboot C:\Windows /s S: /f UEFI
  • C: = OS path
  • S: = EFI partition
  • /f UEFI = Force GPT boot

5. 📸 Create VM Checkpoint Before Generalization

⚠️ CRITICAL STEP: After all manual preparation is complete and BEFORE running the automated script or final sysprep command, create a checkpoint/snapshot of your VM. This allows you to restore and retry if generalization fails.

Steps:

  1. In your hypervisor management console, create a checkpoint/snapshot.
  2. Name it appropriately (e.g., "Pre-Sysprep-Checkpoint").
  3. This allows you to restore and retry if sysprep fails.

6. ⚙️ Automated Preparation & Generalization

This section provides the comprehensive PowerShell script that performs all the final cleanup and generalization steps.

🚀 AUTOMATED OPTION: Complete PowerShell Script

For a fully automated approach that handles all cleanup steps including the sysprep generalization loop with AppxPackage removal, you can use this comprehensive script:

# Complete-WindowsImagePrep.ps1
# Comprehensive Windows Image Preparation Script
# Combines all cleanup, generalization, and AppxPackage removal steps
# Run as Administrator

param(
    [switch]$SkipUserCleanup,
    [switch]$SkipAgentCleanup,
    [switch]$SkipLogCleanup
)

function Write-StepHeader {
    param([string]$Title)
    Write-Host ""
    Write-Host "=" * 60 -ForegroundColor Cyan
    Write-Host $Title -ForegroundColor Cyan
    Write-Host "=" * 60 -ForegroundColor Cyan
}

function Write-Success {
    param([string]$Message)
    Write-Host "✓ $Message" -ForegroundColor Green
}

function Write-Error {
    param([string]$Message)
    Write-Host "✗ $Message" -ForegroundColor Red
}

function Write-Warning {
    param([string]$Message)
    Write-Host "⚠ $Message" -ForegroundColor Yellow
}

function Write-Info {
    param([string]$Message)
    Write-Host "ℹ $Message" -ForegroundColor Blue
}

function Confirm-Step {
    param([string]$Message)
    do {
        $response = Read-Host "$Message (y/N)"
        $response = $response.ToLower()
    } while ($response -notin @('y', 'yes', 'n', 'no', ''))
    
    return ($response -in @('y', 'yes'))
}

function Clear-SysprepLogs {
    Write-Info "Clearing Sysprep Panther logs for fresh run..."
    try {
        $pantherPath = "C:\Windows\System32\Sysprep\Panther"
        if (Test-Path $pantherPath) {
            Remove-Item -Path "$pantherPath\*" -Force -Recurse -ErrorAction Stop
            Write-Success "Cleared Sysprep Panther logs"
        } else {
            Write-Info "Sysprep Panther directory doesn't exist yet"
        }
    } catch {
        Write-Warning "Could not clear Sysprep logs: $($_.Exception.Message)"
    }
}

function Remove-UserAccountsAndProfiles {
    if ($SkipUserCleanup) {
        Write-Warning "Skipping user cleanup (parameter specified)"
        return
    }

    Write-StepHeader "STEP 1: Remove User Accounts and Profiles"
    Write-Info "This will remove all local user accounts and profiles except:"
    Write-Info "• System accounts (Administrator, Guest, etc.)"
    Write-Info "• Current user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1])"
    
    if (-not (Confirm-Step "Continue with user cleanup?")) {
        Write-Warning "Skipped user cleanup"
        return
    }

    $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1]
    $systemAccounts = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount',$currentUser)
    $systemProfiles = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount','Public',$currentUser)
    
    # Remove user accounts
    Write-Info "Removing local user accounts..."
    $users = Get-LocalUser | Where-Object { $_.Name -notin $systemAccounts }
    if ($users.Count -eq 0) {
        Write-Success "No additional user accounts found"
    } else {
        foreach ($user in $users) {
            try {
                Remove-LocalUser -Name $user.Name -ErrorAction Stop
                Write-Success "Removed user: $($user.Name)"
            } catch {
                Write-Error "Failed to remove user: $($user.Name) - $($_.Exception.Message)"
            }
        }
    }
    
    # Remove user profiles
    Write-Info "Removing local user profiles..."
    $profileFolders = Get-ChildItem -Path 'C:\Users' -Directory | Where-Object {
        $_.Name -notin $systemProfiles
    }
    
    if ($profileFolders.Count -eq 0) {
        Write-Success "No additional user profiles found"
    } else {
        foreach ($folder in $profileFolders) {
            try {
                # Remove profile registry entry
                $profileSid = (Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -eq $folder.FullName }).SID
                if ($profileSid) {
                    Remove-CimInstance -InputObject (Get-CimInstance Win32_UserProfile | Where-Object { $_.SID -eq $profileSid }) -ErrorAction SilentlyContinue
                    Write-Info "Removed registry entry for: $($folder.Name)"
                }
                
                # Force remove profile folder
                Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction Stop
                Write-Success "Removed profile folder: $($folder.FullName)"
            } catch {
                Write-Error "Failed to remove profile: $($folder.FullName) - $($_.Exception.Message)"
            }
        }
    }
}

function Remove-ProblematicApplications {
    Write-StepHeader "STEP 2: Remove Problematic Applications"
    Write-Info "This will silently uninstall applications that interfere with sysprep:"
    Write-Info "• Veeam applications (all variants)"
    Write-Info "• SnapAgent (Blackpoint Cyber)"
    Write-Info "• These applications cause sysprep failures and must be removed"
    
    if (-not (Confirm-Step "Continue with application removal?")) {
        Write-Warning "Skipped application removal"
        return
    }

    # Function to silently uninstall applications by name pattern
    function Uninstall-ApplicationByName {
        param([string]$AppNamePattern, [string]$DisplayName)
        
        try {
            # Get applications from both 32-bit and 64-bit registry
            $apps = @()
            $regPaths = @(
                'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
            )
            
            foreach ($regPath in $regPaths) {
                try {
                    $apps += Get-ItemProperty $regPath -ErrorAction SilentlyContinue | 
                        Where-Object { $_.DisplayName -like "*$AppNamePattern*" -and $_.UninstallString }
                } catch { }
            }
            
            if ($apps.Count -eq 0) {
                Write-Info "${DisplayName}: No installations found"
                return
            }
            
            Write-Info "${DisplayName}: Found $($apps.Count) installation(s)"
            
            foreach ($app in $apps) {
                try {
                    Write-Info "  Uninstalling: $($app.DisplayName)"
                    
                    $uninstallString = $app.UninstallString
                    if ($uninstallString -like "*msiexec*") {
                        # MSI installation - extract product code and use quiet uninstall
                        if ($uninstallString -match '\{[A-F0-9\-]+\}') {
                            $productCode = $matches[0]
                            $process = Start-Process -FilePath "msiexec.exe" -ArgumentList "/x", $productCode, "/quiet", "/norestart" -Wait -PassThru -WindowStyle Hidden
                            if ($process.ExitCode -eq 0) {
                                Write-Success "    Successfully uninstalled: $($app.DisplayName)"
                            } else {
                                Write-Warning "    Uninstall completed with exit code: $($process.ExitCode)"
                            }
                        }
                    } elseif ($uninstallString -like "*uninstall*" -or $uninstallString -like "*setup*") {
                        # Try to add silent switches for common installers
                        $silentArgs = @()
                        if ($uninstallString -like "*uninstall.exe*") {
                            $silentArgs = @("/S", "/silent", "/quiet")
                        } elseif ($uninstallString -like "*setup.exe*") {
                            $silentArgs = @("/S", "/silent", "/quiet", "/uninstall")
                        }
                        
                        # Parse executable and arguments
                        if ($uninstallString -match '^"([^"]+)"(.*)$') {
                            $executable = $matches[1]
                            $existingArgs = $matches[2].Trim()
                        } else {
                            $parts = $uninstallString.Split(' ', 2)
                            $executable = $parts[0]
                            $existingArgs = if ($parts.Length -gt 1) { $parts[1] } else { "" }
                        }
                        
                        # Combine existing args with silent args
                        $allArgs = @($existingArgs.Split(' ') + $silentArgs) | Where-Object { $_ -ne "" }
                        
                        if (Test-Path $executable) {
                            $process = Start-Process -FilePath $executable -ArgumentList $allArgs -Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
                            if ($process.ExitCode -eq 0) {
                                Write-Success "    Successfully uninstalled: $($app.DisplayName)"
                            } else {
                                Write-Warning "    Uninstall completed with exit code: $($process.ExitCode)"
                            }
                        } else {
                            Write-Warning "    Uninstaller not found: $executable"
                        }
                    } else {
                        Write-Warning "    Unsupported uninstall string format: $uninstallString"
                    }
                } catch {
                    Write-Error "    Failed to uninstall $($app.DisplayName): $($_.Exception.Message)"
                }
            }
        } catch {
            Write-Error "Failed to process ${DisplayName} applications: $($_.Exception.Message)"
        }
    }
    
    # Remove Veeam applications
    Uninstall-ApplicationByName -AppNamePattern "Veeam" -DisplayName "Veeam"
    
    # Remove SnapAgent (Blackpoint Cyber)
    Uninstall-ApplicationByName -AppNamePattern "SnapAgent" -DisplayName "SnapAgent (Blackpoint)"
    Uninstall-ApplicationByName -AppNamePattern "Blackpoint" -DisplayName "Blackpoint Cyber"
}

function Clear-AgentIdentityData {
    if ($SkipAgentCleanup) {
        Write-Warning "Skipping agent cleanup (parameter specified)"
        return
    }

    Write-StepHeader "STEP 3: Clear Agent Identity Data"
    Write-Info "This will clear identity data for:"
    Write-Info "• NinjaRMM (NodeId registry value and data folder)"
    Write-Info "• Veeam (registry keys and data folder)"
    Write-Info "• Services will be preserved"
    
    if (-not (Confirm-Step "Continue with agent cleanup?")) {
        Write-Warning "Skipped agent cleanup"
        return
    }

    # Remove NinjaRMM NodeId
    try {
        Remove-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\NinjaRMM LLC\NinjaRMMAgent\Agent' -Name 'NodeId' -Force -ErrorAction Stop
        Write-Success "Removed NinjaRMM NodeId registry value"
    } catch {
        Write-Info "NinjaRMM NodeId registry value not found or already removed"
    }
    
    # Remove Veeam registry key
    try {
        Remove-Item -Path 'HKLM:\SOFTWARE\Veeam' -Recurse -Force -ErrorAction Stop
        Write-Success "Removed Veeam registry key"
    } catch {
        Write-Info "Veeam registry key not found or already removed"
    }
    
    # Remove NinjaRMM folder
    try {
        Remove-Item -Path 'C:\ProgramData\NinjaRMMAgent' -Recurse -Force -ErrorAction Stop
        Write-Success "Removed NinjaRMM data folder"
    } catch {
        Write-Info "NinjaRMM data folder not found or already removed"
    }
    
    # Remove Veeam folder
    try {
        Remove-Item -Path 'C:\ProgramData\Veeam' -Recurse -Force -ErrorAction Stop
        Write-Success "Removed Veeam data folder"
    } catch {
        Write-Info "Veeam data folder not found or already removed"
    }
}

function Disable-BitLockerVolumes {
    Write-StepHeader "STEP 4: Disable BitLocker"
    Write-Info "This will disable BitLocker on all encrypted volumes"
    Write-Warning "BitLocker decryption may take time depending on drive size"
    
    if (-not (Confirm-Step "Continue with BitLocker disable?")) {
        Write-Warning "Skipped BitLocker disable"
        return
    }

    try {
        # Get all BitLocker volumes
        $bitlockerVolumes = Get-BitLockerVolume -ErrorAction SilentlyContinue
        
        if (-not $bitlockerVolumes) {
            Write-Success "No BitLocker volumes found"
            return
        }
        
        $encryptedVolumes = $bitlockerVolumes | Where-Object { 
            $_.VolumeStatus -eq 'FullyEncrypted' -or 
            $_.VolumeStatus -eq 'EncryptionInProgress' -or
            $_.VolumeStatus -eq 'DecryptionInProgress'
        }
        
        if ($encryptedVolumes.Count -eq 0) {
            Write-Success "No encrypted BitLocker volumes found"
            return
        }
        
        Write-Info "Found $($encryptedVolumes.Count) encrypted BitLocker volume(s):"
        foreach ($volume in $encryptedVolumes) {
            Write-Info "  • $($volume.MountPoint) - Status: $($volume.VolumeStatus)"
        }
        
        foreach ($volume in $encryptedVolumes) {
            try {
                Write-Info "Disabling BitLocker on volume: $($volume.MountPoint)"
                Disable-BitLocker -MountPoint $volume.MountPoint -ErrorAction Stop
                Write-Success "BitLocker disable initiated for: $($volume.MountPoint)"
            } catch {
                Write-Error "Failed to disable BitLocker on $($volume.MountPoint): $($_.Exception.Message)"
            }
        }
        
        # Check if any volumes are still decrypting
        $decryptingVolumes = Get-BitLockerVolume | Where-Object { 
            $_.VolumeStatus -eq 'DecryptionInProgress' 
        }
        
        if ($decryptingVolumes.Count -gt 0) {
            Write-Warning "BitLocker decryption is in progress on $($decryptingVolumes.Count) volume(s)"
            Write-Info "Decryption will continue in the background"
            Write-Info "You can check status with: Get-BitLockerVolume"
        }
        
    } catch {
        Write-Error "Failed to process BitLocker volumes: $($_.Exception.Message)"
        Write-Info "BitLocker management may not be available on this system"
    }
}

function Clear-WindowsLogs {
    if ($SkipLogCleanup) {
        Write-Warning "Skipping log cleanup (parameter specified)"
        return
    }

    Write-StepHeader "STEP 5: Clear Windows Logs"
    Write-Info "This will remove:"
    Write-Info "• Major Windows Event Logs (Application, System, Security)"
    Write-Info "• Panther setup logs"
    
    if (-not (Confirm-Step "Continue with log cleanup?")) {
        Write-Warning "Skipped log cleanup"
        return
    }

    # Clear Windows event logs using wevtutil
    Write-Info "Clearing Windows Event Logs..."
    $logsToClear = @('Application', 'Security', 'Setup', 'System')
    foreach ($log in $logsToClear) {
        try {
            Write-Info "  Clearing $log log..."
            wevtutil.exe cl $log /q:true
            Write-Success "    Cleared $log log"
        } catch {
            Write-Warning "  Could not clear $log log: $($_.Exception.Message)"
        }
    }
    
    # Remove Panther logs recursively
    Write-Info "Clearing Panther logs..."
    try {
        $pantherPath = "$env:SystemRoot\Panther"
        if (Test-Path $pantherPath) {
            Remove-Item -Path "$pantherPath\*" -Recurse -Force -ErrorAction Stop
            Write-Success "Removed Panther logs"
        } else {
            Write-Info "Panther directory does not exist."
        }
    } catch {
        Write-Error "Failed to remove Panther logs: $($_.Exception.Message)"
    }
}

function Create-UnattendXml {
    Write-StepHeader "STEP 6: Create unattend.xml"
    $unattendPath = 'C:\Windows\System32\Sysprep\unattend.xml'
    
    Write-Info "Creating a new, validated unattend.xml at: $unattendPath"
    
    if (-not (Confirm-Step "Continue with unattend.xml creation?")) {
        Write-Warning "Skipped unattend.xml creation"
        return $false
    }

    $unattendContent = @'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
    xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <settings pass="specialize">
        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
            <UserAccounts>
                <LocalAccounts>
                    <LocalAccount wcm:action="add">
                        <Name>installadmin</Name>
                        <Group>Administrators</Group>
                        <Password>
                            <Value>DTC@dental2025</Value>
                            <PlainText>true</PlainText>
                        </Password>
                    </LocalAccount>
                </LocalAccounts>
            </UserAccounts>
            <AutoLogon>
                <Enabled>true</Enabled>
                <Username>installadmin</Username>
                <Password>
                    <Value>DTC@dental2025</Value>
                    <PlainText>true</PlainText>
                </Password>
            </AutoLogon>
        </component>
    </settings>
    <settings pass="oobeSystem">
        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
            <OOBE>
                <HideEULAPage>true</HideEULAPage>
                <HideLocalAccountScreen>true</HideLocalAccountScreen>
                <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
                <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
                <ProtectYourPC>1</ProtectYourPC>
                <SkipMachineOOBE>true</SkipMachineOOBE>
            </OOBE>
        </component>
    </settings>
</unattend>
'@

    try {
        $unattendContent | Set-Content -Path $unattendPath -Encoding UTF8 -ErrorAction Stop
        Write-Success "unattend.xml created successfully"
        return $true
    } catch {
        Write-Error "Failed to create unattend.xml: $($_.Exception.Message)"
        return $false
    }
}

function Get-AppxBlockers {
    $logPath = 'C:\Windows\System32\Sysprep\Panther\setuperr.log'
    
    if (!(Test-Path $logPath)) {
        return @()
    }
    
    try {
        $content = Get-Content $logPath -Raw -ErrorAction Stop
        
        # Match the actual sysprep error format for AppxPackages
        # Example: "SYSPRP Package Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe was installed for a user, but not provisioned for all users"
        $matches = [regex]::Matches($content, 'SYSPRP Package ([^\s]+) was installed for a user, but not provisioned for all users')
        
        $blockers = @()
        foreach ($match in $matches) {
            $packageFullName = $match.Groups[1].Value
            
            # Store both the full name and the base name for better removal chances
            $blockers += $packageFullName
            
            # Also try to extract just the base package name for broader matching
            # Example: Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe -> Microsoft.WidgetsPlatformRuntime
            if ($packageFullName -match '^([^_]+)') {
                $baseName = $Matches[1]
                if ($baseName -ne $packageFullName) {
                    $blockers += $baseName
                }
            }
        }
        
        # Also check for the older error format as fallback
        $legacyMatches = [regex]::Matches($content, 'Package (.*?) cannot be removed and is preventing Sysprep')
        foreach ($match in $legacyMatches) {
            $blockers += $match.Groups[1].Value
        }
        
        return ($blockers | Select-Object -Unique)
    } catch {
        Write-Warning "Could not read or parse Sysprep error log: $($_.Exception.Message)"
        return @()
    }
}

function Remove-AppxBlockers {
    param([string[]]$Blockers)
    
    Write-Info "Attempting to remove $($Blockers.Count) blocking AppxPackages..."
    
    $totalRemoved = 0
    foreach ($blocker in $Blockers) {
        $removedInLoop = $false
        try {
            # Try removing the provisioned package(s) first
            $provisionedPackages = Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like "*$blocker*" }
            if ($provisionedPackages) {
                foreach ($package in $provisionedPackages) {
                    Write-Info "  Removing provisioned package: $($package.DisplayName)"
                    Remove-AppxProvisionedPackage -Online -PackageName $package.PackageName -ErrorAction Stop
                    Write-Success "    Removed provisioned package: $($package.DisplayName)"
                    $removedInLoop = $true
                }
            }
            
            # Try removing the user-specific package(s)
            $appPackages = Get-AppxPackage -AllUsers | Where-Object { $_.Name -like "*$blocker*" -or $_.PackageFullName -like "*$blocker*" }
            if ($appPackages) {
                foreach ($package in $appPackages) {
                    Write-Info "  Removing AppxPackage: $($package.Name)"
                    Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop
                    Write-Success "    Removed AppxPackage: $($package.Name)"
                    $removedInLoop = $true
                }
            }
            
            if ($removedInLoop) {
                $totalRemoved++
            } else {
                Write-Warning "  No installed or provisioned package found for blocker: '$blocker'"
            }
        } catch {
            Write-Error "  Failed during removal for blocker '$blocker': $($_.Exception.Message)"
        }
    }
    
    Write-Info "Total packages removed in this pass: $totalRemoved"
    return $totalRemoved
}

function Run-SysprepLoop {
    Write-StepHeader "STEP 7: Run Sysprep Generalization"
    Write-Info "This will run sysprep and handle AppxPackage errors in a loop."
    
    if (-not (Test-Path 'C:\Windows\System32\Sysprep\unattend.xml')) {
        Write-Warning "unattend.xml not found. Sysprep will run without it."
        if (-not (Confirm-Step "Continue without unattend.xml?")) {
            Write-Error "Sysprep aborted."
            return
        }
    }
    
    $sysprepPath = "C:\Windows\System32\Sysprep\sysprep.exe"
    $maxAttempts = 10
    $attempt = 1
    
    while ($attempt -le $maxAttempts) {
        Write-Info "---"
        Write-Info "Sysprep Attempt #$attempt"
        Write-Info "---"
        
        Clear-SysprepLogs
        
        $arguments = @(
            "/generalize",
            "/oobe",
            "/shutdown",
            "/unattend:C:\Windows\System32\Sysprep\unattend.xml"
        )
        
        try {
            # Sysprep should shut down the machine on success. If the process exits and
            # the script continues, it means it failed.
            Write-Info "Executing Sysprep... The system should shut down on success."
            Start-Process -FilePath $sysprepPath -ArgumentList $arguments -Wait -PassThru -ErrorAction Stop
            
            # If we get here, Sysprep failed because a successful run would have shut down the PC.
            Write-Warning "Sysprep process completed without shutting down, indicating a failure."
            # Give logs a moment to be written
            Start-Sleep -Seconds 5

        } catch {
            Write-Error "Failed to execute Sysprep: $($_.Exception.Message)"
        }

        # If we are here, sysprep likely failed. Check for blockers.
        $blockers = Get-AppxBlockers
        
        if ($blockers.Count -eq 0) {
            Write-Error "Sysprep failed, but no AppxPackage blockers were found in the logs."
            Write-Info "Please check the logs manually for other errors: C:\Windows\System32\Sysprep\Panther\setuperr.log"
            break
        }
        
        Write-Warning "Sysprep failed. Found $($blockers.Count) potential blockers."
        
        $removedCount = Remove-AppxBlockers -Blockers $blockers
        if ($removedCount -eq 0) {
            Write-Error "Could not remove any of the blocking packages. Manual intervention required."
            break
        }
        
        $attempt++
        if ($attempt -gt $maxAttempts) {
            Write-Error "Reached max attempts. Sysprep failed."
        } else {
            Write-Info "Packages removed. Preparing to retry."
            if (-not (Confirm-Step "Ready to retry sysprep?")) {
                Write-Error "Sysprep retry aborted by user."
                break
            }
            Write-Info "Retrying sysprep..."
        }
    }
}

# Main execution
function Main {
    Write-StepHeader "Windows Image Preparation Script"
    Write-Info "This script will prepare a Windows image for generalization"
    Write-Info "Run as Administrator in the VM you want to generalize"
    Write-Warning "Create a VM checkpoint/snapshot before running!"
    
    if (-not (Confirm-Step "Continue with image preparation?")) {
        Write-Warning "Image preparation cancelled"
        exit 0
    }
    
    # Check if running as administrator
    $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
    if (-not $isAdmin) {
        Write-Error "This script must be run as Administrator"
        exit 1
    }
    
    try {
        # Execute all steps
        Remove-UserAccountsAndProfiles
        Remove-ProblematicApplications
        Clear-AgentIdentityData
        Disable-BitLockerVolumes
        Clear-WindowsLogs
        
        if (Create-UnattendXml) {
            Run-SysprepLoop
        } else {
            Write-Error "Cannot proceed without unattend.xml"
            exit 1
        }
        
    } catch {
        Write-Error "Script execution failed: $($_.Exception.Message)"
        exit 1
    }
}

# Run main function
Main 

7. ✅ Final Checklist

  • Booted offline on hypervisor
  • Converted to GPT with valid partitions
  • Optional: user profiles backed up and deleted
  • VM Checkpoint Created
  • Agent identities reset (binaries remain)
  • Problematic packages removed based on setuperr.log
  • unattend.xml disables OOBE + telemetry
  • Sysprep executed successfully and VM is shut down.