Functions/Invoke-WindowsImageUpdate.ps1

#requires -Version 2
function Invoke-WindowsImageUpdate
{
    <#
            .Synopsis
            Starts the process of applying updates to all (or selected) images in a Windows Image Tools BaseImages Folder
            .DESCRIPTION
            This Command updates all (or selected) the images created via Add-UpdateImage in a Windows Image Tools BaseImages folder
            New-WindowsImageToolsExample can be use to create the structrure
            .EXAMPLE
            Invoke-WindowsImageUpdate -Path C:\WITExample
            Update all the Images created with Add-UpdateImage located in C:\WITExample\BaseImages and place the resulting VHD and WIM in c:\WITExample\UpdatedImageShare
            .EXAMPLE
            Invoke-WindowsImageUpdate -Path C:\WITExample -Name 2012r2Wmf5
            Update Image named 2012r2Wmf5_Base.vhdx in C:\WITExample\BaseImages and place the resulting VHD and WIM in c:\WITExample\UpdatedImageShare
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([bool])]
    Param
    (
        # Path to the Windows Image Tools Update Folders (created via New-WindowsImageToolsExample)
        [Parameter(Mandatory = $true, 
        ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNull()]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
                    if (Test-Path $_) 
                    {
                        $true
                    }
                    else 
                    {
                        throw "Path $_ does not exist"
                    }
        })]
        [Alias('FullName')] 
        $Path,
        # Name of the Image to update
        [ValidateNotNull()]
        [ValidateNotNullOrEmpty()]
        [Alias('FriendlyName')]
        [string[]]
        $ImageName,
        
        # Reduce output file by removing feature sources
        [switch]
        $ReduceImageSize,

        # what files to export if upates are added : NONE, WIM, Both (wim and vhdx) default = both
        [ValidateSet('NONE', 'WIM', 'Both')]
        [string]
        $output = 'Both'

    )

    $ParametersToPass = @{}
    foreach ($key in ('Whatif', 'Verbose', 'Debug'))
    {
        if ($PSBoundParameters.ContainsKey($key)) 
        {
            $ParametersToPass[$key] = $PSBoundParameters[$key]
        }
    }

    #region validate input
    try
    {
        $null = Test-Path -Path "$Path\BaseImage" -ErrorAction Stop
        $null = Test-Path -Path "$Path\Resource" -ErrorAction Stop
        $null = Test-Path -Path "$Path\UpdatedImageShare" -ErrorAction Stop
        $null = Test-Path -Path "$Path\config.xml" -ErrorAction Stop
    }
    catch
    {
        throw "$Path folder structure incorrect, see New-WindowsImageToolsExample for an example"
    }
    
    if ($ImageName)
    {
        foreach ($testpath in $ImageName) 
        {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Validateing [$testpath]"
            if (-not (Test-Path -Path "$Path\BaseImage\$($testpath)_base.vhdx" ))
            
            {
                throw "$Path\BaseImage\$($testpath)_base.vhdx"
            }
        }
        $ImageList = $ImageName
    }
    else 
    {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Colecting List of Images"
        $ImageList = (Get-ChildItem -Path $Path\BaseImage\*_Base.vhdx).Name -replace '_Base.vhdx', ''
    }

    $configData = Import-Clixml -Path "$Path\config.xml"

    try
    {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Validateing VM switch config"
        $null = Get-VMSwitch -Name $configData.VmSwitch -ErrorAction Stop
    }
    catch
    {
        throw "VM Switch Configuration in $Path incorrect Set-UpdateConfig"
    }

    #endregion
    
    #region update resorces folder
    if ($pscmdlet.ShouldProcess('PowerShell Gallery', 'Download required Modules'))
    {
        if (-not (Test-Path -Path $Path\Resource\Modules)) 
        {
            $null = mkdir -Path $Path\Resource\Modules
        }
        if (-not (Get-Command Save-Module))
        {
            Write-Warning -Message 'PowerShellGet missing. you will need to download required modules from PowerShell Gallery manualy'
            Write-Warning -Message 'Required Modules : PSWindowsUpdate'
        }
        else 
        {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Geting latest PSWindowsUpdate"
            try 
            {
                # if nuget needs updating this prompts
                ### To-Do find a way to silenty update nuget ###
                $null = Save-Module -Name PSWindowsUpdate -Path $Path\Resource\Modules -Force -ErrorAction Stop @ParametersToPass
            }
            catch 
            {
                if (Test-Path -Path $Path\Resource\Modules\PSWindowsUpdate)
                {
                    Write-Warning -Message "[$($MyInvocation.MyCommand)] : PSwindowsUpdate present, but unable to download latest"
                }
                else 
                {
                    throw "unable to download PSWindowsUpdate from PowerShellGalary.com, download manualy and place in $Path\Resource\Modules "
                }
            }
        }
    }
    #endregion

    #region Process Images
    foreach ($TargetImage in $ImageList)
    { 
        if ($pscmdlet.ShouldProcess($TargetImage, 'Invoke Windows Updates on Image'))
        {
            #region setup enviroment
            $BaseImage = "$Path\BaseImage\$($TargetImage)_base.vhdx"
            $UpdateImage = "$Path\BaseImage\$($TargetImage)_Update.vhdx"
            $SysprepImage = "$Path\BaseImage\$($TargetImage)_Sysprep.vhdx"
            $OutputVhd = "$Path\UpdatedImageShare\$($TargetImage).vhdx"
            $OutputWim = "$Path\UpdatedImageShare\$($TargetImage).wim"

            $vmGeneration = 1
            $PartitionStyle = GetVHDPartitionStyle -vhd $BaseImage
            if ($PartitionStyle -eq 'GPT') 
            {
                $vmGeneration = 2
            }
            $configData = Get-UpdateConfig -Path $Path

            $vhdData = Get-VHD -Path $BaseImage
            #endregion

            #region create Diff disk
            try 
            { 
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Windows Update : New Diff Disk : Creating $UpdateImage from $BaseImage"
                $null = New-VHD -Path $UpdateImage -ParentPath $BaseImage -ErrorAction Stop @ParametersToPass
            }
            catch 
            {
                throw "error creating differencing disk $UpdateImage from $BaseImage"
            }
            #endregion

            #region Inject files
            $RunWindowsUpdateAtStartup = {
                Start-Transcript -Path $PSScriptRoot\AtStartup.log -Append
                
                $IpType = 'IPTYPEPLACEHOLDER'
                $IPAddress = 'IPADDRESSPLACEHOLDER'
                $SubnetMask = 'SUBNETMASKPLACEHOLDER'
                $Gateway = 'GATEWAYPLACEHOLDER'
                $DnsServer = 'DNSPLACEHOLDER'
                
                if (-not ($IpType -eq 'DHCP'))
                {
                    Write-Verbose -Message 'Set Network : Getting network adaptor' -Verbose
                    $adapter = Get-NetAdapter | Where-Object -FilterScript {
                        $_.Status -eq 'up'
                    }
                    
                    Write-Verbose -Message "Set Network : removing existing config on $($adaptor.Name)" -Verbose
                    If (($adapter | Get-NetIPConfiguration).IPv4Address.IPAddress) 
                    {
                        $adapter | Remove-NetIPAddress -AddressFamily $IpType -Confirm:$false
                    }
                    If (($adapter | Get-NetIPConfiguration).Ipv4DefaultGateway) 
                    {
                        $adapter | Remove-NetRoute -AddressFamily $IpType -Confirm:$false
                    }
                    
                    $params = {
                        AddressFamily = $IpType
                        IPAddress = $IPAddress
                        PrefixLength = $SubnetMask
                        DefaultGateway = $Gateway
                    }
                    Write-Verbose -Message 'Set Network : Adding settings to adaptor'
                    Write-Verbose -Message $params -Verbose
                    $adapter | New-NetIPAddress @params
                    
                    Write-Verbose "Set Network : Set DNS to $DnsServer" -Verbose
                    $adapter | Set-DnsClientServerAddress -ServerAddresses $DnsServer  
                }

                try 
                {
                    Import-Module "$env:SystemDrive\PsTemp\Modules\PSWindowsUpdate" -Force -ErrorAction Stop
                }
                catch
                {
                    Write-Error 'Unable to import update module'
                    Stop-Transcript
                    Stop-Computer -Force
                }
                
                # Run pre-update script if it exists
                if (Test-Path "$env:SystemDrive\PsTemp\PreUpdateScript.ps1") 
                {
                    Write-Verbose "Pre-Upate script : found $env:SystemDrive\PsTemp\PreUpdateScript.ps1"
                    & "$env:SystemDrive\PsTemp\PreUpdateScript.ps1"
                }

                if ((Get-WUList -verbose -NotCategory 'Language packs').Count -gt 0)
                {
                    Write-Verbose 'Windows updates : Updates needed, flaging drive as changed' -Verbose
                    Get-Date | Out-File $env:SystemDrive\PsTemp\changesMade.txt -Force
                }
                else 
                {
                    Write-Verbose 'Windows updates : No further updates' -Verbose
                
                    if(-not ($IpType -eq 'DHCP')) 
                    {
                        $adapter = Get-NetAdapter | Where-Object {
                            $_.Status -eq 'up'
                        }
                        $interface = $adapter | Get-NetIPInterface -AddressFamily $IpType

                        Write-Verbose 'Set Network : Removing static config' -Verbose
                        If ($interface.Dhcp -eq 'Disabled') 
                        {
                            If (($interface | Get-NetIPConfiguration).Ipv4DefaultGateway) 
                            {
                                $interface | Remove-NetRoute -Confirm:$false
                            }
                            $interface | Set-NetIPInterface -Dhcp Enabled
                            $interface | Set-DnsClientServerAddress -ResetServerAddresses
                        }
                    }
                    Write-Verbose 'Shuting down' -Verbose
                    ## remove self so as to not triger updates if manual mantinance required
                    Remove-Item "$env:SystemDrive\PsTemp\AtStartup.ps1"
                    Stop-Transcript
                    Stop-Computer 
                }
 
                # Apply all non-language updates
                Write-Verbose 'Windows updates : installing updates' -Verbose
                Get-WUInstall -AcceptAll -IgnoreReboot -IgnoreUserInput -NotCategory 'Language packs' -Verbose

                # Run post-update script if it exists
                if (Test-Path "$env:SystemDrive\PsTemp\PostUpdateScript.ps1") 
                {
                    Write-Verbose "Post-Update script : found $env:SystemDrive\PsTemp\PostUpdateScript.ps1"
                    & "$env:SystemDrive\PsTemp\PostUpdateScript.ps1"
                }

 
                if (Get-WURebootStatus -Silent) 
                {
                    Write-Verbose 'Windows updates : Reboot required to finish restarting' -Verbose
                } 
                else
                {
                    Write-Verbose 'Windows updates : Restarting to check for additional updates' -Verbose
                }
                Stop-Transcript
                Restart-Computer -Force
            }

            #region add configuration data into block
            $block = $RunWindowsUpdateAtStartup | Out-String -Width 400
    
            $block = $block.Replace('IPTYPEPLACEHOLDER', $configData.IpType)
            $block = $block.Replace('IPADDRESSPLACEHOLDER', $configData.IPAddress)
            $block = $block.Replace('SUBNETMASKPLACEHOLDER', $configData.SubnetMask)
            $block = $block.Replace('GATEWAYPLACEHOLDER', $configData.Gateway)
            $block = $block.Replace('DNSPLACEHOLDER', $configData.DnsServer)
            
            $RunWindowsUpdateAtStartup = [scriptblock]::Create($block)
            #endregion
            
            $CopyInUpdateFilesBlock = {
                if (-not (Test-Path -Path "$($driveLetter):\PsTemp"))
                {
                    $null = mkdir -Path "$($driveLetter):\PsTemp"
                }
                if (-not (Test-Path -Path "$($driveLetter):\PsTemp\Modules"))
                {
                    $null = mkdir -Path "$($driveLetter):\PsTemp\Modules"
                }
                $null = New-Item -Path "$($driveLetter):\PsTemp" -Name AtStartup.ps1 -ItemType 'file' -Value $RunWindowsUpdateAtStartup -Force
                cleanupFile "$($driveLetter):\PsTemp\Modules\*"
                $null = Copy-Item -Path "$Path\Resource\Modules\*" -Destination "$($driveLetter):\PsTemp\Modules\" -Recurse

                if ((Get-ChildItem "$($driveLetter):\PsTemp\Modules\PSWindowsUpdate" -File).count -eq 0)
                {
                    Write-Verbose -Message 'Sidebyside detected in PSWindowsUpdate : switching to v4 compatability'
                    $newest = (Get-ChildItem "$($driveLetter):\PsTemp\Modules\PSWindowsUpdate" -Directory | Sort-Object LastWriteTime)[0] 
                    Copy-Item -Path $newest.fullname -Destination "$($driveLetter):\PsTemp\Modules\PSWindowsUpdate_temp" -Recurse
                    cleanupFile "$($driveLetter):\PsTemp\Modules\PSWindowsUpdate"
                    Rename-Item -Path "$($driveLetter):\PsTemp\Modules\PSWindowsUpdate_temp" -NewName "$($driveLetter):\PsTemp\Modules\PSWindowsUpdate" 
                }
            }
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Windows Update : Adding PSWindowsUpdate Module to $UpdateImage"
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Windows Update : updateting AtStartup script"
            MountVHDandRunBlock -vhd $UpdateImage -block $CopyInUpdateFilesBlock 
            #endregion

            #region create vm and run updates
            createRunAndWaitVM -vhdPath $UpdateImage -vmGeneration $vmGeneration -configData $configData @ParametersToPass
            #endregion

            #region Detect results - Merge or discard.
            $checkresultsBlock = {
                Test-Path -Path "$($driveLetter):\PsTemp\ChangesMade.txt"
                Remove-Item "$($driveLetter):\PsTemp\ChangesMade.txt" -ErrorAction SilentlyContinue
            }
            $ChangesMade = MountVHDandRunBlock -vhd $UpdateImage -block $checkresultsBlock
            if ($ChangesMade)
            {
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Windows Update : Changes detected : Merging $UpdateImage into $BaseImage"
                Merge-VHD -Path $UpdateImage -DestinationPath $BaseImage @ParametersToPass
            }
            else 
            {
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] : Windows Update : No changes, discarding $UpdateImage" 
                cleanupFile $UpdateImage
            }
            #endregion
      
            if ($output -ne 'none')
            { 
                #region Sysprep if changes or missing output vhd
                if (($ChangesMade) -or (-not (Test-Path $OutputVhd)))
                {
                    try 
                    { 
                        Write-Verbose -Message "[$($MyInvocation.MyCommand)] : SysPrep : New Diff Disk : Creating $SysprepImage from $BaseImage"
                        cleanupFile $SysprepImage
                        $null = New-VHD -Path $SysprepImage -ParentPath $BaseImage -ErrorAction Stop @ParametersToPass
                    }
                    catch 
                    {
                        throw "error creating differencing disk $SysprepImage from $BaseImage"
                    }
                
      
                    $sysprepAtStartup = {
                        Start-Transcript -Path $PSScriptRoot\AtStartup.log -Append
                        # Run pre-sysprep script if it exists
                        if (Test-Path "$env:SystemDrive\PsTemp\PreSysprepScript.ps1") 
                        {
                            & "$env:SystemDrive\PsTemp\PreSysprepScript.ps1"
                        }
                    
      
                        # Remove Scedualed task
                        Write-Verbose -Message 'SysPrep : Removeing AtStartup task' -Verbose
                        if (Get-Command -Name Unregister-ScheduledTask -ErrorAction SilentlyContinue)
                        {
                            Unregister-ScheduledTask -TaskName AtStartup -Confirm:$false -Verbose
                        }
                        else 
                        {
                            schtasks.exe /delete /TN 'AtStartup' /f
                        }
                        $params = @{
                            'FilePath'             = "$ENV:SystemRoot\System32\Sysprep\Sysprep.exe"
                            'ArgumentList'         = '/generalize', '/oobe', '/shutdown'
                            'NoNewWindow'          = $true
                            'Wait'                 = $true
                            'RedirectStandardOutput' = "$($env:temp)\$($exeName)-StandardOutput.txt"
                            'RedirectStandardError' = "$($env:temp)\$($exeName)-StandardError.txt"
                            'PassThru'             = $true
                        }
      
                        Write-Verbose -Message 'SysPrep : starting Sysprep' -Verbose
                        $ret = Start-Process @params
                        Start-Sleep -Seconds 30
                        Get-Date | Out-File c:\sysprepfail.txt
                    }
      
                    $CopyInSysprepFilesBlock = {
                        $null = New-Item -Path "$($driveLetter):\PsTemp" -Name AtStartup.ps1 -ItemType 'file' -Value $sysprepAtStartup -Force
                    }
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : SysPrep : updateting AtStartup script"
                    MountVHDandRunBlock -vhd $SysprepImage -block $CopyInSysprepFilesBlock 
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : SysPrep : Creating temp vm and waiting"
                    createRunAndWaitVM -vhdPath $SysprepImage -vmGeneration $vmGeneration -configData $configData @ParametersToPass
             
                    MountVHDandRunBlock -vhd $SysprepImage -block {
                        if (Test-Path "$($driveLetter):\sysprepfail.txt")
                        {
                            throw 'Sysprep Failed!'
                        }
                    }
                
                    $CleanupVhdBlock = {
                        cleanupFile "$($driveLetter):\Unattend.xml"
                        cleanupFile "$($driveLetter):\PsTemp"
                        attrib.exe -s -h "$($driveLetter):\pagefile.sys"
                        cleanupFile "$($driveLetter):\pagefile.sys"
                        if ($ReduceImageSize)
                        { 
                            $null = Dism.exe /image:$($driveLetter):\ /Cleanup-Image /StartComponentCleanup /ResetBase
                            $null = Get-WindowsOptionalFeature -Path "$($driveLetter):\" |
                            Where-Object State -EQ -Value 'Disabled' |
                            Disable-WindowsOptionalFeature -Remove -Path "$($driveLetter):\" @ParametersToPass
                        }
                    }
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : SysPrep : Removing PageFile and PsTemp"
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : SysPrep : Cleaning SxS"
                    MountVHDandRunBlock -vhd $SysprepImage -block $CleanupVhdBlock
                }
                #endregion
      
                #region export WIM
                if (($ChangesMade) -or (-not (Test-Path $OutputWim)) -or (-not (Test-Path $OutputVhd)))
                { 
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : WIM : Creating $OutputWim"
                    cleanupFile $OutputWim
                    MountVHDandRunBlock -ReadOnly $SysprepImage -block {
                        $nul = New-WindowsImage -CapturePath "$($driveLetter):" -ImagePath $OutputWim -Name "$TargetImage Updated $(Get-Date)" @ParametersToPass
                    }
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : WIM : removing $SysprepImage"
                    cleanupFile $SysprepImage
                }
            
                #endregion
      
                #region create output VHD
                if ((($ChangesMade) -or (-not (Test-Path $OutputVhd))) -and $output -eq 'both')
                {
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] : VHD : Creating $OutputVhd from $OutputWim"
                    cleanupFile $OutputVhd
                    $layout = 'BIOS'
                    if ($PartitionStyle -eq 'GPT')
                    {
                        $layout = 'UEFI'
                    }
                    $dynamic = $false
                    if ($vhdData.VhdType -eq 'Dynamic')
                    {
                        $dynamic = $true
                    }
                    $param = @{
                        Path       = "$OutputVhd"
                        Size       = $vhdData.Size
                        dynamic    = $dynamic
                        DiskLayout = $layout
                        force      = $true
                        SourcePath = "$OutputWim"
                    }
                    $nul = Convert-Wim2VHD @param @ParametersToPass 
                }
                #endregion
            }
        }
    }
    #endregion
}